KeiSeiKit-1.0/_primitives/_rust/kei-registry/src/handlers.rs
Parfii-bot d6dbdee870 feat(kei-registry): status subcommand — cross-cutting substrate dashboard
Phase 3 of substrate-unified-registry: a single command shows every
live artefact across the three sources without merging stores.

`kei-registry status` joins:
1. `blocks` table (kei-registry SQLite) — active counts per BlockType,
   plus the registered path-atoms with DNA prefix + body sha8.
2. `git for-each-ref refs/heads` (shell-out, no DB persistence) — local
   branches, current marker, ahead/behind via `upstream:track,nobracket`.
3. `agents` table (kei-ledger SQLite) — fork counts per status
   (running/done/failed/merged/rejected). Missing ledger DB → section
   skipped, never an error.

Output: ASCII multi-section table by default; `--format json` for
machine consumption.

Files:
- `_primitives/_rust/kei-registry/src/status.rs` — new module, ~270
  LOC. Pure read-side per Constructor Pattern. 7 unit tests cover
  `parse_track` (in sync / ahead / behind / both / "gone"), DNA prefix
  rendering, and empty-status section presence.
- `_primitives/_rust/kei-registry/src/cli.rs` — new `Status` variant
  with `--db`, `--git-repo`, `--ledger-db`, `--format` flags.
- `_primitives/_rust/kei-registry/src/handlers.rs` — `handle_status`
  dispatcher, ASCII/JSON branching.
- `_primitives/_rust/kei-registry/src/lib.rs` — module export.

