From 67f2c16cc28cb5f6c9ce945bd19d1fb0d0e8d816 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sun, 10 May 2026 20:22:45 +0900
Subject: [PATCH] feat(cli): kebab search --bulk flag + stdin ndjson + output
stream (fb-42)
---
crates/kebab-cli/src/main.rs | 90 ++++++++++++++++++++++++++++++++++++
crates/kebab-cli/src/wire.rs | 14 ++++++
2 files changed, 104 insertions(+)
diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs
index 41f2657..647860f 100644
--- a/crates/kebab-cli/src/main.rs
+++ b/crates/kebab-cli/src/main.rs
@@ -171,6 +171,16 @@ enum Cmd {
/// without embeddings via a no-op vector stub.
#[arg(long)]
trace: bool,
+
+ /// p9-fb-42: bulk multi-query mode. Reads ndjson from stdin —
+ /// one JSON object per line, each item shape mirrors the
+ /// single-query input. Output is per-query ndjson on stdout
+ /// (one `bulk_search_item.v1` per line) plus a summary line on
+ /// stderr. Single-query flags (`--mode`, `--k`, `--tag`, etc.)
+ /// are ignored when `--bulk` is set; pass them per-item in the
+ /// stdin JSON instead. Caps at 100 queries per call.
+ #[arg(long)]
+ bulk: bool,
},
/// Retrieval-augmented question answering.
@@ -678,7 +688,87 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
ingested_after,
doc_id,
trace,
+ bulk,
} => {
+ // p9-fb-42: bulk mode — stdin ndjson → bulk_search_with_config
+ // → stdout ndjson per query + stderr summary. Single-query
+ // flags are ignored (each item supplies its own).
+ if *bulk {
+ use std::io::{BufRead, Write};
+
+ let cfg = kebab_config::Config::load(cli.config.as_deref())?;
+
+ let stdin = std::io::stdin();
+ let stdin_locked = stdin.lock();
+ let mut raw_items: Vec = Vec::new();
+ for (lineno, line) in stdin_locked.lines().enumerate() {
+ let line = line?;
+ if line.trim().is_empty() {
+ continue;
+ }
+ let v: serde_json::Value =
+ serde_json::from_str(&line).map_err(|e| {
+ anyhow::Error::new(kebab_app::StructuredError(
+ kebab_app::ErrorV1 {
+ schema_version: kebab_app::ERROR_V1_ID
+ .to_string(),
+ code: "config_invalid".to_string(),
+ message: format!(
+ "stdin ndjson line {} parse error: {e}",
+ lineno + 1
+ ),
+ details: serde_json::Value::Null,
+ hint: Some(
+ "each line must be a JSON object with at least `query`"
+ .to_string(),
+ ),
+ },
+ ))
+ })?;
+ raw_items.push(v);
+ }
+
+ let (items, summary) =
+ kebab_app::bulk_search_with_config(cfg, raw_items)?;
+
+ if cli.json {
+ let mut stdout = std::io::stdout().lock();
+ for item in &items {
+ let v = wire::wire_bulk_search_item(item);
+ writeln!(stdout, "{}", serde_json::to_string(&v)?)?;
+ }
+ eprintln!(
+ "bulk_summary: total={} succeeded={} failed={}",
+ summary.total, summary.succeeded, summary.failed,
+ );
+ } else {
+ let mut stdout = std::io::stdout().lock();
+ for (idx, item) in items.iter().enumerate() {
+ writeln!(
+ stdout,
+ "# Query {}: {}",
+ idx + 1,
+ serde_json::to_string(&item.query)?,
+ )?;
+ if let Some(err) = &item.error {
+ writeln!(stdout, "error: {}", err)?;
+ } else if let Some(resp) = &item.response {
+ writeln!(
+ stdout,
+ "{}",
+ serde_json::to_string_pretty(resp)?
+ )?;
+ }
+ writeln!(stdout)?;
+ }
+ eprintln!(
+ "bulk_summary: total={} succeeded={} failed={}",
+ summary.total, summary.succeeded, summary.failed,
+ );
+ }
+ return Ok(());
+ }
+
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
// p9-fb-36: normalize --media aliases (md → markdown).
diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs
index 29ab7aa..10d0047 100644
--- a/crates/kebab-cli/src/wire.rs
+++ b/crates/kebab-cli/src/wire.rs
@@ -201,6 +201,20 @@ pub fn wire_fetch_result(r: &kebab_core::FetchResult) -> Value {
tag_object(v, "fetch_result.v1")
}
+/// p9-fb-42: tag a `BulkSearchItem` (already serialized as a Value)
+/// as `bulk_search_item.v1`. The inner `query` / `response` / `error`
+/// fields stay verbatim — only the envelope gets the schema_version stamp.
+pub fn wire_bulk_search_item(item: &kebab_core::BulkSearchItem) -> Value {
+ let mut v = serde_json::to_value(item).expect("BulkSearchItem serializes");
+ if let Value::Object(ref mut map) = v {
+ map.insert(
+ "schema_version".to_string(),
+ Value::String("bulk_search_item.v1".to_string()),
+ );
+ }
+ v
+}
+
#[cfg(test)]
mod tests {
use super::*;