From ec8a4ddb1b8ddaac3d869de02b2fdf7f223bcff1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 30 Apr 2026 05:17:18 +0000 Subject: [PATCH] =?UTF-8?q?p0-1:=20kb-cli=20clap=20entry=20with=20=C2=A710?= =?UTF-8?q?=20exit-code=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the kb binary with clap v4 derive subcommands mapping 1:1 to kb-app facade functions: init | ingest | list docs | inspect (doc|chunk) | search | ask | doctor | eval run Global flags: --config, --verbose, --debug, --json. On --json, output conforms to wire schema v1 (e.g. doctor.v1 emitted by kb-app::doctor). Exit-code mapping per design §10: 0 success 1 RefusalSignal / NoHitSignal (kb ask refusal, kb search no-hit) 2 any other anyhow::Error 3 DoctorUnhealthy Tracing initialized at startup with the file appender from kb-app. Verified via: XDG_*=… cargo run -p kb-cli -- init → idempotent XDG_*=… cargo run -p kb-cli -- doctor --json → {"schema_version":"doctor.v1","ok":true,…} exit 0 XDG_*=… cargo run -p kb-cli -- doctor (human form, ✓ marks) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kb-cli/Cargo.toml | 20 +++ crates/kb-cli/src/main.rs | 360 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 crates/kb-cli/Cargo.toml create mode 100644 crates/kb-cli/src/main.rs diff --git a/crates/kb-cli/Cargo.toml b/crates/kb-cli/Cargo.toml new file mode 100644 index 0000000..ed8a81a --- /dev/null +++ b/crates/kb-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "kb-cli" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +description = "kb command-line interface" + +[[bin]] +name = "kb" +path = "src/main.rs" + +[dependencies] +kb-core = { path = "../kb-core" } +kb-config = { path = "../kb-config" } +kb-app = { path = "../kb-app" } +anyhow = { workspace = true } +serde_json = { workspace = true } +clap = { version = "4", features = ["derive"] } diff --git a/crates/kb-cli/src/main.rs b/crates/kb-cli/src/main.rs new file mode 100644 index 0000000..28f04e0 --- /dev/null +++ b/crates/kb-cli/src/main.rs @@ -0,0 +1,360 @@ +//! `kb` — command-line interface. Each subcommand maps 1:1 to a `kb-app` +//! function. Exit codes per design §10. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +use kb_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal}; + +#[derive(Parser, Debug)] +#[command(name = "kb", version, about = "personal local knowledge base")] +struct Cli { + /// Path to a non-default `config.toml`. + #[arg(long, global = true)] + config: Option, + + /// Show anyhow chain on errors. + #[arg(long, global = true)] + verbose: bool, + + /// Show tracing target/level on errors. + #[arg(long, global = true)] + debug: bool, + + /// Emit machine-readable wire JSON (`*.v1`). + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Initialise XDG dirs + workspace + `config.toml`. + Init { + /// Overwrite an existing `config.toml`. + #[arg(long)] + force: bool, + }, + + /// Scan the workspace and ingest new/updated documents. + Ingest { + /// Workspace root override. + #[arg(long)] + root: Option, + + /// Suppress the per-file `items` list. + #[arg(long)] + summary_only: bool, + }, + + /// Listing subcommands. + List { + #[command(subcommand)] + what: ListWhat, + }, + + /// Inspect documents or chunks by ID. + Inspect { + #[command(subcommand)] + what: InspectWhat, + }, + + /// Lexical / vector / hybrid search over chunks. + Search { + query: String, + + #[arg(long, default_value_t = 10)] + k: usize, + + #[arg(long, value_enum, default_value_t = ModeFlag::Hybrid)] + mode: ModeFlag, + + #[arg(long)] + explain: bool, + }, + + /// Retrieval-augmented question answering. + Ask { + query: String, + + #[arg(long, default_value_t = 8)] + k: usize, + + #[arg(long, value_enum, default_value_t = ModeFlag::Hybrid)] + mode: ModeFlag, + + #[arg(long)] + explain: bool, + + #[arg(long)] + temperature: Option, + + #[arg(long)] + seed: Option, + }, + + /// Health check. + Doctor, + + /// Eval suite (placeholder; lands in P9). + Eval { + #[command(subcommand)] + what: EvalWhat, + }, +} + +#[derive(Subcommand, Debug)] +enum ListWhat { + /// List documents currently indexed. + Docs, +} + +#[derive(Subcommand, Debug)] +enum InspectWhat { + /// Inspect a single document by ID. + Doc { id: String }, + /// Inspect a single chunk by ID. + Chunk { id: String }, +} + +#[derive(Subcommand, Debug)] +enum EvalWhat { + /// Run an eval suite (placeholder for P9). + Run { + #[arg(long)] + suite: Option, + }, +} + +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum ModeFlag { + Lexical, + Vector, + Hybrid, +} + +impl From for kb_core::SearchMode { + fn from(m: ModeFlag) -> Self { + match m { + ModeFlag::Lexical => kb_core::SearchMode::Lexical, + ModeFlag::Vector => kb_core::SearchMode::Vector, + ModeFlag::Hybrid => kb_core::SearchMode::Hybrid, + } + } +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let level = if cli.debug { + kb_app::logging::LogLevel::Debug + } else if cli.verbose { + kb_app::logging::LogLevel::Verbose + } else { + kb_app::logging::LogLevel::Default + }; + let _log_guard = kb_app::logging::init(level).ok().flatten(); + match run(&cli) { + Ok(()) => ExitCode::from(0), + Err(e) => { + let code = exit_code(&e); + // Refusals at exit code 1 print to stdout (already done by the + // caller); errors go to stderr. + if code != 1 { + eprintln!("error: {e}"); + if cli.verbose { + for cause in e.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } + } + } + ExitCode::from(code) + } + } +} + +fn exit_code(err: &anyhow::Error) -> u8 { + if err.downcast_ref::().is_some() { + return 1; + } + if err.downcast_ref::().is_some() { + return 1; + } + if err.downcast_ref::().is_some() { + return 3; + } + 2 +} + +fn run(cli: &Cli) -> anyhow::Result<()> { + match &cli.command { + Cmd::Init { force } => { + kb_app::init_workspace(*force)?; + if !cli.json { + println!( + "created {}", + kb_config::Config::xdg_config_path().display() + ); + println!("created {}", kb_config::Config::xdg_data_dir().display()); + println!("created {}", kb_config::Config::xdg_state_dir().display()); + println!("hint edit the config above, then `kb ingest`"); + } + Ok(()) + } + + Cmd::Ingest { + root, + summary_only, + } => { + let cfg = kb_config::Config::load(cli.config.as_deref())?; + let scope = kb_core::SourceScope { + root: root.clone().unwrap_or_else(|| PathBuf::from(&cfg.workspace.root)), + include: cfg.workspace.include.clone(), + exclude: cfg.workspace.exclude.clone(), + }; + let report = kb_app::ingest(scope, *summary_only)?; + if cli.json { + println!("{}", serde_json::to_string(&wire_ingest(&report))?); + } else { + println!( + "scanned {} new {} updated {} skipped {} errors {} ({} ms)", + report.scanned, + report.new, + report.updated, + report.skipped, + report.errors, + report.duration_ms + ); + } + Ok(()) + } + + Cmd::List { what } => match what { + ListWhat::Docs => { + let docs = kb_app::list_docs(kb_core::DocFilter::default())?; + if cli.json { + println!("{}", serde_json::to_string(&docs)?); + } else { + for d in &docs { + println!("{}\t{}", d.doc_id, d.doc_path.0); + } + } + Ok(()) + } + }, + + Cmd::Inspect { what } => match what { + InspectWhat::Doc { id } => { + let doc_id: kb_core::DocumentId = id.parse()?; + let doc = kb_app::inspect_doc(&doc_id)?; + println!("{}", serde_json::to_string(&doc)?); + Ok(()) + } + InspectWhat::Chunk { id } => { + let chunk_id: kb_core::ChunkId = id.parse()?; + let chunk = kb_app::inspect_chunk(&chunk_id)?; + println!("{}", serde_json::to_string(&chunk)?); + Ok(()) + } + }, + + Cmd::Search { + query, + k, + mode, + explain: _, + } => { + let q = kb_core::SearchQuery { + text: query.clone(), + mode: (*mode).into(), + k: *k, + filters: kb_core::SearchFilters::default(), + }; + let hits = kb_app::search(q)?; + if cli.json { + println!("{}", serde_json::to_string(&hits)?); + } else { + for h in &hits { + println!("{:>2}. {:.2} {}", h.rank, h.retrieval.fusion_score, h.doc_path.0); + } + } + Ok(()) + } + + Cmd::Ask { + query, + k, + mode, + explain, + temperature, + seed, + } => { + let opts = kb_app::AskOpts { + k: *k, + explain: *explain, + mode: (*mode).into(), + temperature: *temperature, + seed: *seed, + }; + let ans = kb_app::ask(query, opts)?; + if cli.json { + println!("{}", serde_json::to_string(&ans)?); + } else { + println!("{}", ans.answer); + } + // Refusal → exit 1. + if !ans.grounded { + return Err(RefusalSignal.into()); + } + Ok(()) + } + + Cmd::Doctor => { + let report = kb_app::doctor()?; + if cli.json { + println!("{}", serde_json::to_string(&report)?); + } else { + for c in &report.checks { + let mark = if c.ok { "✓" } else { "✗" }; + println!("{mark} {:<20} {}", c.name, c.detail); + if let (false, Some(hint)) = (c.ok, c.hint.as_ref()) { + println!(" hint: {hint}"); + } + } + if !report.ok { + println!(); + let failed = report.checks.iter().filter(|c| !c.ok).count(); + println!("{failed} check(s) failed."); + } + } + if !report.ok { + return Err(DoctorUnhealthy.into()); + } + Ok(()) + } + + Cmd::Eval { what } => match what { + EvalWhat::Run { suite: _ } => { + anyhow::bail!("not yet wired (P9-3)") + } + }, + } +} + +/// Convenience wrapper to emit `ingest_report.v1` schema_version. +fn wire_ingest(r: &kb_core::IngestReport) -> serde_json::Value { + serde_json::json!({ + "schema_version": "ingest_report.v1", + "scope": r.scope, + "scanned": r.scanned, + "new": r.new, + "updated": r.updated, + "skipped": r.skipped, + "errors": r.errors, + "duration_ms": r.duration_ms, + "items": r.items, + }) +}