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>
This commit is contained in:
Parfii-bot 2026-05-02 00:06:16 +08:00
parent be1a864629
commit af46684330
9 changed files with 647 additions and 8 deletions

View file

@ -253,3 +253,219 @@ output_per_mtok_micro = 0
status = "verified"
source_url = "https://ollama.com/library/llama3"
verified_at = "2026-04-28"
# ============================================================
# Google Gemini image-gen (nano-banana CLI) — verified 2026-05-01
# Source: https://fal.ai/models/fal-ai/flux-2-pro (fal.ai catalog)
# https://fal.ai/pricing
# SCHEMA NOTE: provider="local" is a placeholder — the Provider
# enum does not yet include "google". Follow-up: add Provider::Google
# and Provider::Fal and Provider::Elevenlabs to model.rs + enum parse().
# capabilities=[] because Capability enum has no image-gen / video-gen
# variants yet. Follow-up: extend Capability enum with generation caps.
# Per-image cost stored in output_per_mtok_micro (1 image = 1 unit).
# Unit semantics noted in the notes field (loader does not yet parse "unit").
# ============================================================
[[models]]
id = "gemini-3-1-flash-image"
provider = "local"
display_name = "Gemini 3.1 Flash Image (Nano Banana 2)"
context_tokens = 0
capabilities = ["image-gen", "text-to-image"]
status = "active"
role_tags = ["image-cheap", "image-gen"]
fallback = ""
notes = "Google Gemini Flash image-gen consumed via nano-banana CLI. Real provider: google. Per-image cost; 1 unit = 1 image. output_per_mtok_micro stores per-image micro-cents. Pricing unverified on public page — nanobanana aggregator lists $0.0398/image via fal.ai."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 3980000
status = "needs-verification"
source_url = "https://fal.ai/pricing"
verified_at = "2026-05-01"
[[models]]
id = "gemini-3-pro-image"
provider = "local"
display_name = "Gemini 3 Pro Image (Nano Banana Pro)"
context_tokens = 0
capabilities = ["image-gen", "text-to-image", "image-edit"]
status = "active"
role_tags = ["image-flagship", "image-gen"]
fallback = "gemini-3-1-flash-image"
notes = "Google Gemini Pro image-gen consumed via nano-banana CLI. Real provider: google. Per-image cost; 1 unit = 1 image. output_per_mtok_micro stores per-image micro-cents. Pricing not on public page — marked needs-verification."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 0
status = "needs-verification"
source_url = "https://fal.ai/pricing"
verified_at = "2026-05-01"
# ============================================================
# fal.ai generation models (image + video) — verified 2026-05-01
# Source: https://fal.ai/models/fal-ai/flux-2-pro
# https://fal.ai/models/fal-ai/flux-pro/v1.1
# https://fal.ai/models/fal-ai/kling-video/o3/4k/text-to-video
# https://fal.ai/models/fal-ai/kling-video/o3/standard/image-to-video
# https://fal.ai/models/fal-ai/veo3
# https://fal.ai/models/fal-ai/ideogram/v3
# https://fal.ai/models/fal-ai/recraft-v3
# SCHEMA NOTE: provider="local" placeholder (real provider: fal).
# capabilities=[] placeholder (enum lacks image-gen/video-gen/etc).
# Image pricing: output_per_mtok_micro = per-image micro-cents (1 unit = 1 image).
# Video pricing: output_per_mtok_micro = per-second micro-cents (1 unit = 1 second).
# ============================================================
[[models]]
id = "flux-2-pro"
provider = "local"
display_name = "FLUX.2 [pro] Text to Image"
context_tokens = 0
capabilities = ["image-gen", "text-to-image"]
status = "active"
role_tags = ["image-flagship", "image-gen"]
fallback = "flux-pro-1-1"
notes = "fal.ai FLUX.2 [pro]. ZERO-CONFIG: no guidance_scale parameter (model handles internally). Per-megapixel billing: $0.03 first MP + $0.015 per extra MP. 1024x1024 = $0.03, 1920x1080 = $0.045. output_per_mtok_micro = 3000000 (base 1MP price). Real provider: fal."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 3000000
status = "verified"
source_url = "https://fal.ai/models/fal-ai/flux-2-pro"
verified_at = "2026-05-01"
[[models]]
id = "flux-pro-1-1"
provider = "local"
display_name = "FLUX.1 [pro] v1.1 Text to Image"
context_tokens = 0
capabilities = ["image-gen", "text-to-image"]
status = "active"
role_tags = ["image-flagship", "image-gen"]
fallback = ""
notes = "fal.ai FLUX Pro 1.1. Per-megapixel billing: $0.04/MP rounded up. Fallback for flux-2-pro. output_per_mtok_micro = 4000000 (1MP base). Real provider: fal."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 4000000
status = "verified"
source_url = "https://fal.ai/models/fal-ai/flux-pro/v1.1"
verified_at = "2026-05-01"
[[models]]
id = "kling-o3"
provider = "local"
display_name = "Kling O3 4K Text/Image to Video"
context_tokens = 0
capabilities = ["video-gen", "text-to-video", "image-to-video"]
status = "active"
role_tags = ["video-gen"]
fallback = ""
notes = "fal.ai Kling O3. 4K video generation. Per-second cost: $0.42/s (audio off). Durations 3-15s; default 5s (=$2.10). 2500-char prompt limit (O3 hard limit). Supports elements + voice_ids simultaneously. output_per_mtok_micro = 42000000 (per-second micro-cents). Real provider: fal."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 42000000
status = "verified"
source_url = "https://fal.ai/models/fal-ai/kling-video/o3/4k/text-to-video"
verified_at = "2026-05-01"
[[models]]
id = "veo-3"
provider = "local"
display_name = "Google Veo 3 (via fal.ai)"
context_tokens = 0
capabilities = ["video-gen", "text-to-video"]
status = "active"
role_tags = ["video-gen"]
fallback = "kling-o3"
notes = "fal.ai Google Veo 3 partner model. Per-second cost: $0.50/s (audio off) or $0.75/s (audio on). Fast tier: $0.25/s (audio off) or $0.40/s (audio on). output_per_mtok_micro = 50000000 (standard audio-off). Real provider: fal/google."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 50000000
status = "verified"
source_url = "https://fal.ai/models/fal-ai/veo3"
verified_at = "2026-05-01"
[[models]]
id = "ideogram-v3"
provider = "local"
display_name = "Ideogram V3 Text to Image"
context_tokens = 0
capabilities = ["image-gen", "text-to-image", "image-edit"]
status = "active"
role_tags = ["image-flagship", "image-gen"]
fallback = ""
notes = "fal.ai Ideogram V3. Per-image tiered: $0.03 TURBO / $0.06 BALANCED / $0.09 QUALITY. Exceptional text/typography rendering. output_per_mtok_micro = 6000000 (BALANCED tier). Real provider: fal."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 6000000
status = "verified"
source_url = "https://fal.ai/models/fal-ai/ideogram/v3"
verified_at = "2026-05-01"
[[models]]
id = "recraft-v3"
provider = "local"
display_name = "Recraft V3 Text to Image / Vector"
context_tokens = 0
capabilities = ["image-gen", "text-to-image", "image-edit"]
status = "active"
role_tags = ["image-cheap", "image-gen"]
fallback = ""
notes = "fal.ai Recraft V3. Per-image: $0.04 (raster) or $0.08 (vector_illustration style). Styles: realistic_image, digital_illustration, vector_illustration. Strong text rendering. output_per_mtok_micro = 4000000 (raster base). Real provider: fal."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 4000000
status = "verified"
source_url = "https://fal.ai/models/fal-ai/recraft-v3"
verified_at = "2026-05-01"
# ============================================================
# ElevenLabs voice generation — verified 2026-05-01
# Source: https://elevenlabs.io/pricing/api
# SCHEMA NOTE: provider="local" placeholder (real provider: elevenlabs).
# Voice pricing: output_per_mtok_micro = per-1k-char micro-cents
# (1 unit = 1000 characters). input_per_mtok_micro = 0.
# ============================================================
[[models]]
id = "elevenlabs-v3"
provider = "local"
display_name = "ElevenLabs Turbo v3 (Flash)"
context_tokens = 0
capabilities = ["voice-gen", "text-to-speech", "voice-clone"]
status = "active"
role_tags = ["voice-gen"]
fallback = "elevenlabs-multilingual-v2"
notes = "ElevenLabs high-quality voice model. Per-1k-chars: $0.10. Low latency ~250-300ms. Supports 32 languages. 40k char limit per request. output_per_mtok_micro = 10000000 (per-1k-chars micro-cents). Real provider: elevenlabs. ElevenLabs 3-step pattern: designVoice -> createVoice -> TTS."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 10000000
status = "verified"
source_url = "https://elevenlabs.io/pricing/api"
verified_at = "2026-05-01"
[[models]]
id = "elevenlabs-multilingual-v2"
provider = "local"
display_name = "ElevenLabs Multilingual v2"
context_tokens = 0
capabilities = ["voice-gen", "text-to-speech", "voice-clone"]
status = "active"
role_tags = ["voice-gen"]
fallback = ""
notes = "ElevenLabs multilingual voice model (fallback for v3). Per-1k-chars: $0.10. Same pricing tier as v3. Supports 32 languages. output_per_mtok_micro = 10000000. Real provider: elevenlabs."
[models.pricing]
input_per_mtok_micro = 0
output_per_mtok_micro = 10000000
status = "verified"
source_url = "https://elevenlabs.io/pricing/api"
verified_at = "2026-05-01"

