KeiSeiKit-1.0/_primitives/_rust/kei-registry/src/handlers.rs
Parfii-bot af46684330 feat(secrets+catalog): orphan-detector for env vars + image/video/voice models
Two parallel agents (both Sonnet 4.6 via the just-activated tier system)
extended the substrate-unified-registry. First end-to-end proof that the
Phase 4 router refactor saves money: no Opus spawns this round.

PART 1 — `kei-registry secrets` subcommand (Agent A — code-implementer)

Reads env-var NAMES from `~/.claude/secrets/.env` (RULE 0.8 SSoT) and
per-project `secrets/*.env`, greps the kit tree for usages, reports
orphans (defined but unreferenced). Live run on this kit found 26 keys,
11 ORPHAN — actionable cleanup candidates incl. GitHub OAuth client
creds, Godaddy keys, KeiGit admin creds, KEI_MEMORY_TOKEN.

Files:
- `_primitives/_rust/kei-registry/src/secrets.rs` (152 LOC) — pure
  read-side cube. SecretsReport + KeyRow types, env-file parser
  (KEY=value lines, validates `^[A-Z][A-Z0-9_]*$`), walkdir-based
  scanner with skips (target/ node_modules/ .git/ _generated/),
  word-boundary regex per key. ASCII + JSON render.
- `_primitives/_rust/kei-registry/src/secrets_tests.rs` (125 LOC) —
  5 unit tests covering env parse, scan correctness, word-boundary
  regression (`MY_KEY` ≠ `MY_KEY_EXTRA`), JSON roundtrip, ORPHAN marker.
- `_primitives/_rust/kei-registry/src/secrets_handler.rs` (58 LOC) —
  CLI dispatch handler.
- `cli.rs`, `handlers.rs`, `lib.rs` extended with Secrets variant.

Resolves the asymmetry called out in the design discussion: paths got
atomization (commit 3422bdc), keys get a query-layer instead. Reason:
env-var NAMES are already public and stable; opaque atom-DNA over them
adds zero security and full overhead. Orphan detection is the unique
value, and a 30-LOC subcommand delivers it without a per-key atom file.

PART 2 — kei-model catalog extension (Agent B — fal-ai-runner)

Adds 10 generation-model entries with VERIFIED pricing per RULE 0.4:
- google: gemini-3-1-flash-image, gemini-3-pro-image
- fal.ai: flux-2-pro, flux-pro-1-1, kling-o3, veo-3, ideogram-v3, recraft-v3
- elevenlabs: elevenlabs-v3, elevenlabs-multilingual-v2

Pricing sourced from each provider's public pricing page (URLs cited
per row in `notes` + `source_url` fields); 8/10 verified, 2 marked
needs-verification (gemini-3-pro-image price not found on public page).

Schema additions to `_primitives/_rust/kei-model/src/model.rs` to
support the new entries without `provider = "local"` placeholder:
- Provider enum + 3 variants: Google, Fal, Elevenlabs (with as_str
  + parse impls).
- Capability enum + 9 variants: image-gen, text-to-image, image-edit,
  video-gen, text-to-video, image-to-video, voice-gen, text-to-speech,
  voice-clone (with serde rename + as_str + parse).

Pricing struct unchanged: per-image / per-second / per-1k-chars unit
costs ride existing `output_per_mtok_micro` field with the unit
documented in `notes` (e.g. "Per-image cost. 1 unit = 1 image."). A
proper Pricing.unit field is a follow-up.

Files:
- `_primitives/_rust/kei-model/src/model.rs` (+24 LOC enum extensions)
- `_primitives/_rust/kei-model/data/models.toml` (+216 LOC, 471 total)

`kei-model list` returns the full 21-model catalog incl. new providers.

Tests:
- kei-registry: 25 passed (existing + 5 secrets tests + 10 status)
- kei-model: 0 (no unit tests in crate, parser smoke via list)
- agent-assembler: 29 passed (no regressions)

Verification (cited):
- `./target/release/kei-registry secrets --env-file ~/.claude/secrets/.env`
  emits real report 26/11 orphan.
- `./target/release/kei-model list` parses all 21 entries cleanly.
- `cargo build --release --workspace` clean.

What this does NOT do (deferred):
- Pricing.unit field (per-mtok / per-image / per-second / per-1k-chars
  discriminator) — needs Rust struct refactor + cost-estimator update.
- `secrets` skip-list extension (worktrees, _ts_packages/node_modules
  duplicate counts) — minor noise.
- gemini-3-pro-image pricing (no public page; vendor-specific quote
  needed).

=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
  - Pricing.unit field for cost-estimator correctness on gen models
  - secrets scan: skip .claude/worktrees/ to avoid duplicate counts
  - gemini-3-pro-image price verification

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:06:16 +08:00

322 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),
Command::Secrets {
env_files,
scan_root,
format,
} => crate::secrets_handler::handle_secrets(env_files, scan_root, 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)
}