Files
kebab/crates/kebab-cli/src/main.rs
altair823 43ff4048e8 feat(kebab-tui): P9-1 Ratatui shell + Library pane
새 crate `kebab-tui` 가 §8 facade rule 따라 `kebab-app` 만 import.
Ratatui 0.28 + crossterm 0.28 기반 shell 이 다음을 제공:

- `App` 구조체: config + focus + library + 3 Option sub-state slot
  (search/ask/inspect — p9-2/3/4 가 자기 모듈에서 채우는 parallel-safety
  contract). p9-1 외에 App 정의 손대지 않음.
- `Pane` enum (Library/Search/Ask/Inspect/Jobs).
- `KeyOutcome` (Continue/Quit/SwitchPane/Refresh).
- `LibraryState` + 내부 inner: docs / list_state / filter / filter_edit /
  needs_refresh / loading / pending_g.
- `render_library` (Frame, area, &App) — heading/body, filter overlay
  toggleable, Korean/wide-char 너비는 unicode-width 로 계산.
- `handle_key_library`: j/k/Down/Up 이동, gg/G 끝, f 필터 overlay,
  /=>Search ?=>Ask Enter=>Inspect, q/Esc 종료. error overlay 가 켜
  있으면 어떤 키든 dismiss.
- 필터 overlay: tags_any (CSV) + lang 두 필드, Tab cycle, Enter
  apply→Refresh, Esc cancel.
- `ErrorOverlay`: anyhow chain 캡쳐 후 popup 렌더 (Clear + 빨간 border).
- 터미널 lifecycle: `TuiTerminal` 가 enter raw mode + alt screen,
  Drop 이 종료 시 (panic 포함) restore — 사용자 쉘 깨지지 않게.
- 비동기 없음: facade 호출은 main thread 동기. v1 의 brief hang 수용.

CLI: `kebab tui` 서브커맨드 추가, --config 받아 App::new + run.

테스트 10건 (`tests/library.rs`, TestBackend 사용):
- 빈 library / 3-doc render / q,Esc quit / / Search 전환 / ? Ask 전환
- Enter 빈 list 무동작 / Enter Inspect 전환 / j 이동 (3-step clamp) /
  f 필터 overlay → 입력 → Enter Refresh.