View file

@ -59,6 +59,14 @@ pub enum Provider {
Mistral,
Deepseek,
Local,
/// Google: Gemini text-LLM family + image generation (nano-banana CLI
/// consumes Gemini 3.1 Flash Image / Gemini 3 Pro Image).
Google,
/// fal.ai — image / video / 3D generation aggregator. Hosts Flux,
/// Kling O3, Veo 3, Ideogram, Recraft, etc.
Fal,
/// ElevenLabs — text-to-speech and voice cloning.
Elevenlabs,
}
impl Provider {
@ -70,6 +78,9 @@ impl Provider {
Provider::Mistral => "mistral",
Provider::Deepseek => "deepseek",
Provider::Local => "local",
Provider::Google => "google",
Provider::Fal => "fal",
Provider::Elevenlabs => "elevenlabs",
}
}
@ -81,6 +92,9 @@ impl Provider {
"mistral" => Some(Provider::Mistral),
"deepseek" => Some(Provider::Deepseek),
"local" => Some(Provider::Local),
"google" => Some(Provider::Google),
"fal" => Some(Provider::Fal),
"elevenlabs" => Some(Provider::Elevenlabs),
_ => None,
}
}
@ -102,6 +116,28 @@ pub enum Capability {
LongContext1m,
#[serde(rename = "system-prompt")]
SystemPrompt,
// Generation capabilities (image / video / voice). Pricing for these
// is per-image / per-second / per-1k-chars rather than per-mtok; the
// existing Pricing struct stores the unit price in
// `output_per_mtok_micro` and the unit semantics live in `notes`.
#[serde(rename = "image-gen")]
ImageGen,
#[serde(rename = "text-to-image")]
TextToImage,
#[serde(rename = "image-edit")]
ImageEdit,
#[serde(rename = "video-gen")]
VideoGen,
#[serde(rename = "text-to-video")]
TextToVideo,
#[serde(rename = "image-to-video")]
ImageToVideo,
#[serde(rename = "voice-gen")]
VoiceGen,
#[serde(rename = "text-to-speech")]
TextToSpeech,
#[serde(rename = "voice-clone")]
VoiceClone,
}
impl Capability {
@ -114,6 +150,15 @@ impl Capability {
Capability::LongContext200k => "long-context-200k",
Capability::LongContext1m => "long-context-1m",
Capability::SystemPrompt => "system-prompt",
Capability::ImageGen => "image-gen",
Capability::TextToImage => "text-to-image",
Capability::ImageEdit => "image-edit",
Capability::VideoGen => "video-gen",
Capability::TextToVideo => "text-to-video",
Capability::ImageToVideo => "image-to-video",
Capability::VoiceGen => "voice-gen",
Capability::TextToSpeech => "text-to-speech",
Capability::VoiceClone => "voice-clone",
}
}
@ -126,6 +171,15 @@ impl Capability {
"long-context-200k" => Some(Capability::LongContext200k),
"long-context-1m" => Some(Capability::LongContext1m),
"system-prompt" => Some(Capability::SystemPrompt),
"image-gen" => Some(Capability::ImageGen),
"text-to-image" => Some(Capability::TextToImage),
"image-edit" => Some(Capability::ImageEdit),
"video-gen" => Some(Capability::VideoGen),
"text-to-video" => Some(Capability::TextToVideo),
"image-to-video" => Some(Capability::ImageToVideo),
"voice-gen" => Some(Capability::VoiceGen),
"text-to-speech" => Some(Capability::TextToSpeech),
"voice-clone" => Some(Capability::VoiceClone),
_ => None,
}
}