End-to-end run from kit root shows the prior gap: 17 local branches
(many `worktree-agent-*` orphans), kei-ledger summary 4 running /
158 done / 35 failed / 7 merged / 0 rejected — visibility the user
asked for ("в каждой сессии видеть, чтобы не бегать по диску в
поисках несмерженных").

What this does NOT do (Phase 4):
- No orphan detection (`kei-status orphans`) — counts only.
- No auto-registration of branches into kei-ledger (Phase 2). Branches
  come from live `git for-each-ref` shell-out; if the repo moves or
  is deleted the row vanishes from the dashboard. Acceptable for v1.

=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
  - Phase 2 (post-checkout hook → kei-ledger auto-register)
  - Phase 4 (orphan detection: branches with no commits in N days,
    path-atoms with no consumers, agent forks stuck running)
  - --filter flags (--type, --status) for targeted queries

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:46:53 +08:00

317 lines
10 KiB
Rust

//! CLI command handlers.
//!
//! Constructor Pattern: this cube wires CLI args to library calls. Each
//! handler is a thin adapter. The common parts (db path resolve,
//! id-or-DNA lookup) live in sibling cubes (`paths.rs`, `lookup.rs`).
//! Schema-version mismatch surfaces as exit 3, not-found 2, IO 1.
use anyhow::{Context, Result};
use serde_json::json;
use std::path::PathBuf;
use std::str::FromStr;
use crate::block::{Block, BlockType};
use crate::cli::Command;
use crate::encyclopedia::{render_json, render_markdown, to_entries};
use crate::index_substrate;
use crate::lookup::lookup_block;
use crate::paths::resolve_db;
use crate::registry::{list, list_by_type};
use crate::scan_orchestrator;
use crate::scanners::hook::HookScanner;
use crate::scanners::Scanner;
use crate::store::open_db;
/// Exit-code outcome. `Ok` for success, plus typed not-found variant.
#[derive(Debug)]
pub enum Outcome {
Ok,
NotFound(String),
}
/// Dispatch one parsed Command. Returns Outcome → main maps to exit code.
pub fn dispatch(cmd: Command) -> Result<Outcome> {
match cmd {
Command::Init { db } => handle_init(db),
Command::Scan {
kit_root,
rules_root,
hooks_root,
db,
types,
} => scan_orchestrator::handle_scan(kit_root, rules_root, hooks_root, db, types),
Command::Register {
block_type,
path,
name,
caps,
db,
} => handle_register(block_type, path, name, caps, db),
Command::List {
block_type,
db,
limit,
include_superseded,
} => handle_list(block_type, db, limit, include_superseded),
Command::Get { target, db } => handle_get(target, db),
Command::Related { target, depth, db } => handle_related(target, depth, db),
Command::Diff { a, b, db } => handle_diff(a, b, db),
Command::Stats { db } => handle_stats(db),
Command::Encyclopedia {
registry_db,
output,
format,
block_type,
} => handle_encyclopedia(registry_db, output, format, block_type),
Command::RegisterSkill { path, name, db } => handle_register_skill(path, name, db),
Command::RegisterHook { path, name, db } => handle_register_hook(path, name, db),
Command::IndexSubstrate { kit_root, db, dry_run } => {
index_substrate::handle_index_substrate(Some(kit_root), db, dry_run)
}
Command::Status {
db,
git_repo,
ledger_db,
format,
} => handle_status(db, git_repo, ledger_db, format),
}
}
fn handle_status(
db: Option<PathBuf>,
git_repo: PathBuf,
ledger_db: Option<PathBuf>,
format: String,
) -> Result<Outcome> {
let db_path = resolve_db(db);
let conn = open_db(&db_path)?;
let ledger = ledger_db.unwrap_or_else(crate::status::default_ledger_path);
let snap = crate::status::compute_status(&conn, Some(&git_repo), Some(&ledger))?;
match format.as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&snap)?);
}
"ascii" | "" => {
print!("{}", crate::status::render_ascii(&snap));
}
other => anyhow::bail!("unknown --format '{other}' (use ascii or json)"),
}
Ok(Outcome::Ok)
}
fn handle_init(db: Option<PathBuf>) -> Result<Outcome> {
let path = resolve_db(db);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
let _conn = open_db(&path)?;
println!(
"{}",
json!({
"ok": true,
"db": path.to_string_lossy(),
"schema_version": crate::store::SCHEMA_VERSION,
})
);
Ok(Outcome::Ok)
}
fn handle_register(
type_str: String,
path: PathBuf,
name: Option<String>,
caps: Option<String>,
db: Option<PathBuf>,
) -> Result<Outcome> {
let block_type = BlockType::from_str(&type_str).map_err(anyhow::Error::msg)?;
let canonical = path
.canonicalize()
.with_context(|| format!("canonicalize {}", path.display()))?;
let body = std::fs::read(&canonical)?;
let final_name = name.unwrap_or_else(|| auto_name_from_path(&canonical));
let final_caps = caps.unwrap_or_default();
let conn = open_db(resolve_db(db))?;
let block = crate::registry::register(
&conn,
block_type,
&final_name,
&canonical.to_string_lossy(),
&body,
&final_caps,
)?;
println!("{}", serde_json::to_string_pretty(&block)?);
Ok(Outcome::Ok)
}
fn auto_name_from_path(canonical: &std::path::Path) -> String {
canonical
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
}
fn handle_list(
type_str: Option<String>,
db: Option<PathBuf>,
limit: i64,
include_superseded: bool,
) -> Result<Outcome> {
let conn = open_db(resolve_db(db))?;
let blocks: Vec<Block> = match type_str {
Some(t) => {
let bt = BlockType::from_str(&t).map_err(anyhow::Error::msg)?;
list_by_type(&conn, bt)?
}
None => list(&conn, include_superseded, limit)?,
};
println!("{}", serde_json::to_string_pretty(&blocks)?);
Ok(Outcome::Ok)
}
fn handle_get(target: String, db: Option<PathBuf>) -> Result<Outcome> {
let conn = open_db(resolve_db(db))?;
match lookup_block(&conn, &target)? {
Some(b) => {
println!("{}", serde_json::to_string_pretty(&b)?);
Ok(Outcome::Ok)
}
None => Ok(Outcome::NotFound(target)),
}
}
fn handle_related(target: String, depth: u32, db: Option<PathBuf>) -> Result<Outcome> {
let conn = open_db(resolve_db(db))?;
let root = match lookup_block(&conn, &target)? {
Some(b) => b,
None => return Ok(Outcome::NotFound(target)),
};
let related = crate::related::find_related(&conn, &root, depth)?;
println!(
"{}",
serde_json::to_string_pretty(&json!({"root": root, "related": related}))?
);
Ok(Outcome::Ok)
}
fn handle_diff(a: String, b: String, db: Option<PathBuf>) -> Result<Outcome> {
let conn = open_db(resolve_db(db))?;
let (block_a, block_b) = match (lookup_block(&conn, &a)?, lookup_block(&conn, &b)?) {
(Some(x), Some(y)) => (x, y),
(None, _) => return Ok(Outcome::NotFound(a)),
(_, None) => return Ok(Outcome::NotFound(b)),
};
let diff = crate::diff::diff_blocks(&block_a, &block_b);
println!("{}", serde_json::to_string_pretty(&diff)?);
Ok(Outcome::Ok)
}
fn handle_stats(db: Option<PathBuf>) -> Result<Outcome> {
let conn = open_db(resolve_db(db))?;
let stats = crate::stats::compute_stats(&conn)?;
println!("{}", serde_json::to_string_pretty(&stats)?);
Ok(Outcome::Ok)
}
fn handle_register_skill(
path: PathBuf,
name: Option<String>,
db: Option<PathBuf>,
) -> Result<Outcome> {
let canonical = path
.canonicalize()
.with_context(|| format!("canonicalize skill dir {}", path.display()))?;
if !canonical.is_dir() {
anyhow::bail!("{} is not a directory", canonical.display());
}
let one = crate::scanners::skill::scan_one_skill(&canonical)?;
let found: Vec<_> = match one {
Some(f) => vec![f],
None => anyhow::bail!("no SKILL.md found under {}", canonical.display()),
};
let conn = open_db(resolve_db(db))?;
let mut results = Vec::new();
for mut f in found {
if let Some(ref n) = name {
f.name = n.clone();
}
let block = crate::registry::register(&conn, f.block_type, &f.name, &f.path, &f.body, &f.caps)?;
results.push(block);
}
println!("{}", serde_json::to_string_pretty(&results)?);
Ok(Outcome::Ok)
}
fn handle_register_hook(
path: PathBuf,
name: Option<String>,
db: Option<PathBuf>,
) -> Result<Outcome> {
let canonical = path
.canonicalize()
.with_context(|| format!("canonicalize hook file {}", path.display()))?;
if !canonical.is_file() {
anyhow::bail!("{} is not a file", canonical.display());
}
let parent = canonical.parent().unwrap_or(&canonical);
let found = HookScanner.scan(parent)?;
let stem = canonical
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let hook_path = canonical.to_string_lossy().to_string();
let target = found.into_iter().find(|f| f.path == hook_path);
let mut f = match target {
Some(f) => f,
None => anyhow::bail!("{} not recognised as a .sh hook", canonical.display()),
};
if let Some(ref n) = name {
f.name = n.clone();
} else {
f.name = stem.to_string();
}
let conn = open_db(resolve_db(db))?;
let block = crate::registry::register(&conn, f.block_type, &f.name, &f.path, &f.body, &f.caps)?;
println!("{}", serde_json::to_string_pretty(&block)?);
Ok(Outcome::Ok)
}
fn handle_encyclopedia(
registry_db: Option<PathBuf>,
output: Option<PathBuf>,
format: String,
type_filter: Option<String>,
) -> Result<Outcome> {
let db_path = resolve_db(registry_db);
let conn = open_db(db_path)?;
// Fetch active rows (optionally filtered) + all rows for supersede chains.
let active_blocks = match &type_filter {
Some(t) => {
let bt = BlockType::from_str(t).map_err(anyhow::Error::msg)?;
list_by_type(&conn, bt)?
}
None => list(&conn, false, i64::MAX)?,
};
let all_blocks = list(&conn, true, i64::MAX)?;
let entries = to_entries(&active_blocks);
let rendered = match format.as_str() {
"json" => render_json(&entries)?,
_ => render_markdown(&entries, &all_blocks),
};
match output {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &rendered)?;
eprintln!("kei-registry: encyclopedia written to {}", path.display());
}
None => print!("{rendered}"),
}
Ok(Outcome::Ok)
}