From 6a18847892c193c0122329d7d0ab8403c686c4de Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sun, 10 May 2026 03:57:55 +0900
Subject: [PATCH] feat(cli): kebab search filter flags (fb-36)
7 new flags: --tag (repeatable), --lang, --path-glob,
--trust-min (value_enum), --media (csv with `md` alias),
--ingested-after (RFC3339; config_invalid on parse fail),
--doc-id. Dispatch translates clap values into SearchFilters
and propagates structured errors through the existing
StructuredError wrapper from fb-34.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
crates/kebab-cli/src/main.rs | 111 ++++++++++++++++++++++++++++++++++-
1 file changed, 110 insertions(+), 1 deletion(-)
diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs
index c92ba27..7e41d8a 100644
--- a/crates/kebab-cli/src/main.rs
+++ b/crates/kebab-cli/src/main.rs
@@ -131,6 +131,38 @@ enum Cmd {
/// `corpus_revision` returns `error.v1.code = stale_cursor`.
#[arg(long)]
cursor: Option,
+
+ /// p9-fb-36: filter by `metadata.tags`. Repeatable; OR-within (any tag).
+ #[arg(long)]
+ tag: Vec,
+
+ /// p9-fb-36: filter by `documents.lang` (ISO code).
+ #[arg(long)]
+ lang: Option,
+
+ /// p9-fb-36: filter by `documents.workspace_path` glob.
+ #[arg(long)]
+ path_glob: Option,
+
+ /// p9-fb-36: filter by minimum `documents.trust_level`.
+ #[arg(long, value_enum)]
+ trust_min: Option,
+
+ /// p9-fb-36: filter by `assets.media_type` kind. Comma-separated.
+ /// Aliases: `md` → `markdown`. Other accepted: `markdown`, `pdf`,
+ /// `image`, `audio`, `other`. Unknown values match nothing.
+ #[arg(long, value_delimiter = ',')]
+ media: Vec,
+
+ /// p9-fb-36: filter to docs whose `updated_at` is >= this RFC3339
+ /// timestamp (UTC). Invalid format → exit 2 with error.v1
+ /// code = config_invalid.
+ #[arg(long)]
+ ingested_after: Option,
+
+ /// p9-fb-36: filter to a single doc by id.
+ #[arg(long)]
+ doc_id: Option,
},
/// Retrieval-augmented question answering.
@@ -351,6 +383,25 @@ impl From for kebab_core::SearchMode {
}
}
+/// p9-fb-36: clap value enum for `--trust-min`. Maps to
+/// `kebab_core::TrustLevel` via `From`.
+#[derive(clap::ValueEnum, Clone, Debug)]
+enum TrustLevelFlag {
+ Primary,
+ Secondary,
+ Generated,
+}
+
+impl From for kebab_core::TrustLevel {
+ fn from(f: TrustLevelFlag) -> Self {
+ match f {
+ TrustLevelFlag::Primary => kebab_core::TrustLevel::Primary,
+ TrustLevelFlag::Secondary => kebab_core::TrustLevel::Secondary,
+ TrustLevelFlag::Generated => kebab_core::TrustLevel::Generated,
+ }
+ }
+}
+
/// Parse boolean env var accepting "1", "true", "yes", "on" (case-insensitive)
/// as truthy; "0", "false", "no", "off" as falsy. Used for `KEBAB_READONLY`.
fn parse_bool_env(s: &str) -> Result {
@@ -611,13 +662,71 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
max_tokens,
snippet_chars,
cursor,
+ tag,
+ lang,
+ path_glob,
+ trust_min,
+ media,
+ ingested_after,
+ doc_id,
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
+
+ // p9-fb-36: normalize --media aliases (md → markdown).
+ fn normalize_media_alias(s: &str) -> String {
+ match s.to_ascii_lowercase().as_str() {
+ "md" => "markdown".to_string(),
+ other => other.to_string(),
+ }
+ }
+ let media_norm: Vec =
+ media.iter().map(|s| normalize_media_alias(s)).collect();
+
+ // p9-fb-36: parse --ingested-after as RFC3339; structured error on failure.
+ let ingested_after_parsed: Option =
+ match ingested_after.as_deref() {
+ Some(s) => {
+ match time::OffsetDateTime::parse(
+ s,
+ &time::format_description::well_known::Rfc3339,
+ ) {
+ Ok(ts) => Some(ts),
+ Err(e) => {
+ return Err(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!(
+ "--ingested-after: invalid RFC3339 timestamp '{s}': {e}"
+ ),
+ details: serde_json::Value::Null,
+ hint: Some(
+ "expected format like 2026-04-01T00:00:00Z".to_string(),
+ ),
+ }),
+ ));
+ }
+ }
+ }
+ None => None,
+ };
+
+ // p9-fb-36: build SearchFilters from the 7 new flags.
+ let filters = kebab_core::SearchFilters {
+ tags_any: tag.clone(),
+ lang: lang.as_ref().map(|s| kebab_core::Lang(s.clone())),
+ path_glob: path_glob.clone(),
+ trust_min: trust_min.clone().map(Into::into),
+ media: media_norm,
+ ingested_after: ingested_after_parsed,
+ doc_id: doc_id.as_ref().map(|s| kebab_core::DocumentId(s.clone())),
+ };
+
let q = kebab_core::SearchQuery {
text: query.clone(),
mode: (*mode).into(),
k: *k,
- filters: kebab_core::SearchFilters::default(),
+ filters,
};
let opts = kebab_core::SearchOpts {
max_tokens: *max_tokens,