View file

@ -168,4 +168,20 @@ pub enum Command {
#[arg(long, default_value = "ascii")]
format: String,
},
/// Audit secret/env-var references across the kit. Reads env-var NAMES
/// from .env files (never values), greps the kit tree for usages,
/// reports orphans (defined but unreferenced).
Secrets {
/// Env-file paths to scan (default: `~/.claude/secrets/.env` if
/// exists, plus any `<scan-root>/secrets/*.env`).
#[arg(long = "env-file")]
env_files: Vec<PathBuf>,
/// Root to scan for usages (default: current directory).
#[arg(long, default_value = ".")]
scan_root: PathBuf,
/// Output format: `ascii` (default) or `json`.
#[arg(long, default_value = "ascii")]
format: String,
},
}

View file

@ -74,6 +74,11 @@ pub fn dispatch(cmd: Command) -> Result<Outcome> {
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),
}
}

View file

@ -26,6 +26,8 @@ pub mod registry;
pub mod related;
pub mod scan_orchestrator;
pub mod scanners;
pub mod secrets;
pub mod secrets_handler;
pub mod stats;
pub mod status;
pub mod store;
@ -35,6 +37,11 @@ pub use diff::{diff_blocks, BlockDiff};
pub use dna_block::{compose_for_block, compose_for_block_with_nonce};
pub use registry::{find_by_path, get, list, list_by_type, mark_superseded, register};
pub use related::{find_related, RelatedHit};
// Both `secrets` and `status` expose a `render_ascii` function — keep them
// module-qualified at the top level (avoid re-exporting `render_ascii` to
// prevent a name collision; callers use `secrets::render_ascii` /
// `status::render_ascii`).
pub use secrets::{compute_secrets_report, SecretsReport};
pub use stats::{compute_stats, Stats};
pub use status::{compute_status, render_ascii, Status};
pub use status::{compute_status, Status};
pub use store::{open_db, SCHEMA_VERSION};

