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