Test seam: `App::populate_library_for_testing` (#[doc(hidden)]) 가
`pub(crate)` inner 를 우회. spec parallel-safety contract 그대로 유지.

Spec deviation (HOTFIXES `2026-05-02 P9-1`):
- `render_library` 의 `<B: Backend>` generic 제거 — ratatui 0.28 의 Frame
  이 backend-agnostic.
- `populate_library_for_testing` 추가 (test seam, 공식 API 아님).

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

499 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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 kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
mod wire;
#[derive(Parser, Debug)]
#[command(name = "kebab", version, about = "personal local knowledge base")]
struct Cli {
/// Path to a non-default `config.toml`.
#[arg(long, global = true)]
config: Option<PathBuf>,
/// 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<PathBuf>,
/// 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<f32>,
#[arg(long)]
seed: Option<u64>,
},
/// Health check.
Doctor,
/// Launch the Ratatui shell (P9-1 — Library pane only; search /
/// ask / inspect panes land with p9-2 / p9-3 / p9-4).
Tui,
/// 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 the golden suite end-to-end and persist `eval_runs` +
/// `eval_query_results` + `runs_dir/<run_id>/per_query.jsonl`
/// (P5-1).
Run {
#[arg(long, default_value = "golden")]
suite: String,
#[arg(long, value_enum, default_value_t = ModeFlag::Lexical)]
mode: ModeFlag,
#[arg(long, default_value_t = 10)]
k: usize,
#[arg(long)]
with_rag: bool,
#[arg(long)]
temperature: Option<f32>,
#[arg(long)]
seed: Option<u64>,
},
/// Compute aggregate metrics for a stored run and write them back
/// into `eval_runs.aggregate_json` (P5-2).
Aggregate { run_id: String },
/// Diff two stored runs (P5-2). Default output is a Markdown
/// summary; use `--json` (top-level flag) for the raw report.
Compare {
run_a: String,
run_b: String,
/// Refuse to compare when the two runs' `chunker_version`
/// differ (default is graceful doc-id fallback).
#[arg(long)]
strict_chunker_version: bool,
/// Also write the Markdown report to
/// `runs_dir/<run_b>/report.md`.
#[arg(long)]
write_report: bool,
},
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum ModeFlag {
Lexical,
Vector,
Hybrid,
}
impl From<ModeFlag> for kebab_core::SearchMode {
fn from(m: ModeFlag) -> Self {
match m {
ModeFlag::Lexical => kebab_core::SearchMode::Lexical,
ModeFlag::Vector => kebab_core::SearchMode::Vector,
ModeFlag::Hybrid => kebab_core::SearchMode::Hybrid,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let level = if cli.debug {
kebab_app::logging::LogLevel::Debug
} else if cli.verbose {
kebab_app::logging::LogLevel::Verbose
} else {
kebab_app::logging::LogLevel::Default
};
// Fail-soft: if logging init errors (e.g. XDG state dir is read-only),
// proceed without a guard rather than crashing — `kb` is still usable.
let _log_guard = kebab_app::logging::init(level).ok();
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::<RefusalSignal>().is_some() {
return 1;
}
if err.downcast_ref::<NoHitSignal>().is_some() {
return 1;
}
if err.downcast_ref::<DoctorUnhealthy>().is_some() {
return 3;
}
2
}
fn run(cli: &Cli) -> anyhow::Result<()> {
match &cli.command {
Cmd::Init { force } => {
kebab_app::init_workspace(*force)?;
if !cli.json {
println!(
"created {}",
kebab_config::Config::xdg_config_path().display()
);
println!("created {}", kebab_config::Config::xdg_data_dir().display());
println!("created {}", kebab_config::Config::xdg_state_dir().display());
println!("hint edit the config above, then `kb ingest`");
}
Ok(())
}
Cmd::Ingest {
root,
summary_only,
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let scope = kebab_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 = kebab_app::ingest_with_config(cfg, scope, *summary_only)?;
if cli.json {
println!("{}", serde_json::to_string(&wire::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 cfg = kebab_config::Config::load(cli.config.as_deref())?;
let docs = kebab_app::list_docs_with_config(cfg, kebab_core::DocFilter::default())?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_doc_summaries(&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 cfg = kebab_config::Config::load(cli.config.as_deref())?;
let doc_id: kebab_core::DocumentId = id.parse()?;
let doc = kebab_app::inspect_doc_with_config(cfg, &doc_id)?;
// Inspect doc emits a `CanonicalDocument` — there's no §2
// wire schema for it (P1-5 will decide whether this also
// becomes a tagged wrapper or stays as the raw domain
// object). Until then keep raw JSON, matching pre-P0-1
// behaviour.
println!("{}", serde_json::to_string(&doc)?);
Ok(())
}
InspectWhat::Chunk { id } => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let chunk_id: kebab_core::ChunkId = id.parse()?;
let chunk = kebab_app::inspect_chunk_with_config(cfg, &chunk_id)?;
println!("{}", serde_json::to_string(&wire::wire_chunk_inspection(&chunk))?);
Ok(())
}
},
Cmd::Search {
query,
k,
mode,
explain: _,
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let q = kebab_core::SearchQuery {
text: query.clone(),
mode: (*mode).into(),
k: *k,
filters: kebab_core::SearchFilters::default(),
};
let hits = kebab_app::search_with_config(cfg, q)?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_search_hits(&hits))?);
} else {
for h in &hits {
// Show 4-digit score so RRF fused scores (bounded
// ~00.033 for k_rrf=60) don't all collapse to "0.02".
// Append heading_path so multiple chunks from the same
// document are distinguishable on a single line.
let heading = if h.heading_path.is_empty() {
String::new()
} else {
format!(" > {}", h.heading_path.join(" / "))
};
println!(
"{:>2}. {:.4} {}{}",
h.rank,
h.retrieval.fusion_score,
h.doc_path.0,
heading,
);
}
}
Ok(())
}
Cmd::Ask {
query,
k,
mode,
explain,
temperature,
seed,
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let opts = kebab_app::AskOpts {
k: *k,
explain: *explain,
mode: (*mode).into(),
temperature: *temperature,
seed: *seed,
// CLI ask is non-streaming today (the answer prints all at
// once on completion). The TUI ask pane (P9-3) is what
// wires up a real `mpsc::Sender` here.
stream_sink: None,
};
let ans = kebab_app::ask_with_config(cfg, query, opts)?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_answer(&ans))?);
} else {
println!("{}", ans.answer);
}
// Refusal → exit 1.
if !ans.grounded {
return Err(RefusalSignal.into());
}
Ok(())
}
Cmd::Doctor => {
let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_doctor(&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::Tui => {
// P9-1: Ratatui shell with Library pane. Search / Ask /
// Inspect panes land in p9-2 / p9-3 / p9-4.
let config = match cli.config.as_deref() {
Some(path) => kebab_config::Config::load(Some(path))?,
None => kebab_config::Config::load(None)?,
};
let mut app = kebab_tui::App::new(config)?;
app.run()
}
Cmd::Eval { what } => match what {
EvalWhat::Run {
suite,
mode,
k,
with_rag,
temperature,
seed,
} => {
let opts = kebab_eval::EvalRunOpts {
suite: suite.clone(),
mode: (*mode).into(),
with_rag: *with_rag,
k: *k,
temperature: *temperature,
seed: *seed,
};
let run = kebab_eval::run_eval(&opts)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&run)?);
} else {
println!("run_id: {}", run.run_id);
println!("queries: {}", run.per_query.len());
let failed = run.per_query.iter().filter(|q| q.error.is_some()).count();
println!("failed: {failed}");
}
Ok(())
}
EvalWhat::Aggregate { run_id } => {
let agg = kebab_eval::compute_aggregate(run_id)?;
kebab_eval::store_aggregate(run_id, &agg)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&agg)?);
} else {
println!("run_id: {run_id}");
println!("queries: {} ({} failed)", agg.total_queries, agg.failed_queries);
println!("hit@1: {:.4}", agg.hit_at_k.get(&1).copied().unwrap_or(0.0));
println!("hit@5: {:.4}", agg.hit_at_k.get(&5).copied().unwrap_or(0.0));
println!("MRR: {:.4}", agg.mrr);
}
Ok(())
}
EvalWhat::Compare {
run_a,
run_b,
strict_chunker_version,
write_report,
} => {
let cfg = kebab_config::Config::load(None)?;
let opts = kebab_eval::CompareOpts {
strict_chunker_version: *strict_chunker_version,
};
let report = kebab_eval::compare_runs_with_config(&cfg, run_a, run_b, &opts)?;
let md = kebab_eval::render_report_md(&report);
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{md}");
}
if *write_report {
let resolved_data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
let runs_dir = kebab_config::expand_path(
&cfg.storage.runs_dir,
&resolved_data_dir.to_string_lossy(),
);
let dir = runs_dir.join(run_b);
std::fs::create_dir_all(&dir)?;
let path = dir.join("report.md");
std::fs::write(&path, &md)?;
if !cli.json {
eprintln!("wrote {}", path.display());
}
}
Ok(())
}
},
}
}