View file

@ -0,0 +1,152 @@
//! Secret-reference orphan detector.
//!
//! Reads env-var NAMES from `.env` files (never values), greps the kit
//! tree for usages, returns a `SecretsReport` with per-key usage counts
//! and orphan list. Constructor Pattern: pure read-side cube.
use anyhow::Result;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecretsReport {
pub keys: Vec<KeyRow>,
pub scanned_files: u64,
pub env_files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyRow {
pub name: String,
pub source_env_file: String,
pub usage_count: u64,
/// Top 5 files where the key appears.
pub usage_files: Vec<String>,
pub orphan: bool,
}
const SKIP_DIRS: &[&str] = &["target", "node_modules", ".git", "_generated"];
const TEXT_EXTS: &[&str] = &["rs", "toml", "md", "sh", "py", "ts", "js", "yml", "yaml", "json"];
pub(crate) fn is_valid_key(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_uppercase() => {
chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
_ => false,
}
}
pub(crate) fn parse_env_file(path: &Path) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path)?;
let mut keys = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') { continue; }
let Some(idx) = trimmed.find('=') else { continue; };
let key = trimmed[..idx].trim();
if is_valid_key(key) { keys.push(key.to_string()); }
}
Ok(keys)
}
fn is_text_file(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).map_or(false, |ext| TEXT_EXTS.contains(&ext))
}
fn word_re(key: &str) -> Result<Regex> {
Ok(Regex::new(&format!(r"\b{}\b", regex::escape(key)))?)
}
/// Scan `scan_root`, returning scanned_files count and per-key (count, files) map.
pub(crate) fn scan_usages(
keys: &[String],
scan_root: &Path,
) -> Result<(u64, BTreeMap<String, (u64, Vec<String>)>)> {
let patterns: Vec<(String, Regex)> = keys
.iter()
.map(|k| Ok((k.clone(), word_re(k)?)))
.collect::<Result<_>>()?;
let mut counts: BTreeMap<String, (u64, Vec<String>)> = BTreeMap::new();
for k in keys { counts.insert(k.clone(), (0, Vec::new())); }
let mut scanned = 0u64;
for entry in WalkDir::new(scan_root).follow_links(false)
.into_iter()
.filter_entry(|e| !SKIP_DIRS.contains(&e.file_name().to_string_lossy().as_ref()))
.flatten()
{
if !entry.file_type().is_file() || !is_text_file(entry.path()) { continue; }
let Ok(content) = std::fs::read_to_string(entry.path()) else { continue; };
scanned += 1;
let rel = entry.path().strip_prefix(scan_root).unwrap_or(entry.path())
.to_string_lossy().to_string();
for (key, re) in &patterns {
if re.is_match(&content) {
let e = counts.get_mut(key).expect("key present");
e.0 += 1;
if e.1.len() < 5 { e.1.push(rel.clone()); }
}
}
}
Ok((scanned, counts))
}
/// Build a `SecretsReport`. Pure: no side effects beyond file reads.
pub fn compute_secrets_report(env_paths: &[PathBuf], scan_root: &Path) -> Result<SecretsReport> {
let mut all_keys: Vec<(String, String)> = Vec::new();
let mut env_file_labels: Vec<String> = Vec::new();
for ep in env_paths {
let label = ep.to_string_lossy().to_string();
env_file_labels.push(label.clone());
for k in parse_env_file(ep).unwrap_or_default() { all_keys.push((k, label.clone())); }
}
let unique_keys: Vec<String> = {
let mut seen = std::collections::HashSet::new();
all_keys.iter().filter(|(k, _)| seen.insert(k.clone())).map(|(k, _)| k.clone()).collect()
};
let (scanned_files, counts) = scan_usages(&unique_keys, scan_root)?;
let mut rows: Vec<KeyRow> = all_keys.into_iter().map(|(name, source_env_file)| {
let (usage_count, usage_files) = counts.get(&name).cloned().unwrap_or_default();
KeyRow { orphan: usage_count == 0, name, source_env_file, usage_count, usage_files }
}).collect();
rows.sort_by(|a, b| a.name.cmp(&b.name));
Ok(SecretsReport { keys: rows, scanned_files, env_files: env_file_labels })
}
/// Render a `SecretsReport` as ASCII text.
pub fn render_ascii(r: &SecretsReport) -> String {
use std::fmt::Write as FmtWrite;
let mut out = String::new();
let mut by_file: BTreeMap<&str, Vec<&KeyRow>> = BTreeMap::new();
for row in &r.keys { by_file.entry(row.source_env_file.as_str()).or_default().push(row); }
for (file, rows) in &by_file {
let orphan_count = rows.iter().filter(|r| r.orphan).count();
let _ = writeln!(out, "[Secrets — {} ({} keys, {} orphan)]", file, rows.len(), orphan_count);
for row in rows.iter() { render_row(&mut out, row); }
}
let total_orphans = r.keys.iter().filter(|k| k.orphan).count();
let _ = writeln!(out, "Total: {} keys across {} env files, {} orphan",
r.keys.len(), r.env_files.len(), total_orphans);
out
}
fn render_row(out: &mut String, row: &KeyRow) {
use std::fmt::Write as FmtWrite;
if row.orphan {
let _ = writeln!(out, " {:<35} *ORPHAN* 0 refs — candidate for removal", row.name);
return;
}
let files_str = row.usage_files.join(", ");
let extra = if row.usage_count > row.usage_files.len() as u64 {
format!(", +{} more", row.usage_count - row.usage_files.len() as u64)
} else { String::new() };
let _ = writeln!(out, " {:<35} {:>4} refs ({}{})", row.name, row.usage_count, files_str, extra);
}
#[cfg(test)]
#[path = "secrets_tests.rs"]
mod tests;

