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>
322 lines
10 KiB
Rust
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)
|
|
}
|