Skip to main content

scc_cli/
commands.rs

1//! Command implementations for the `scc` CLI (docs/API_AND_INTEGRATIONS.md §4).
2
3use crate::{config_path, load_config, open_store, scc_dir};
4
5// trace:exempt reason=internal-detail
6fn engine_err(e: scc_engine::EngineError) -> crate::CliError {
7    crate::CliError::Other(e.to_string())
8}
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-init work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
13pub fn cmd_init(root: &Path) -> crate::Result<()> {
14    let dir = scc_dir(root);
15    std::fs::create_dir_all(&dir)?;
16    let cfg_path = config_path(root);
17    if !cfg_path.exists() {
18        std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
19        println!("created {}", cfg_path.display());
20    } else {
21        println!("config exists: {}", cfg_path.display());
22    }
23    // create the DB so the workspace is ready
24    let store = open_store(root)?;
25    crate::ensure_scc_ignored(root);
26    println!(
27        "initialized SCC workspace for repository '{}' at {}",
28        store.repo_name,
29        dir.display()
30    );
31    println!("next: scc index");
32    Ok(())
33}
34
35// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-index work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
36pub fn cmd_index(root: &Path, quiet: bool) -> crate::Result<()> {
37    let config = load_config(root)?;
38    let report = scc_engine::index::full(root, &config).map_err(engine_err)?;
39    if !quiet {
40        println!(
41            "indexed {} file(s) ({} changed, {} added, {} removed, {} failed) in {:.2}s",
42            report.indexed,
43            report.changed,
44            report.added,
45            report.removed,
46            report.failed,
47            report.duration_ms as f64 / 1000.0
48        );
49        println!("analysis_quality: {}", report.analysis_quality.compact_line());
50        let s = &report.scan_stats;
51        println!(
52            "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
53            s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
54        );
55    }
56    Ok(())
57}
58
59// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-index-paths work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
60pub fn cmd_index_paths(root: &Path, paths: &[String], quiet: bool) -> crate::Result<()> {
61    let config = load_config(root)?;
62    let report = scc_engine::index::refresh_paths(root, &config, paths).map_err(engine_err)?;
63    if !quiet && report.indexed > 0 {
64        println!("refreshed {} file(s)", report.indexed);
65    }
66    Ok(())
67}
68
69// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-status work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
70pub fn cmd_status(root: &Path) -> crate::Result<()> {
71    let store = open_store(root)?;
72    let s = scc_engine::status::status(&store).map_err(engine_err)?;
73    println!("Repository: {} ({})", s.repository, s.repository_id);
74    if let Some(url) = &s.remote {
75        println!("Remote: {url}");
76    }
77    if s.indexed {
78        println!("Revision: {}", s.revision);
79        if let Some(b) = &s.branch {
80            println!("Branch: {b}");
81        }
82        println!("Indexed at: {}", s.indexed_at.as_deref().unwrap_or(""));
83        let mut keys: Vec<&String> = s.stats.keys().collect();
84        keys.sort();
85        for k in keys {
86            println!("{k}: {}", s.stats[k]);
87        }
88        if s.freshness == "CURRENT" {
89            println!("freshness: CURRENT — model matches working tree");
90        } else {
91            println!(
92                "freshness: STALE — {} file(s) changed since index (run `scc index`)",
93                s.stale_count
94            );
95            for f in &s.stale_files {
96                println!("  {f}");
97            }
98        }
99        if let Some(raw) = &s.analysis_quality {
100            match serde_json::from_str::<scc_core::AnalysisQuality>(raw) {
101                Ok(q) => println!("analysis_quality: {}", q.compact_line()),
102                Err(_) => println!("analysis_quality: {raw}"),
103            }
104        }
105        if let Some(v) = &s.scan_stats {
106            // Counts only, recorded at index time; live staleness is
107            // reported above from the working-tree scan.
108            let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
109            println!(
110                "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
111                n("discovered"),
112                n("indexed"),
113                n("ignored"),
114                n("unsupported"),
115                n("oversized"),
116                n("unreadable")
117            );
118        }
119    } else {
120        println!("not indexed yet — run `scc index`");
121    }
122    Ok(())
123}
124
125// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-scan work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-NX53P4B7
126pub fn cmd_scan(root: &Path, path: Option<&str>, json: bool) -> crate::Result<()> {
127    let config = load_config(root)?;
128    let exp = scc_indexer::scan::explain_scan(root, &config.index)
129        .map_err(|e| crate::CliError::Index(scc_indexer::IndexError::Scan(e)))?;
130    if json {
131        let mut out = String::from("{\"indexed\":[");
132        for (i, f) in exp.indexed.iter().enumerate() {
133            if i > 0 {
134                out.push(',');
135            }
136            out.push_str(&format!(
137                "{{\"path\":{},\"language\":{},\"kind\":{},\"bytes\":{}}}",
138                serde_json::to_string(&f.path).unwrap_or_default(),
139                serde_json::to_string(f.language.as_str()).unwrap_or_default(),
140                serde_json::to_string(f.kind.as_str()).unwrap_or_default(),
141                f.size
142            ));
143        }
144        out.push_str("],\"skipped\":[");
145        for (i, s) in exp.skipped.iter().enumerate() {
146            if i > 0 {
147                out.push(',');
148            }
149            out.push_str(&format!(
150                "{{\"path\":{},\"reason\":{},\"rule\":{}}}",
151                serde_json::to_string(&s.path).unwrap_or_default(),
152                serde_json::to_string(s.reason).unwrap_or_default(),
153                serde_json::to_string(&s.rule).unwrap_or_default()
154            ));
155        }
156        out.push_str("]}");
157        println!("{out}");
158        return Ok(());
159    }
160    // Single path: one verdict line (exit 0 indexed, 1 skipped, 2 missing).
161    if let Some(q) = path {
162        let q = q.trim_start_matches("./");
163        if let Some(f) = exp.indexed.iter().find(|f| f.path == q) {
164            println!("{}: indexed ({} {})", f.path, f.language.as_str(), f.kind.as_str());
165            return Ok(());
166        }
167        if let Some(s) = exp.skipped.iter().find(|s| s.path == q) {
168            match &s.rule {
169                Some(rule) => println!("{q}: {} ({rule})", s.reason),
170                None => println!("{q}: {}", s.reason),
171            }
172            std::process::exit(1);
173        }
174        println!("{q}: not found (no such file in working tree)");
175        std::process::exit(2);
176    }
177    let s = &exp.stats;
178    println!(
179        "scan: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={} (live walk, not index-time counts)",
180        s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
181    );
182    if !exp.skipped.is_empty() {
183        use std::collections::BTreeMap;
184        let mut by_rule: BTreeMap<String, usize> = BTreeMap::new();
185        for sk in &exp.skipped {
186            let key = format!("{}: {}", sk.reason, sk.rule.as_deref().unwrap_or("?"));
187            *by_rule.entry(key).or_default() += 1;
188        }
189        println!("\nskips by rule:");
190        for (rule, n) in &by_rule {
191            println!("  {n:>6}  {rule}");
192        }
193    }
194    // Indexed files: top-level dir histogram (where the index weight is).
195    {
196        use std::collections::BTreeMap;
197        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
198        for f in &exp.indexed {
199            let top = f.path.split('/').next().unwrap_or(".").to_string();
200            let key = if f.path.contains('/') { top } else { "(root)".to_string() };
201            *by_dir.entry(key).or_default() += 1;
202        }
203        println!("\nindexed by top dir:");
204        let mut rows: Vec<_> = by_dir.into_iter().collect();
205        rows.sort_by_key(|r| std::cmp::Reverse(r.1));
206        for (dir, n) in rows.iter().take(15) {
207            println!("  {n:>6}  {dir}/");
208        }
209    }
210    println!("\nrerun with a path (`scc scan <path>`) for one verdict, or `--json` for the full lists.");
211    Ok(())
212}
213
214/// `scc languages` — generated support matrix. Never hand-maintain a
215/// second list in CLI copy.
216// trace:v1 id=impl.scc.cli.languages work=WORK-ripwire-lessons-phase1 satisfies=REQ-language-support-matrix
217pub fn cmd_languages() -> crate::Result<()> {
218    print!("{}", scc_core::support_matrix_markdown());
219    Ok(())
220}
221
222// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-overview work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
223pub fn cmd_overview(root: &Path, json: bool) -> crate::Result<()> {
224    let store = open_store(root)?;
225    let config = load_config(root)?;
226    let stale = crate::stale_paths(&store)?;
227    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
228    let pack = engine.context().overview().map_err(engine_err)?;
229    if json {
230        println!("{}", serde_json::to_string_pretty(&pack)?);
231    } else {
232        print!("{}", pack.content);
233    }
234    Ok(())
235}
236// trace:v1 id=impl.scc.cli work=WORK-SCC-001 satisfies=REQ-SCC-API
237
238/// `scc atlas [--budget N] [--json]` — the full System Atlas.
239// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-atlas work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
240pub fn cmd_atlas(
241    root: &Path,
242    budget: Option<usize>,
243    json: bool,
244    full: bool,
245    unbounded: bool,
246) -> crate::Result<()> {
247    let store = open_store(root)?;
248    let config = load_config(root)?;
249    let stale = crate::stale_paths(&store)?;
250    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
251    let pack = engine.context().atlas(budget, full, unbounded).map_err(engine_err)?;
252    if json {
253        println!("{}", serde_json::to_string(&pack)?);
254    } else {
255        print!("{}", pack.content);
256    }
257    Ok(())
258}
259
260/// `scc context startup [--budget N]` — the Wave 14 startup artifact:
261/// the Atlas + Surface fusion, deterministic per epoch (prompt-cache
262/// stable). Records what the session just showed in the context ledger so
263/// task deltas suppress already-visible APIs.
264// trace:exempt reason=internal-detail
265// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-startup work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
266pub fn cmd_context_startup(root: &Path, budget_tokens: Option<usize>) -> crate::Result<()> {
267    let store = open_store(root)?;
268    let config = load_config(root)?;
269    let stale = crate::stale_paths(&store)?;
270    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
271    let (_startup, text) = engine.context().startup(&scc_api::StartupRequest { budget: budget_tokens }).map_err(engine_err)?;
272    print!("{text}");
273    Ok(())
274}
275
276/// `scc surface [--task "<goal>"] [--budget N] [--explain]` — the System
277/// Surface Map: the actual callable API layer, global or task-personalized.
278/// BOTH modes run the ONE authoritative service — `build_surface` (Global
279/// or Task) — so the CLI, MCP, hermes, and the SDKs render the same
280/// artifact for the same request (no parallel pipelines). The rendered
281/// entries are recorded in the context ledger.
282// trace:exempt reason=internal-detail
283// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-surface work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
284pub fn cmd_surface(
285    root: &Path,
286    task: Option<&str>,
287    budget: Option<usize>,
288    explain: bool,
289) -> crate::Result<()> {
290    let store = open_store(root)?;
291    let config = load_config(root)?;
292    let stale = crate::stale_paths(&store)?;
293    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
294    let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
295    // Semantic scorer (SCC-071): wired in when the embed_cli rankers are
296    // available (inference.enabled + remote-model policy) and the surface
297    // is task-personalized (a scorer rates entities against a goal; the
298    // global surface has no goal). Disabled embeddings → None, and the
299    // pipeline explicitly redistributes the 10% semantic share.
300    let scorer = match task {
301        Some(goal) => {
302            let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
303            scorer
304        }
305        None => None,
306    };
307    let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
308        scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
309    let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
310    let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
311    print!("{text}");
312    Ok(())
313}
314
315/// `scc important [--limit N] [--component C] [--task G] [--json]` —
316/// the fast "where do I pay attention first" answer (audit item 3). Global
317/// mode ranks by architectural centrality (global PPR + badges); --task
318/// re-ranks by task PPR and renders TASK-CRITICAL SYMBOLS. --component
319/// filters to one component substring. No new MCP tool: the section also
320/// ships inside startup/task context.
321// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-important work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
322pub fn cmd_important(
323    root: &Path,
324    limit: usize,
325    component: Option<&str>,
326    task: Option<&str>,
327    json: bool,
328) -> crate::Result<()> {
329    let store = open_store(root)?;
330    let config = load_config(root)?;
331    let stale = crate::stale_paths(&store)?;
332    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
333    let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
334    if json {
335        println!("{}", serde_json::to_string_pretty(&entries)?);
336        return Ok(());
337    }
338    print!("{}", scc_context::surface::render_important(&entries, tasked));
339    Ok(())
340}
341
342/// `scc context structural --files <paths...> | --task "<goal>" [--budget N]` —
343/// the Structural Source product surface (fixwave Item 7): the per-file
344/// signature/structural representation of the requested files, or of the
345/// files matched to a task goal. Returns the rendered text so every
346/// transport (CLI, MCP) shares one implementation.
347// trace:exempt reason=internal-detail
348// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-structural work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
349pub fn cmd_context_structural(
350    root: &Path,
351    files: &[String],
352    task: Option<&str>,
353    budget: Option<usize>,
354) -> crate::Result<String> {
355    // Registry derivation: parse + dispatch only; the engine owns the build.
356    let out = scc_engine::invoke(
357        root,
358        "context.structural",
359        serde_json::json!({"files": files, "task": task, "budget": budget}),
360    )
361    .map_err(engine_err)?;
362    Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
363}
364
365/// THE one complete task artifact (transport parity): the enriched task
366/// pack AND its surface delta derived together, from ONE builder.
367#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
368// trace:v1 id=impl.crates-scc-cli-src-commands.task-context-artifact work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
369pub struct TaskContextArtifact {
370    pub pack: scc_context::ContextPack,
371    /// The task-personalized Surface delta (only NEW relevant APIs vs the
372    /// ledger). Empty string when the delta budget is 0.
373    pub delta: String,
374    /// Entry ids the delta rendered (ledger recording). ALWAYS serialized
375    /// (empty array, not omitted) — the public JSON contract the SDKs type.
376    #[serde(default)]
377    pub delta_ids: Vec<String>,
378    /// Actual token count of the complete rendered artifact:
379    /// `estimate_tokens(pack.content) + estimate_tokens(delta)` computed
380    /// AFTER all enrichment (Beads/Hindsight), never from stale component
381    /// estimates — the accounting the SDKs and benchmarking rely on.
382    #[serde(default)]
383    pub token_count: usize,
384}
385
386/// Build the complete task artifact: scorer + beads + hindsight enrichment
387/// for the pack, then the SAME-scorer task delta against the ledger.
388/// `hook` keeps the whole focus within the §37 1500-token cap (the delta
389/// gets what the pack leaves); an explicit budget caps the total; the
390/// default gives the delta its own `task_delta` slice.
391// trace:v1 id=impl.crates-scc-cli-src-commands.build-task-context work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
392pub fn build_task_context(
393    root: &Path,
394    goal: &str,
395    files: &[String],
396    symbols: &[String],
397    budget: Option<usize>,
398    hook: bool,
399) -> crate::Result<TaskContextArtifact> {
400    build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
401}
402
403// trace:exempt reason=internal-detail
404fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
405    TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
406}
407
408// trace:exempt reason=internal-detail
409fn build_task_context_engine(
410    root: &Path,
411    goal: &str,
412    files: &[String],
413    symbols: &[String],
414    budget: Option<usize>,
415    hook: bool,
416) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
417    // Registry derivation: the engine owns the task build; this crate only
418    // parses args and renders. One path for CLI/HTTP/MCP/SDKs (spec 2).
419    let out = scc_engine::invoke(
420        root,
421        "context.task",
422        serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
423    )?;
424    serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
425}
426
427/// pack with scorer + beads + hindsight and its post-enrichment token
428/// count. NO Surface delta, NO ContextLedger mutation, NO visibility side
429/// effects. Pack-only callers (`context compress`, [`build_task_pack`])
430/// MUST use this — NEVER [`build_task_context`], which also builds a delta
431/// and records its rendered ids in the ledger: throwing the delta away
432/// would mark Surface APIs "already visible" the agent never saw, breaking
433/// the ledger invariant (ledger = content actually shown to the agent).
434// trace:v1 id=impl.crates-scc-cli-src-commands.build-enriched-task-pack work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
435pub fn build_enriched_task_pack(
436    root: &Path,
437    goal: &str,
438    files: &[String],
439    symbols: &[String],
440    budget: Option<usize>,
441    hook: bool,
442) -> crate::Result<scc_context::ContextPack> {
443    // Registry derivation: the engine owns the pack build (spec 2).
444    let out = scc_engine::invoke(
445        root,
446        "context.task_pack",
447        serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
448    )
449    .map_err(engine_err)?;
450    // Engine returns the pack directly (ContextPack), not wrapped.
451    serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
452}
453
454/// `scc context task <goal> [--budget N] [--json] [--hook]` — the complete
455/// task focus. Text and JSON are TWO VIEWS OF ONE ARTIFACT built by
456/// [`build_task_context`]: JSON carries `{pack, delta, delta_ids}`, text
457/// prints pack content followed by the delta — identical derivation, no
458/// transport downgrades quality.
459// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-task work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
460pub fn cmd_context_task(
461    root: &Path,
462    goal: &str,
463    files: &[String],
464    symbols: &[String],
465    budget: Option<usize>,
466    json: bool,
467    hook: bool,
468) -> crate::Result<()> {
469    if hook {
470        // Wave 2 (§37): UserPromptSubmit injects a task focus only when
471        // context.inject_task_focus is enabled; otherwise silent no-op.
472        let config = load_config(root)?;
473        if !config.context.inject_task_focus {
474            return Ok(());
475        }
476    }
477    let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
478    if json {
479        println!("{}", serde_json::to_string_pretty(&artifact)?);
480    } else {
481        print!("{}", artifact.pack.content);
482        print!("\n{}", artifact.delta);
483    }
484    Ok(())
485}
486
487
488/// half built by the pure [`build_enriched_task_pack`] — NO delta, NO
489/// ledger side effects. New transports should call
490/// [`build_task_context`] directly so the delta ships with the pack.
491// trace:exempt reason=internal-detail
492pub fn build_task_pack(
493    root: &Path,
494    goal: &str,
495    files: &[String],
496    symbols: &[String],
497    budget: Option<usize>,
498) -> crate::Result<scc_context::ContextPack> {
499    build_enriched_task_pack(root, goal, files, symbols, budget, false)
500}
501/// The complete task artifact as JSON — the same derivation as CLI text
502/// (`{pack, delta, delta_ids}`); serialization is the only difference.
503// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-task-json work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-complete-task-context-identical-across-transports
504pub fn cmd_context_task_json(
505    root: &Path,
506    goal: &str,
507    files: &[String],
508    symbols: &[String],
509    budget: Option<usize>,
510) -> crate::Result<String> {
511    Ok(serde_json::to_string_pretty(&build_task_context(
512        root, goal, files, symbols, budget, false,
513    )?)?)
514}
515
516/// `scc context docs <dependency>` — external library docs via Context7
517/// (labeled external; never mixed with repository facts).
518// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-docs work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
519pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
520    print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
521    Ok(())
522}
523
524/// Subagent context policy (SCC-107, docs/API_AND_INTEGRATIONS.md §5):
525/// a narrower, tighter-budget task pack with explicit scope boundaries so
526/// delegated agents start from the same system model without re-deriving it.
527// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-subagent work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
528pub fn cmd_context_subagent(
529    root: &Path,
530    goal: &str,
531    files: &[String],
532    symbols: &[String],
533    budget: Option<usize>,
534    json: bool,
535) -> crate::Result<()> {
536    let store = open_store(root)?;
537    let config = load_config(root)?;
538    let stale = crate::stale_paths(&store)?;
539    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
540    let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
541    if json {
542        println!("{}", serde_json::to_string_pretty(&pack)?);
543    } else {
544        print!("{}", pack.content);
545    }
546    Ok(())
547}
548
549// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-component work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
550pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
551    let store = open_store(root)?;
552    let config = load_config(root)?;
553    let stale = crate::stale_paths(&store)?;
554    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
555    let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
556    if json {
557        println!("{}", serde_json::to_string_pretty(&pack)?);
558    } else {
559        print!("{}", pack.content);
560    }
561    Ok(())
562}
563
564// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-context-flow work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
565pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
566    let store = open_store(root)?;
567    let config = load_config(root)?;
568    let stale = crate::stale_paths(&store)?;
569    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
570    let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
571    if json {
572        println!("{}", serde_json::to_string_pretty(&pack)?);
573    } else {
574        print!("{}", pack.content);
575    }
576    Ok(())
577}
578
579// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-impact work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
580pub fn cmd_impact(
581    root: &Path,
582    files: &[String],
583    symbols: &[String],
584    diff: Option<&str>,
585    json: bool,
586    unbounded: bool,
587) -> crate::Result<()> {
588    let store = open_store(root)?;
589    let config = load_config(root)?;
590    let stale = crate::stale_paths(&store)?;
591    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
592    let pack = engine.context().impact(&scc_api::ImpactRequest { files: files.to_vec(), symbols: symbols.to_vec(), diff: diff.map(|s| s.to_string()), unbounded }).map_err(engine_err)?;
593    if json {
594        println!("{}", serde_json::to_string_pretty(&pack)?);
595    } else {
596        print!("{}", pack.content);
597    }
598    Ok(())
599}
600
601// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-verify work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
602pub fn cmd_verify(
603    root: &Path,
604    warnings_only: bool,
605    json: bool,
606    unbounded: bool,
607) -> crate::Result<()> {
608    let store = open_store(root)?;
609    let config = load_config(root)?;
610    let stale = crate::stale_paths(&store)?;
611    let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
612    let pack = engine.context().verify(unbounded).map_err(engine_err)?;
613    if warnings_only {
614        for w in &pack.warnings {
615            println!("⚠ {w}");
616        }
617        return Ok(());
618    }
619    if json {
620        println!("{}", serde_json::to_string_pretty(&pack)?);
621        return Ok(());
622    }
623    print!("{}", pack.content);
624    Ok(())
625}
626
627// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-drift work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
628pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
629    let store = open_store(root)?;
630    let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
631    if json {
632        println!("{}", serde_json::to_string_pretty(&findings)?);
633    } else {
634        if findings.is_empty() {
635            println!("no drift findings");
636        }
637        for f in &findings {
638            println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
639        }
640    }
641    Ok(())
642}
643
644// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-system work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
645pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
646    let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
647    if json {
648        println!("{}", serde_json::to_string_pretty(&all)?);
649        return Ok(());
650    }
651    println!(
652        "system: {} members, {} stitches",
653        members.len(),
654        all.len()
655    );
656    for m in &members {
657        println!("member {} ({})", m.repo_id, m.root.display());
658    }
659    for s in &all {
660        println!(
661            "[{:?}/{:?}] {} ({} ends)",
662            s.kind,
663            s.match_kind,
664            s.key,
665            s.ends.len()
666        );
667        for e in &s.ends {
668            match e.role {
669                Some(role) => println!("  {} {} ({role:?})", e.repo_id, e.entity_id),
670                None => println!("  {} {}", e.repo_id, e.entity_id),
671            }
672        }
673    }
674    Ok(())
675}
676
677// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-history work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
678pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
679    let store = open_store(root)?;
680    let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
681    if json {
682        println!("{}", serde_json::to_string_pretty(&revs)?);
683        return Ok(());
684    }
685    if revs.is_empty() {
686        println!("no graph revisions recorded");
687        return Ok(());
688    }
689    for r in &revs {
690        println!(
691            "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
692            r.rev,
693            r.base_rev,
694            r.entity_count,
695            r.rel_count,
696            r.file_count,
697            &r.source_hash[..r.source_hash.len().min(12)],
698            r.extractor_version,
699            r.created_at,
700        );
701    }
702    Ok(())
703}
704
705// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
706pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
707    let store = open_store(root)?;
708    let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
709    if json {
710        println!("{}", serde_json::to_string_pretty(&d)?);
711        return Ok(());
712    }
713    // Bounded human rendering: counts plus the first 20 ids per class.
714    println!("diff rev {from}..{to}:");
715    for (label, ids) in [
716        ("added entities", &d.added_entities),
717        ("removed entities", &d.removed_entities),
718        ("modified entities", &d.modified_entities),
719        ("added relationships", &d.added_relationships),
720        ("removed relationships", &d.removed_relationships),
721        ("modified relationships", &d.modified_relationships),
722    ] {
723        println!("  {label}: {}", ids.len());
724        for id in ids.iter().take(20) {
725            println!("    {id}");
726        }
727        if ids.len() > 20 {
728            println!("    … ({} more)", ids.len() - 20);
729        }
730    }
731    if !d.modified_kinds.is_empty() {
732        let kinds: Vec<String> = d
733            .modified_kinds
734            .iter()
735            .map(|(k, v)| format!("{k}:{v}"))
736            .collect();
737        println!("  modified kinds: {}", kinds.join(", "));
738    }
739    Ok(())
740}
741
742// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-save work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
743pub fn cmd_snapshot_save(
744    root: &Path,
745    task: &str,
746    budget: Option<usize>,
747    json: bool,
748) -> crate::Result<()> {
749    let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
750    if json {
751        println!("{}", serde_json::to_string_pretty(&snap)?);
752    } else {
753        println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
754    }
755    Ok(())
756}
757
758// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-show work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
759pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
760    let store = open_store(root)?;
761    match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
762        Some(s) => print!("{}", s.artifact),
763        None => println!("no snapshot {id}"),
764    }
765    Ok(())
766}
767
768// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snapshot-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
769pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
770    let store = open_store(root)?;
771    match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
772        Some(d) => {
773            if json {
774                println!("{}", serde_json::to_string_pretty(&d)?);
775            } else {
776                println!(
777                    "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
778                    d.snapshot_revision,
779                    d.current_revision,
780                    d.still_valid.len(),
781                    d.invalidated.len(),
782                    d.modified_entities.len(),
783                    if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
784                );
785                for (label, ids) in [
786                    ("invalidated", &d.invalidated),
787                    ("modified entities", &d.modified_entities),
788                    ("changed relationships", &d.changed_relationships),
789                    ("changed contracts", &d.changed_contracts),
790                    ("changed state", &d.changed_state),
791                    ("changed flows", &d.changed_flows),
792                ] {
793                    if ids.is_empty() {
794                        continue;
795                    }
796                    println!("  {label} ({}):", ids.len());
797                    for f in ids.iter().take(20) {
798                        println!("    - {f}");
799                    }
800                    if ids.len() > 20 {
801                        println!("    … ({} more)", ids.len() - 20);
802                    }
803                }
804            }
805        }
806        None => println!("no snapshot {id}"),
807    }
808    Ok(())
809}
810
811// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-export work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
812pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
813    let store = open_store(root)?;
814    match format {
815        "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
816        "system-ir.jsonl" => {
817            let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
818            for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
819                println!("{line}");
820            }
821        }
822        "ccg" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::ccg(&scc_engine::exports::system_ir(&store).map_err(engine_err)?).map_err(engine_err)?)?),
823        "flow-graphs.json" => {
824            println!(
825                "{}",
826                serde_json::to_string_pretty(&store.flow_graphs()?)?
827            )
828        }
829        "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
830        other => {
831            return Err(crate::CliError::Other(format!(
832                "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
833            )))
834        }
835    }
836    Ok(())
837}
838
839// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-query work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
840// trace:v1 id=impl.cli.query.fallback work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
841pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
842    let store = open_store(root)?;
843    let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
844    println!("— entities —");
845    for e in &hit.entities {
846        println!("{} [{}]", e.name, e.kind);
847    }
848    println!("— symbols —");
849    for (name, sig, kind, file, line) in &hit.symbols {
850        if *line > 0 {
851            println!("{name} ({kind}) {file}:{line} {sig}");
852        } else {
853            println!("{name} ({kind}) {file} {sig}");
854        }
855    }
856    Ok(())
857}
858
859// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-list-components work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
860pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
861    let store = open_store(root)?;
862    for c in scc_engine::graph::components(&store).map_err(engine_err)? {
863        println!("{}", c.name);
864    }
865    Ok(())
866}
867
868// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-list-flows work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
869pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
870    let store = open_store(root)?;
871    for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
872        println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
873    }
874    Ok(())
875}
876
877/// `scc cochange`: print the git co-change pairs (files changed together
878/// across commits) and, when an indexed store exists, enrich its components
879/// with the signal.
880// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-cochange work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
881pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
882    let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
883    if pairs.is_empty() {
884        println!("no co-change pairs with >= {min_commits} shared commits");
885    } else {
886        println!("co-change pairs (>= {min_commits} shared commits):");
887        for p in &pairs {
888            println!("  {} <-> {} ×{}", p.a, p.b, p.commits);
889        }
890    }
891    if n > 0 {
892        println!("enriched {n} components with co-change signal");
893    }
894    Ok(())
895}
896
897/// scc verify --graph-invariants: structural checks for CI.
898// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-check-invariants work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
899pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
900    let store = open_store(root)?;
901    let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
902    for v in &violations {
903        println!("{}", v.message);
904    }
905    Ok(violations.is_empty())
906}
907
908/// `scc ci check` (docs/DEPLOYMENT_AND_INFRA.md §3, EPIC-180 CI policies):
909/// graph invariants + drift severity policy. Exits nonzero on violation.
910// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-ci-check work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
911pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
912    let store = open_store(root)?;
913    let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
914    for v in &violations {
915        println!("{}", v.message);
916    }
917    let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
918    for l in &lines {
919        // invariant lines already printed above; print only ci lines + summary
920        if l.starts_with("[ci:") || *l == "ci check passed" {
921            println!("{l}");
922        }
923    }
924    Ok(ok)
925}
926
927// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-checkpoint-save work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
928pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
929    let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
930    if json {
931        println!("{}", serde_json::to_string(&data)?);
932    } else {
933        println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
934    }
935    Ok(())
936}
937
938// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-checkpoint-load work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
939pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
940    if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
941        print!("{content}");
942    } else if !inject {
943        println!("no checkpoint found");
944    }
945    Ok(())
946}
947
948// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-watch work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
949pub fn cmd_watch(root: &Path) -> crate::Result<()> {
950    crate::httpd::watch_loop(root)
951}
952
953// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-serve work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
954pub fn cmd_serve(root: &Path) -> crate::Result<()> {
955    crate::httpd::serve(root)
956}
957
958// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-mcp work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
959pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
960    crate::mcp::serve_stdio(root)
961}
962
963// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-setup-claude work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
964pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
965    crate::plugin::install(root)
966}
967
968// Setup targets for harness auto-detection. Hermes is deliberately absent:
969// it installs into a home directory outside the repo (different trust
970// domain), so it stays an explicit opt-in.
971#[derive(Debug, Clone, Copy, PartialEq, Eq)]
972// trace:exempt reason=internal-detail
973pub enum SetupHarness {
974    Claude,
975    Codex,
976    Opencode,
977    Omp,
978    Pi,
979}
980
981// Pure detection: a harness counts as present when its binary is on PATH
982// or its home/config dir exists (or, for project-local OMP/Pi, the repo
983// already carries the harness dir). `path_dirs` is PATH split already so
984// tests never touch the process environment.
985// trace:exempt reason=internal-detail
986pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
987    let on_path = |bin: &str| {
988        path_dirs
989            .iter()
990            .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
991    };
992    let mut out = Vec::new();
993    if on_path("claude") || home.join(".claude").is_dir() {
994        out.push(SetupHarness::Claude);
995    }
996    if on_path("codex") || home.join(".codex").is_dir() {
997        out.push(SetupHarness::Codex);
998    }
999    if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
1000        out.push(SetupHarness::Opencode);
1001    }
1002    if on_path("omp") || root.join(".omp").is_dir() {
1003        out.push(SetupHarness::Omp);
1004    }
1005    if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
1006        out.push(SetupHarness::Pi);
1007    }
1008    out
1009}
1010
1011// trace:exempt reason=internal-detail
1012fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
1013    match h {
1014        SetupHarness::Claude => cmd_setup_claude(root),
1015        SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
1016        SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
1017        SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
1018        SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
1019    }
1020}
1021
1022/// `scc setup` (no subcommand): install for every detected harness and
1023/// print one summary, including the manual steps setup cannot perform
1024/// (Codex hooks live in user scope). `scc setup all` skips detection and
1025/// installs for every harness unconditionally.
1026// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-setup-detected work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1027pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
1028    let targets: Vec<SetupHarness> = if all {
1029        vec![
1030            SetupHarness::Claude,
1031            SetupHarness::Codex,
1032            SetupHarness::Opencode,
1033            SetupHarness::Omp,
1034            SetupHarness::Pi,
1035        ]
1036    } else {
1037        let home = std::env::var_os("HOME")
1038            .map(PathBuf::from)
1039            .unwrap_or_else(|| PathBuf::from("/"));
1040        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
1041            .map(|p| std::env::split_paths(&p).collect())
1042            .unwrap_or_default();
1043        detect_harnesses(&home, &path_dirs, root)
1044    };
1045    if targets.is_empty() {
1046        println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
1047        println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
1048        println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
1049        return Ok(());
1050    }
1051    for h in &targets {
1052        println!("=== {:?} ===", h);
1053        install_harness(root, *h)?;
1054        println!();
1055    }
1056    println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
1057    if targets.contains(&SetupHarness::Codex) {
1058        println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
1059    }
1060    Ok(())
1061}
1062
1063// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-ingest-runtime work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1064pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
1065    scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
1066    println!("accepted");
1067    Ok(())
1068}
1069
1070/// Declared capability scope per adapter (docs/SECURITY.md §6: the doc
1071/// defines the manifest dimensions — FS scope, network, subprocess,
1072/// credentials — but has no per-adapter table, so the assignments are
1073/// pinned inline here). Importers read repo-local files only; Context7 runs
1074/// an MCP server over stdio via npx (subprocess) that makes network calls.
1075// trace:v1 id=impl.crates-scc-cli-src-commands.adapter-scope work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1076fn adapter_scope(name: &str) -> &'static str {
1077    match name {
1078        "context7" => "network+subprocess(npx)",
1079        "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
1080        _ => "filesystem",
1081    }
1082}
1083
1084/// Compute the configured-adapter scope listing from THE Integration
1085/// Registry: every row whose config key is enabled (or needs no config for
1086/// on-demand import), in fixed registry order. `narsil` never appears: it is
1087/// a `ccg` alias, resolved by `resolve_integration`. Both `scc adapters`
1088/// views read one registry, so they describe one universe.
1089// trace:v1 id=impl.crates-scc-cli-src-commands.configured-adapters work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1090fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1091    use scc_indexer::adapters::IntegrationCategory;
1092    let config = load_config(root)?;
1093    let enabled = |key: Option<&str>| -> bool {
1094        match key {
1095            None => true,
1096            Some("serena") => config.integrations.serena,
1097            Some("beads") => config.integrations.beads,
1098            Some("hindsight") => config.integrations.hindsight,
1099            Some("gitnexus") => config.integrations.gitnexus,
1100            Some("context7_command") => !config.integrations.context7_command.is_empty(),
1101            // Unknown keys fail closed: a new config flag must be wired
1102            // here explicitly, never silently treated as enabled.
1103            Some(_) => false,
1104        }
1105    };
1106    Ok(scc_indexer::adapters::integration_registry()
1107        .into_iter()
1108        .filter(|d| {
1109            d.category != IntegrationCategory::CompatibilityOnly
1110                && d.category != IntegrationCategory::InternalPass
1111                && d.category != IntegrationCategory::AgentIntegration
1112                && enabled(d.config_key)
1113        })
1114        .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1115        .collect())
1116}
1117
1118/// `scc adapters` — list enabled adapters with their declared capability
1119/// scope (security audit; docs/SECURITY.md §6). `--json` dumps the full
1120/// capability manifests instead.
1121// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-adapters work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1122pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1123    let manifests = scc_indexer::adapters::adapter_manifests();
1124    if json {
1125        println!("{}", serde_json::to_string_pretty(&manifests)?);
1126        return Ok(());
1127    }
1128    for (name, scope) in configured_adapters(root)? {
1129        println!("adapter: {name}  scope: {scope}");
1130    }
1131    Ok(())
1132}
1133
1134/// `scc doctor` — integration health from THE Integration Registry
1135/// (`scc_indexer::adapters::integration_registry`), offline, read-only and
1136/// fast. Default never touches the network and never spawns a subprocess:
1137/// every row is answered from local state only (config flags, binary on
1138/// PATH, files present, evidence already in the graph). `--deep` may start
1139/// LOCAL subprocesses (pyright/tsserver handshakes); `--network` may test
1140/// remote endpoints (Context7). `--strict` turns warnings into a nonzero
1141/// exit; `--json` emits the machine-readable report.
1142///
1143/// Honesty rule: a row reports IMPLEMENTED / CONFIGURED / REACHABLE /
1144/// CONTRIBUTING separately. A config boolean never implies contribution —
1145/// contribution means facts of that integration's evidence kind are actually
1146/// in the graph (or a handshake succeeded under --deep/--network).
1147// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-doctor work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1148pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1149    let store = open_store(root)?;
1150    let config = load_config(root)?;
1151    let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1152    let rows = rep.integrations;
1153    let agent_rows = rep.agents;
1154    let mut warnings = rep.warnings;
1155    let (scc_version, rev, stale_len, dangling, stats) =
1156        (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1157    if json {
1158        println!(
1159            "{}",
1160            serde_json::to_string_pretty(&serde_json::json!({
1161                "scc": scc_version,
1162                "revision": rev,
1163                "stale_files": stale_len,
1164                "dangling_edges": dangling,
1165                "stats": stats,
1166                "integrations": rows,
1167                "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1168                "warnings": warnings,
1169            }))?
1170        );
1171    } else {
1172        println!("SCC Doctor");
1173        println!("==========");
1174        println!();
1175        println!("CORE");
1176        println!("  scc        {scc_version}");
1177        println!("  revision   {rev}");
1178        println!("  stale      {} file(s)", stale_len);
1179        println!("  dangling   {dangling} edge(s) (see `scc check-invariants`)");
1180        println!("  entities   {}", stats.get("entities").copied().unwrap_or(0));
1181        println!();
1182        println!("INTEGRATIONS");
1183        for r in &rows {
1184            let mark = if r.contributing {
1185                "✓"
1186            } else if r.configured {
1187                "!"
1188            } else {
1189                "-"
1190            };
1191            let reach = match r.reachable {
1192                Some(true) => "reachable; ",
1193                Some(false) => "UNREACHABLE; ",
1194                None => "",
1195            };
1196            println!("  {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1197        }
1198        println!();
1199        println!("AGENTS");
1200        for a in &agent_rows {
1201            println!("  {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1202        }
1203        println!();
1204        if warnings == 0 {
1205            println!("SUMMARY  PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1206        } else {
1207            println!("SUMMARY  {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1208        }
1209    }
1210    if dangling > 0 {
1211        warnings += 1;
1212    }
1213    // `scc check-invariants` remains the CI gate for graph integrity;
1214    // doctor reports health (warnings) by default and fails only on
1215    // --strict, so informational runs stay exit-0.
1216    Ok(!(strict && warnings > 0))
1217}
1218
1219/// `scc lessons add <text>` — append one durable lesson to
1220/// `<root>/.scc/lessons.jsonl` (the Hindsight memory bank). The bank is
1221/// ingested into the System IR with `scc import hindsight .scc/lessons.jsonl`.
1222// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-lessons-add work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1223pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1224    let dir = scc_dir(root);
1225    std::fs::create_dir_all(&dir)?;
1226    let path = dir.join("lessons.jsonl");
1227    let n = std::fs::read_to_string(&path)
1228        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1229        .unwrap_or(0);
1230    let id = format!("lesson-{}", n + 1);
1231    let record = serde_json::json!({
1232        "id": id,
1233        "text": text,
1234        "created_at": scc_core::now_rfc3339(),
1235    });
1236    let mut f = std::fs::OpenOptions::new()
1237        .create(true)
1238        .append(true)
1239        .open(&path)
1240        .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1241    writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1242    println!(
1243        "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1244        path.display()
1245    );
1246    Ok(())
1247}
1248
1249/// `scc lessons` — list stored lessons from the System IR (most important
1250/// first, same ordering as the context-pack enrichment).
1251// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-lessons-list work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1252pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1253    let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1254    if lessons.is_empty() {
1255        println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1256        return Ok(());
1257    }
1258    for (content, tags) in lessons {
1259        let tag_str = if tags.is_empty() {
1260            String::new()
1261        } else {
1262            format!(" [{}]", tags.join(", "))
1263        };
1264        println!("- {content}{tag_str}");
1265    }
1266    Ok(())
1267}
1268
1269/// `scc beads` — list active (in-progress) tasks from `.beads/issues.jsonl`
1270/// (task state, not system facts).
1271// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-beads work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1272pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1273    let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1274    if active.is_empty() {
1275        println!("no active beads tasks (checked .beads/issues.jsonl)");
1276        return Ok(());
1277    }
1278    println!("active beads tasks:");
1279    for t in active {
1280        println!("- {t}");
1281    }
1282    Ok(())
1283}
1284
1285// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-import work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1286pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1287    let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1288    // P0: imported evidence changes system truth — the engine already bumped
1289    // the evidence epoch and recompiled the derived layer.
1290    println!(
1291        "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1292        report.symbols, report.calls, report.imports, report.errors
1293    );
1294    Ok(())
1295}
1296
1297// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-runtime-status work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1298pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1299    let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1300    if json {
1301        println!("{}", serde_json::to_string_pretty(&edges)?);
1302        return Ok(());
1303    }
1304    if edges.is_empty() {
1305        println!("no runtime observations ingested");
1306    }
1307    let total: u64 = edges.iter().map(|e| e.count).sum();
1308    let errs: u64 = edges.iter().map(|e| e.errors).sum();
1309    println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1310    for e in &edges {
1311        println!(
1312            "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1313            e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1314        );
1315    }
1316    Ok(())
1317}
1318
1319// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-runtime-reconcile work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1320pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1321    let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1322    if json {
1323        println!("{}", serde_json::to_string_pretty(&rec)?);
1324        return Ok(());
1325    }
1326    println!("static-vs-observed reconciliation");
1327    println!("  matched:             {}", rec.matched.len());
1328    println!("  observed not static: {}", rec.observed_not_static.len());
1329    println!("  static not observed: {}", rec.static_not_observed.len());
1330    for e in &rec.matched {
1331        println!("  [matched] {e}");
1332    }
1333    for e in &rec.observed_not_static {
1334        println!("  [runtime-only] {e}");
1335    }
1336    for e in &rec.static_not_observed {
1337        println!("  [static-only] {e}");
1338    }
1339    Ok(())
1340}
1341
1342/// `scc diagram` — SCC-native architecture diagram (SPEC-SCC-VIEWER §1).
1343/// L1 nodes plus capped architectural edges plus flow subgraphs, as Mermaid
1344/// (default) or dependency-free SVG. Deterministic: same index, same bytes.
1345// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-diagram work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1346pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1347    let store = open_store(root)?;
1348    if store.snapshot_status()?.is_none() {
1349        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1350    }
1351    // Registry derivation: the engine owns the model + rendering; the CLI
1352    // parses args, writes files, and prints (spec section 2). The store is
1353    // already open for the staleness check — derive directly instead of
1354    // re-entering through invoke (which would reopen it).
1355    let v = scc_engine::exports::diagram(&store, format).map_err(engine_err)?;
1356    let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1357    if let Some(path) = out {
1358        std::fs::write(path, text)?;
1359        println!(
1360            "diagram: {} nodes, {} edges, {} flows -> {path}",
1361            v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1362            v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1363            v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1364        );
1365    } else {
1366        print!("{text}");
1367    }
1368    Ok(())
1369}
1370
1371/// `scc snap` — Snapcompact-style bitmap export (SPEC-SCC-VIEWER §3).
1372/// Default OFF: requires `--out` text plus `--png` (or config
1373/// `context.snap_enabled`) to render; otherwise prints the map text and the
1374/// pinned Pillow recipe so the operator sees exactly what would ship.
1375// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-snap work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1376pub fn cmd_snap(
1377    root: &Path,
1378    out: Option<&str>,
1379    max_chars: usize,
1380    png: Option<&str>,
1381    color: bool,
1382) -> crate::Result<()> {
1383    let store = open_store(root)?;
1384    if store.snapshot_status()?.is_none() {
1385        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1386    }
1387    let config = load_config(root)?;
1388    let map = crate::viewer::map_text(&store, max_chars)?;
1389    let rows = map.lines().count();
1390    let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1391    let render = png.is_some() || config.context.snap_enabled;
1392    if let Some(path) = out {
1393        std::fs::write(path, &map)?;
1394    } else {
1395        print!("{map}");
1396        if !map.ends_with('\n') {
1397            println!();
1398        }
1399    }
1400    if color {
1401        eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1402    }
1403    println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1404    if render {
1405        let dest = png.unwrap_or("scc-snap.png");
1406        render_snap_png(&map, dest)?;
1407        println!("snap png -> {dest}");
1408    } else {
1409        println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1410        println!("{}", crate::viewer::snap_recipe());
1411    }
1412    Ok(())
1413}
1414
1415/// Render the map text to PNG via the pinned Pillow recipe. Shells out to
1416/// `python3` only when rendering was explicitly requested; falls back to
1417/// printing the recipe path when Pillow is missing.
1418// trace:v1 id=impl.crates-scc-cli-src-commands.render-snap-png work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1419fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1420    // Fail fast with an actionable error when Pillow is missing (CI has no
1421    // PIL): the operator installs Pillow or uses the printed recipe.
1422    let probe = std::process::Command::new("python3")
1423        .arg("-c")
1424        .arg("import PIL")
1425        .output()
1426        .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1427    if !probe.status.success() {
1428        return Err(crate::CliError::Other(
1429            "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1430        ));
1431    }
1432    let dir = tempfile::TempDir::new()?;
1433    let map_path = dir.path().join("map.txt");
1434    let recipe_path = dir.path().join("snap.py");
1435    std::fs::write(&map_path, map)?;
1436    std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1437    let out = std::process::Command::new("python3")
1438        .arg(&recipe_path)
1439        .arg(&map_path)
1440        .arg(dest)
1441        .output()
1442        .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1443    if !out.status.success() {
1444        return Err(crate::CliError::Other(format!(
1445            "snap: Pillow render failed: {}",
1446            String::from_utf8_lossy(&out.stderr).trim()
1447        )));
1448    }
1449    Ok(())
1450}
1451
1452/// `scc view` — local web viewer (SPEC-SCC-VIEWER §2). Serves the loopback
1453/// viewer routes on an ephemeral port (or `--port`), then opens the
1454/// browser unless `--no-open`.
1455// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-view work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
1456pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1457    let store = open_store(root)?;
1458    if store.snapshot_status()?.is_none() {
1459        return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1460    }
1461    let addr = match port {
1462        Some(p) => format!("127.0.0.1:{p}"),
1463        None => "127.0.0.1:0".to_string(),
1464    };
1465    let server = tiny_http::Server::http(&addr)
1466        .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1467    let bound = server.server_addr();
1468    let url = format!("http://{bound}/");
1469    println!("scc view: {url} (root {})", root.display());
1470    if !no_open {
1471        #[cfg(target_os = "macos")]
1472        let _ = std::process::Command::new("open").arg(&url).spawn();
1473        #[cfg(target_os = "linux")]
1474        let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1475    }
1476    for request in server.incoming_requests() {
1477        let url_path = request.url().to_string();
1478        let method = request.method().clone();
1479        if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1480            let store = match open_store(root) {
1481                Ok(s) => s,
1482                Err(e) => {
1483                    let _ = request.respond(
1484                        tiny_http::Response::from_string(format!("store error: {e}"))
1485                            .with_status_code(500),
1486                    );
1487                    continue;
1488                }
1489            };
1490            let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1491            let response = tiny_http::Response::from_string(body)
1492                .with_status_code(status)
1493                .with_header(
1494                    tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1495                        .unwrap(),
1496                );
1497            let _ = request.respond(response);
1498            continue;
1499        }
1500        // Viewer-only server: the daemon (`scc serve`) owns /v1/*.
1501        // Anything else is a 404 so typos fail loudly, not silently.
1502        let _ = request.respond(
1503            tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1504                .with_status_code(404),
1505        );
1506    }
1507    Ok(())
1508}
1509
1510/// `scc rpc --stdio`: structured JSON-RPC (SDK subprocess mode).
1511// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-rpc work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1512pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1513    scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1514}
1515
1516/// `scc operations [--describe ID]`: introspection over the registry.
1517// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-operations work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1518pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1519    if let Some(id) = describe {
1520        match scc_engine::ops::describe(id) {
1521            Some(d) => println!("{}", serde_json::to_string_pretty(&d)?),
1522            None => println!("unknown operation '{id}' (see `scc operations`)"),
1523        }
1524        return Ok(());
1525    }
1526    println!("Operation                 Mutation  Stream  Description");
1527    println!("----------------------------------------------------------------");
1528    for d in scc_engine::ops::OPERATIONS {
1529        println!("{:<26} {:<9} {:<7} {}", d.id, format!("{:?}", d.mutation), d.streaming, d.description);
1530    }
1531    Ok(())
1532}
1533
1534/// `scc plugin list`: enabled plugins with lock entries (engine owns the set).
1535// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-list work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1536pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1537    let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1538    println!("{}", serde_json::to_string_pretty(&out)?);
1539    Ok(())
1540}
1541
1542/// `scc plugin describe <id>`.
1543// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-describe work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1544pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1545    let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1546    println!("{}", serde_json::to_string_pretty(&out)?);
1547    Ok(())
1548}
1549
1550/// `scc plugin doctor`.
1551// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-doctor work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1552pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1553    let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1554    println!("{}", serde_json::to_string_pretty(&out)?);
1555    Ok(())
1556}
1557
1558/// `scc plugin lock`: write .scc/plugins.lock from the live set.
1559// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-lock work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1560pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1561    let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1562    println!("{}", serde_json::to_string_pretty(&out)?);
1563    Ok(())
1564}
1565
1566/// `scc plugin check`: verify live plugins against .scc/plugins.lock.
1567// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-check work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1568pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1569    let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1570    println!("{}", serde_json::to_string_pretty(&out)?);
1571    if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1572        return Err(crate::CliError::Other(format!(
1573            "plugin lock drift: {}",
1574            out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1575        )));
1576    }
1577    Ok(())
1578}
1579
1580/// `scc plugin invoke <operation> [json-input]`.
1581// trace:v1 id=impl.crates-scc-cli-src-commands.cmd-plugin-invoke work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1582pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1583    let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1584    let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1585    println!("{}", serde_json::to_string_pretty(&out)?);
1586    Ok(())
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591
1592    #[test]
1593    // trace:exempt reason=unit-test
1594    fn ensure_scc_ignored_keeps_intent_committable() {
1595        let dir = tempfile::TempDir::new().unwrap();
1596        let root = dir.path();
1597        crate::ensure_scc_ignored(root);
1598        let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1599        assert!(gi.contains(".scc/*"), "{gi}");
1600        assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1601        crate::ensure_scc_ignored(root);
1602        let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1603        assert_eq!(gi, gi2, "idempotent");
1604        std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
1605        crate::ensure_scc_ignored(root);
1606        let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1607        assert!(gi3.contains("target/"), "{gi3}");
1608        assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
1609        assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
1610    }
1611
1612    #[test]
1613    // trace:exempt reason=unit-test
1614    fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
1615        let home = tempfile::TempDir::new().unwrap();
1616        let bindir = home.path().join("bin");
1617        std::fs::create_dir_all(&bindir).unwrap();
1618        std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
1619        std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
1620        let root = tempfile::TempDir::new().unwrap();
1621        std::fs::create_dir_all(root.path().join(".omp")).unwrap();
1622        let found = detect_harnesses(
1623            home.path(),
1624            &[bindir],
1625            root.path(),
1626        );
1627        assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
1628        assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
1629        assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
1630        assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
1631        assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
1632    }
1633
1634    #[test]
1635    // trace:exempt reason=unit-test
1636    fn detect_harnesses_empty_when_nothing_present() {
1637        let home = tempfile::TempDir::new().unwrap();
1638        let root = tempfile::TempDir::new().unwrap();
1639        let found = detect_harnesses(home.path(), &[], root.path());
1640        assert!(found.is_empty(), "{found:?}");
1641    }
1642
1643    use super::*;
1644
1645
1646    #[test]
1647// trace:v1 id=impl.crates-scc-cli-src-commands.lessons-add-appends-jsonl-lines work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1648    fn lessons_add_appends_jsonl_lines() {
1649        let dir = tempfile::TempDir::new().unwrap();
1650        let root = dir.path().join("repo");
1651        std::fs::create_dir_all(&root).unwrap();
1652        cmd_lessons_add(&root, "first lesson").unwrap();
1653        cmd_lessons_add(&root, "second lesson").unwrap();
1654        let path = root.join(".scc/lessons.jsonl");
1655        let text = std::fs::read_to_string(&path).unwrap();
1656        let lines: Vec<&str> = text.lines().collect();
1657        assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
1658        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1659        assert_eq!(first["id"], "lesson-1");
1660        assert_eq!(first["text"], "first lesson");
1661        assert!(first["created_at"].is_string());
1662        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1663        assert_eq!(second["id"], "lesson-2");
1664        assert_eq!(second["text"], "second lesson");
1665        // the shape must be ingestable by the hindsight adapter
1666        let lesson: scc_indexer::adapters::hindsight::Lesson =
1667            serde_json::from_str(lines[0]).unwrap();
1668        assert_eq!(lesson.body(), "first lesson");
1669    }
1670
1671    #[test]
1672// trace:v1 id=impl.crates-scc-cli-src-commands.adapters-lists-configured-scope work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
1673    fn adapters_lists_configured_scope() {
1674        let dir = tempfile::TempDir::new().unwrap();
1675        let root = dir.path().join("repo");
1676        std::fs::create_dir_all(root.join(".scc")).unwrap();
1677        std::fs::write(
1678            root.join(".scc/config.yaml"),
1679            "schema: 1\nintegrations:\n  beads: true\n  context7_command: \"npx -y @upstash/context7-mcp\"\n",
1680        )
1681        .unwrap();
1682        let listing = configured_adapters(&root).unwrap();
1683        assert!(
1684            listing.contains(&("beads".to_string(), "filesystem")),
1685            "{listing:?}"
1686        );
1687        assert!(
1688            listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
1689            "{listing:?}"
1690        );
1691        assert!(
1692            !listing.iter().any(|(n, _)| n == "hindsight"),
1693            "disabled integrations must not be listed: {listing:?}"
1694        );
1695        // scip/cbm are always-available on-demand importers
1696        assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
1697        assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
1698        // One universe: every listed adapter resolves in the registry, and
1699        // the registry's evidence importers appear (tracelayer was missing).
1700        for (n, _) in &listing {
1701            assert!(
1702                scc_indexer::adapters::resolve_integration(n).is_some(),
1703                "listed adapter {n} must resolve in the registry: {listing:?}"
1704            );
1705        }
1706        assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
1707        // narsil is a ccg alias, never a listed adapter.
1708        assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
1709        assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
1710        // cmd_adapters renders the exact expected line format
1711        cmd_adapters(&root, false).unwrap();
1712        // doctor is offline, read-only, exit-0 by default on this fixture.
1713        assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
1714        assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
1715    }
1716}
1717