View file

@ -0,0 +1,58 @@
//! Handler for the `secrets` subcommand.
//!
//! Constructor Pattern: this cube owns the secrets command dispatch only.
//! Env-file resolution + report output. No scanner logic — that lives
//! in `secrets.rs`.
use anyhow::Result;
use std::path::{Path, PathBuf};
use crate::handlers::Outcome;
use crate::secrets::{compute_secrets_report, render_ascii};
/// Top-level handler wired from `handlers::dispatch`.
pub fn handle_secrets(
mut env_files: Vec<PathBuf>,
scan_root: PathBuf,
format: String,
) -> Result<Outcome> {
let root = scan_root.canonicalize().unwrap_or(scan_root);
if env_files.is_empty() {
env_files = resolve_default_env_files(&root);
}
let report = compute_secrets_report(&env_files, &root)?;
match format.as_str() {
"json" => println!("{}", serde_json::to_string_pretty(&report)?),
_ => print!("{}", render_ascii(&report)),
}
Ok(Outcome::Ok)
}
/// Resolve default env files when user provides none.
///
/// Priority order:
/// 1. `~/.claude/secrets/.env` (umbrella SSoT per RULE 0.8)
/// 2. `<scan_root>/secrets/*.env` (per-project secrets)
fn resolve_default_env_files(scan_root: &Path) -> Vec<PathBuf> {
let mut result = Vec::new();
let umbrella = umbrella_env_path();
if umbrella.exists() {
result.push(umbrella);
}
let secrets_dir = scan_root.join("secrets");
if let Ok(rd) = std::fs::read_dir(&secrets_dir) {
let mut local: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("env"))
.collect();
local.sort();
result.extend(local);
}
result
}
fn umbrella_env_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".claude").join("secrets").join(".env")
}

View file

@ -0,0 +1,125 @@
//! Tests for secrets.rs — orphan-detection, env-parse, word-boundary, JSON roundtrip.
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
use super::*;
fn write_file(dir: &Path, name: &str, content: &str) {
let p = dir.join(name);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
fn make_env(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
let p = dir.join(name);
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(content.as_bytes()).unwrap();
p
}
#[test]
fn test_parse_env_file_filters_correctly() {
let tmp = TempDir::new().unwrap();
let env_path = make_env(
tmp.path(),
".env",
"# comment\n\nANTHROPIC_API_KEY=sk-ant-xxx\nlower_key=ignored\nNO_EQUALS\nQUOTED_KEY=\"value\"\n",
);
let keys = parse_env_file(&env_path).unwrap();
assert!(keys.contains(&"ANTHROPIC_API_KEY".to_string()));
assert!(keys.contains(&"QUOTED_KEY".to_string()));
assert!(!keys.contains(&"lower_key".to_string()));
assert!(!keys.contains(&"NO_EQUALS".to_string()));
}
#[test]
fn test_scan_counts_usages_correctly() {
let src_tmp = TempDir::new().unwrap();
write_file(src_tmp.path(), "main.rs", "let x = std::env::var(\"MY_KEY\").unwrap();");
write_file(src_tmp.path(), "config.toml", "key = \"$MY_KEY\"");
write_file(src_tmp.path(), "other.rs", "// no secret here");
let env_tmp = TempDir::new().unwrap();
let env_path = make_env(env_tmp.path(), ".env", "MY_KEY=secret\nORPHAN_KEY=unused\n");
let report = compute_secrets_report(&[env_path], src_tmp.path()).unwrap();
let my_key = report.keys.iter().find(|k| k.name == "MY_KEY").unwrap();
let orphan_key = report.keys.iter().find(|k| k.name == "ORPHAN_KEY").unwrap();
assert_eq!(my_key.usage_count, 2);
assert!(!my_key.orphan);
assert_eq!(orphan_key.usage_count, 0);
assert!(orphan_key.orphan);
}
#[test]
fn test_word_boundary_no_false_positive() {
let src_tmp = TempDir::new().unwrap();
// MY_KEY_EXTRA must NOT match MY_KEY due to word boundary.
write_file(src_tmp.path(), "a.rs", "let _ = std::env::var(\"MY_KEY_EXTRA\");");
let env_tmp = TempDir::new().unwrap();
let env_path = make_env(env_tmp.path(), ".env", "MY_KEY=val\n");
let report = compute_secrets_report(&[env_path], src_tmp.path()).unwrap();
let row = report.keys.iter().find(|k| k.name == "MY_KEY").unwrap();
assert_eq!(
row.usage_count, 0,
"word boundary regression: MY_KEY matched inside MY_KEY_EXTRA"
);
}
#[test]
fn test_json_roundtrip() {
let report = SecretsReport {
keys: vec![KeyRow {
name: "TEST_KEY".into(),
source_env_file: "/tmp/.env".into(),
usage_count: 3,
usage_files: vec!["src/main.rs".into()],
orphan: false,
}],
scanned_files: 10,
env_files: vec!["/tmp/.env".into()],
};
let json = serde_json::to_string(&report).unwrap();
let decoded: SecretsReport = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.keys[0].name, "TEST_KEY");
assert_eq!(decoded.scanned_files, 10);
assert!(!decoded.keys[0].orphan);
}
#[test]
fn test_render_ascii_shows_orphan_marker() {
let report = SecretsReport {
keys: vec![
KeyRow {
name: "ACTIVE_KEY".into(),
source_env_file: "~/.env".into(),
usage_count: 5,
usage_files: vec!["src/a.rs".into()],
orphan: false,
},
KeyRow {
name: "LEGACY_TOKEN".into(),
source_env_file: "~/.env".into(),
usage_count: 0,
usage_files: vec![],
orphan: true,
},
],
scanned_files: 20,
env_files: vec!["~/.env".into()],
};
let ascii = render_ascii(&report);
assert!(ascii.contains("*ORPHAN*"));
assert!(ascii.contains("LEGACY_TOKEN"));
assert!(ascii.contains("ACTIVE_KEY"));
assert!(ascii.contains("Total: 2 keys"));
assert!(ascii.contains("1 orphan"));
}

View file

@ -1,19 +1,19 @@
# KeiSeiKit DNA Encyclopedia
> Auto-generated from kei-registry. Last regenerated: 2026-05-01T15:21:20Z.
> Total blocks: 509. Per-type breakdown:
> Auto-generated from kei-registry. Last regenerated: 2026-05-01T16:06:16Z.
> Total blocks: 512. Per-type breakdown:
| Type | Count |
|---|---:|
| atom | 121 |
| hook | 40 |
| primitive | 106 |
| primitive | 109 |
| rule | 174 |
| skill | 68 |
---
## Primitive (106)
## Primitive (109)
Sorted alphabetically by name.
@ -81,8 +81,8 @@ Sorted alphabetically by name.
| kei-memory-sled | primitive::md,networ… | _primitives/_rust/kei-memory-sled/Cargo.toml | 6fdae904 |
| kei-memory-sqlite | primitive::md,networ… | _primitives/_rust/kei-memory-sqlite/Cargo.toml | 93761682 |
| kei-migrate | primitive::cli,hash,… | _primitives/_rust/kei-migrate/Cargo.toml | fd996e76 |
| kei-model | primitive::cli,md,re… | _primitives/_rust/kei-model/Cargo.toml | 1a4038fd |
| kei-model-router | primitive::md,sqlite… | _primitives/_rust/kei-model-router/Cargo.toml | b67e44b9 |
| kei-model::kei-model | primitive::_::6a479a… | _primitives/_rust/kei-model/Cargo.toml | 3f74b167 |
| kei-net-ipsec | primitive::md,networ… | _primitives/_rust/kei-net-ipsec/Cargo.toml | edb79478 |
| kei-net-openvpn | primitive::md,networ… | _primitives/_rust/kei-net-openvpn/Cargo.toml | a209e645 |
| kei-net-wireguard | primitive::md,networ… | _primitives/_rust/kei-net-wireguard/Cargo.toml | 05a75c60 |
@ -98,9 +98,12 @@ Sorted alphabetically by name.
| kei-provision | primitive::cli,md::1… | _primitives/_rust/kei-provision/Cargo.toml | cfa53bb3 |
| kei-prune | primitive::cli,md,sq… | _primitives/_rust/kei-prune/Cargo.toml | 4454513b |
| kei-refactor-engine | primitive::cli,md::c… | _primitives/_rust/kei-refactor-engine/Cargo.toml | 92e83ce0 |
| kei-registry | primitive::cli,fs,ha… | _primitives/_rust/kei-registry/Cargo.toml | 5a2e79d8 |
| kei-registry::foo | primitive::_::12366c… | _primitives/_rust/kei-registry/tests/fixtures/fake-kit/_primitives/_rust/foo/Cargo.toml | 403bc4b0 |
| kei-registry::foo | primitive::_::3937fa… | _primitives/_rust/kei-registry/tests/fixtures/fake-kit/_primitives/_rust/foo/Cargo.toml | 403bc4b0 |
| kei-registry::kei-registry | primitive::_::30e60a… | _primitives/_rust/kei-registry/Cargo.toml | d5146bbd |
| kei-registry::kei-registry | primitive::_::4744f0… | _primitives/_rust/kei-registry/Cargo.toml | 4e595599 |
| kei-registry::mini-prim | primitive::_::57f8eb… | _primitives/_rust/kei-registry/tests/fixtures/mini-kit/_primitives/_rust/mini-prim/Cargo.toml | 9fa2b304 |
| kei-registry::mini-prim | primitive::_::bb2052… | _primitives/_rust/kei-registry/tests/fixtures/mini-kit/_primitives/_rust/mini-prim/Cargo.toml | 9fa2b304 |
| kei-replay | primitive::cli,hash,… | _primitives/_rust/kei-replay/Cargo.toml | 74f2fcc4 |
| kei-router | primitive::cli,md,ne… | _primitives/_rust/kei-router/Cargo.toml | 2cfaa362 |
| kei-runtime | primitive::cli,fs,md… | _primitives/_rust/kei-runtime/Cargo.toml | c19f68cf |
@ -1093,6 +1096,7 @@ Sorted alphabetically by name.
- `kei-migrate` — 2 versions: db2e7bd0 → fd996e76
- `kei-model` — 2 versions: 0a6ce8bc → 1a4038fd
- `kei-model-router` — 2 versions: 1280a1dd → b67e44b9
- `kei-model::kei-model` — 2 versions: 0948fb4f → 3f74b167
- `kei-net-ipsec` — 2 versions: 600684a8 → edb79478
- `kei-net-openvpn` — 2 versions: d4c94d69 → a209e645
- `kei-net-wireguard` — 2 versions: e2c8fab8 → 05a75c60
@ -1109,7 +1113,9 @@ Sorted alphabetically by name.
- `kei-prune` — 2 versions: 7c0a0c11 → 4454513b
- `kei-refactor-engine` — 2 versions: 90048888 → 92e83ce0
- `kei-registry` — 3 versions: 7d9570ad → 5a2e79d8 → 5a2e79d8
- `kei-registry::kei-registry` — 21 versions: a9d4104f → 4110ba86 → 6e2dc3fd → 1f486539 → f10a08ba → 48886c98 → 6aeaf85c → ca0c09e0 → 130372c0 → f69680b3 → 50364568 → 30e6dee3 → 3bb6d4f8 → 26a25696 → 0951d355 → 3261f321 → 5a190e74 → 80762a78 → d2bd49f3 → 99859be7 → b134cecf
- `kei-registry::foo` — 2 versions: 403bc4b0 → 403bc4b0
- `kei-registry::kei-registry` — 36 versions: a9d4104f → 4110ba86 → 6e2dc3fd → 1f486539 → f10a08ba → 48886c98 → 6aeaf85c → ca0c09e0 → 130372c0 → f69680b3 → 50364568 → 30e6dee3 → 3bb6d4f8 → 26a25696 → 0951d355 → 3261f321 → 5a190e74 → 80762a78 → d2bd49f3 → 99859be7 → b134cecf → 713f693b → 5faa1d45 → 84b3d3aa → f0fd45d4 → a50c01c9 → a4b4526d → b6f981f1 → 93eeffff → d3feb512 → f21fe020 → cbe1a45d → d5146bbd → a33bb21f → a3f03a74 → 4e595599
- `kei-registry::mini-prim` — 2 versions: 9fa2b304 → 9fa2b304
- `kei-replay` — 2 versions: 420ceb46 → 74f2fcc4
- `kei-router` — 2 versions: fc8c6820 → 2cfaa362
- `kei-router::kei-router` — 15 versions: 186634e6 → d91e8a11 → 80d4f8c6 → f8677f1d → a2e47f61 → 299a5afe → 675effa4 → 1fa6b4bb → 89c81c79 → 29340bbb → 51682c29 → ec0a1bfb → f4fce214 → 184e4f53 → 98ab93cd