From faed2d759cf955df927970bbba437162493ec0ec Mon Sep 17 00:00:00 2001 From: Microck Date: Fri, 15 May 2026 07:39:47 +0000 Subject: [PATCH] feat: add TOON output format --- Cargo.lock | 11 ++++++++++ Cargo.toml | 1 + README.md | 6 +++--- docs/SKILL.md | 2 +- docs/commands/assistant.mdx | 1 + docs/commands/quick.mdx | 2 +- docs/commands/search.mdx | 1 + docs/commands/watch.mdx | 2 +- docs/reference/coverage.mdx | 8 ++++---- docs/reference/output-contract.mdx | 2 +- src/cli.rs | 14 ++++++++++--- src/main.rs | 23 +++++++++++++++++++-- tests/integration-cli.rs | 32 ++++++++++++++++++++++++++++++ 13 files changed, 89 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0458a4..59f8796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,6 +1054,7 @@ dependencies = [ "thiserror", "tokio", "toml", + "toon", "tracing", "tracing-subscriber", ] @@ -2104,6 +2105,16 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "toon" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa605a2aa68fefdedc9560bd765ce3ad89d45ac1d3ebad43724621098c9299a" +dependencies = [ + "regex", + "serde_json", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index c42c3b4..0c907b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ toml = "1.1.2" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } mime_guess = "2.0.5" +toon = "0.1.2" [dev-dependencies] httpmock = "0.8.3" diff --git a/README.md b/README.md index 4fdcc0e..e285334 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | command | purpose | | --- | --- | -| `kagi search` | search Kagi with `json` by default, or render as `pretty`, `compact`, `markdown`, or `csv` | -| `kagi batch` | run multiple searches in parallel with JSON, compact, pretty, markdown, or csv output and shared filters | +| `kagi search` | search Kagi with `json` by default, or render as `toon`, `pretty`, `compact`, `markdown`, or `csv` | +| `kagi batch` | run multiple searches in parallel with JSON, TOON, compact, pretty, markdown, or csv output and shared filters | | `kagi auth` | launch the auth wizard, or inspect, validate, and save credentials | | `kagi summarize` | use the paid public summarizer API or the subscriber summarizer with `--subscriber` | | `kagi watch` | rerun a search on an interval and emit added/removed result URLs | @@ -182,7 +182,7 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | `kagi bang custom` | list, inspect, create, update, and delete custom bangs | | `kagi redirect` | list, inspect, create, update, enable, disable, and delete redirect rules | -for automation, stdout stays JSON by default. `--format pretty` only changes rendering for humans. +for automation, stdout stays JSON by default. Use `--format toon` for token-efficient structured output in LLM context. `--format pretty` only changes rendering for humans. ## shell completion diff --git a/docs/SKILL.md b/docs/SKILL.md index 8dc33da..80b156c 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -106,7 +106,7 @@ kagi search --limit 5 "rust release notes" # Per-request personalization override kagi search --no-personalized "rust release notes" -# Output formats: json (default), pretty, compact, markdown, csv +# Output formats: json (default), toon, pretty, compact, markdown, csv kagi search --format markdown "query" > results.md ``` diff --git a/docs/commands/assistant.mdx b/docs/commands/assistant.mdx index f8d769f..de7e7e0 100644 --- a/docs/commands/assistant.mdx +++ b/docs/commands/assistant.mdx @@ -65,6 +65,7 @@ Output format for prompt mode. Possible values: - `json` - pretty JSON +- `toon` - token-efficient structured output for LLM context - `pretty` - terminal-friendly thread id, message id, and reply content - `compact` - minified JSON - `markdown` - only the assistant reply content diff --git a/docs/commands/quick.mdx b/docs/commands/quick.mdx index 1642bd8..4717eaf 100644 --- a/docs/commands/quick.mdx +++ b/docs/commands/quick.mdx @@ -52,7 +52,7 @@ kagi quick "what is rust?" Output format for the response. -**Supported values:** `json`, `compact`, `pretty`, `markdown` +**Supported values:** `json`, `toon`, `compact`, `pretty`, `markdown` **Default:** `json` diff --git a/docs/commands/search.mdx b/docs/commands/search.mdx index 62b6bfe..7d0695d 100644 --- a/docs/commands/search.mdx +++ b/docs/commands/search.mdx @@ -75,6 +75,7 @@ Output format. Possible values: - `json` - pretty JSON +- `toon` - token-efficient structured output for LLM context - `compact` - minified JSON - `pretty` - terminal-friendly output - `markdown` - markdown list output diff --git a/docs/commands/watch.mdx b/docs/commands/watch.mdx index 32de6ee..71041eb 100644 --- a/docs/commands/watch.mdx +++ b/docs/commands/watch.mdx @@ -29,7 +29,7 @@ Number of polls to run. `0` means run until interrupted. ### `--format ` -Output format for each event: `json`, `compact`, or `pretty`. +Output format for each event: `json`, `toon`, `compact`, or `pretty`. ## Example diff --git a/docs/reference/coverage.mdx b/docs/reference/coverage.mdx index 3ce0836..e398b92 100644 --- a/docs/reference/coverage.mdx +++ b/docs/reference/coverage.mdx @@ -98,10 +98,10 @@ These require no authentication: | Command | Formats | |---------|---------| -| `search` | `json`, `compact`, `pretty`, `markdown`, `csv` | -| `batch` | `json`, `compact`, `pretty`, `markdown`, `csv` | -| `quick` | `json`, `compact`, `pretty`, `markdown` | -| `assistant` | `json`, `compact`, `pretty`, `markdown` | +| `search` | `json`, `toon`, `compact`, `pretty`, `markdown`, `csv` | +| `batch` | `json`, `toon`, `compact`, `pretty`, `markdown`, `csv` | +| `quick` | `json`, `toon`, `compact`, `pretty`, `markdown` | +| `assistant` | `json`, `toon`, `compact`, `pretty`, `markdown` | ### Search Options diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index a0cfd98..9757568 100644 --- a/docs/reference/output-contract.mdx +++ b/docs/reference/output-contract.mdx @@ -10,7 +10,7 @@ This page documents the current CLI output behavior as implemented in the repo. ## Core Rules 1. Most commands print pretty-formatted JSON to stdout on success. -2. `kagi search`, `kagi batch`, `kagi quick`, and `kagi assistant` support human-readable output via format flags. +2. `kagi search`, `kagi batch`, `kagi quick`, and `kagi assistant` support alternate output via format flags, including `toon` for token-efficient structured context. 3. Errors are plain text on stderr and exit with status code `1`. 4. Output shapes differ by command. There is no single universal response envelope. diff --git a/src/cli.rs b/src/cli.rs index 9181d38..62be49b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,6 +30,8 @@ pub enum AssistantThreadExportFormat { pub enum OutputFormat { /// JSON output (default) - structured data for scripts and APIs Json, + /// TOON output - token-efficient structured data for LLM context + Toon, /// Pretty formatted output with colors - human-readable terminal display Pretty, /// Compact JSON output - minified JSON for reduced size @@ -44,6 +46,7 @@ impl std::fmt::Display for OutputFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Json => write!(f, "json"), + Self::Toon => write!(f, "toon"), Self::Pretty => write!(f, "pretty"), Self::Compact => write!(f, "compact"), Self::Markdown => write!(f, "markdown"), @@ -57,6 +60,8 @@ impl std::fmt::Display for OutputFormat { pub enum QuickOutputFormat { /// JSON output (default) - structured data for scripts and APIs Json, + /// TOON output - token-efficient structured data for LLM context + Toon, /// Pretty formatted output with colors - human-readable terminal display Pretty, /// Compact JSON output - minified JSON for reduced size @@ -69,6 +74,7 @@ impl std::fmt::Display for QuickOutputFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Json => write!(f, "json"), + Self::Toon => write!(f, "toon"), Self::Pretty => write!(f, "pretty"), Self::Compact => write!(f, "compact"), Self::Markdown => write!(f, "markdown"), @@ -80,6 +86,7 @@ impl std::fmt::Display for QuickOutputFormat { /// Output format for assistant responses. pub enum AssistantOutputFormat { Json, + Toon, Pretty, Compact, Markdown, @@ -89,6 +96,7 @@ impl std::fmt::Display for AssistantOutputFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Json => write!(f, "json"), + Self::Toon => write!(f, "toon"), Self::Pretty => write!(f, "pretty"), Self::Compact => write!(f, "compact"), Self::Markdown => write!(f, "markdown"), @@ -176,7 +184,7 @@ impl NewsFilterScope { Features: • Shell completion generation (bash, zsh, fish, powershell) -• Multiple output formats (json, pretty, compact, markdown, csv) +• Multiple output formats (json, toon, pretty, compact, markdown, csv) • Parallel batch searches with rate limiting • Colorized terminal output (disable with --no-color) • Full Kagi API coverage with session token support", @@ -206,7 +214,7 @@ pub enum Commands { /// Example: kagi search "rust programming" --format pretty /// /// Features: - /// • Multiple output formats: json (default), pretty, compact, markdown, csv + /// • Multiple output formats: json (default), toon, pretty, compact, markdown, csv /// • Colorized pretty output (disable with --no-color) /// • Lens support for scoped searches /// • Region, time, date, order, verbatim, and personalization filters @@ -254,7 +262,7 @@ pub enum Commands { /// Features: /// • Parallel execution with configurable concurrency /// • Token bucket rate limiting to respect API limits - /// • All output formats supported (json, pretty, compact, markdown, csv) + /// • All output formats supported (json, toon, pretty, compact, markdown, csv) /// • Lens support for scoped searches /// • Shared region, time, date, order, verbatim, and personalization filters /// • Color output control with --no-color diff --git a/src/main.rs b/src/main.rs index 2b6a310..cd81ee8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -162,6 +162,7 @@ async fn run() -> Result<(), KagiError> { let request = build_search_request(args.query, &options); let format_str = match args.format { cli::OutputFormat::Json => "json", + cli::OutputFormat::Toon => "toon", cli::OutputFormat::Pretty => "pretty", cli::OutputFormat::Compact => "compact", cli::OutputFormat::Markdown => "markdown", @@ -427,6 +428,7 @@ async fn run() -> Result<(), KagiError> { }; let format_str = match args.format { cli::QuickOutputFormat::Json => "json", + cli::QuickOutputFormat::Toon => "toon", cli::QuickOutputFormat::Pretty => "pretty", cli::QuickOutputFormat::Compact => "compact", cli::QuickOutputFormat::Markdown => "markdown", @@ -719,6 +721,7 @@ async fn run() -> Result<(), KagiError> { let format_str = match args.format { cli::OutputFormat::Json => "json", + cli::OutputFormat::Toon => "toon", cli::OutputFormat::Pretty => "pretty", cli::OutputFormat::Compact => "compact", cli::OutputFormat::Markdown => "markdown", @@ -1015,6 +1018,13 @@ fn print_compact_json(value: &T) -> Result<(), KagiError> { Ok(()) } +fn print_toon(value: &T) -> Result<(), KagiError> { + let value = serde_json::to_value(value) + .map_err(|error| KagiError::Parse(format!("failed to serialize TOON output: {error}")))?; + println!("{}", toon::encode(&value, None)); + Ok(()) +} + async fn cached_json( enabled: bool, ttl_seconds: u64, @@ -1100,6 +1110,7 @@ fn print_quick_response( println!("{}", format_quick_pretty(response, use_color)); Ok(()) } + "toon" => print_toon(response), "compact" => print_compact_json(response), "markdown" => { println!("{}", format_quick_markdown(response)); @@ -1119,6 +1130,7 @@ fn print_assistant_response( println!("{}", format_assistant_pretty(response, use_color)); Ok(()) } + AssistantOutputFormat::Toon => print_toon(response), AssistantOutputFormat::Compact => print_compact_json(response), AssistantOutputFormat::Markdown => { println!("{}", format_assistant_markdown(response)); @@ -1215,6 +1227,9 @@ async fn run_search( format_template_response(&response, template.as_deref().unwrap()) } "pretty" => format_pretty_response(&response, use_color), + "toon" => { + return print_toon(&response); + } "compact" => serde_json::to_string(&response).map_err(|error| { KagiError::Parse(format!("failed to serialize search response: {error}")) })?, @@ -1396,6 +1411,7 @@ fn print_news_search( ) -> Result<(), KagiError> { match format { OutputFormat::Json => print_json(response), + OutputFormat::Toon => print_toon(response), OutputFormat::Compact => print_compact_json(response), OutputFormat::Pretty => { println!("{}", format_pretty_news_response(response, use_color)); @@ -1647,7 +1663,7 @@ async fn run_batch_search(config: BatchSearchConfig<'_>) -> Result<(), KagiError } } - if !failures.is_empty() && (format == "json" || format == "compact") { + if !failures.is_empty() && (format == "json" || format == "compact" || format == "toon") { // For machine-readable formats, exit with error code if any queries failed return Err(KagiError::Batch(format_batch_failure_message( results.len(), @@ -1658,7 +1674,7 @@ async fn run_batch_search(config: BatchSearchConfig<'_>) -> Result<(), KagiError let success_count = results.len(); // Output results in order - if format == "json" || format == "compact" { + if format == "json" || format == "compact" || format == "toon" { // For machine-readable formats, create a proper JSON envelope let queries: Vec = results.iter().map(|(query, _)| query.clone()).collect(); let results_payload = results @@ -1677,6 +1693,8 @@ async fn run_batch_search(config: BatchSearchConfig<'_>) -> Result<(), KagiError if format == "compact" { println!("{}", serde_json::to_string(&results_json)?); + } else if format == "toon" { + println!("{}", toon::encode(&results_json, None)); } else { println!("{}", serde_json::to_string_pretty(&results_json)?); } @@ -1866,6 +1884,7 @@ async fn run_watch(args: WatchArgs, profile: Option<&str>) -> Result<(), KagiErr match format.as_str() { "compact" => print_compact_json(&event)?, + "toon" => print_toon(&event)?, "pretty" => println!( "watch #{iteration}: {} added, {} removed", event["added"].as_array().map_or(0, Vec::len), diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index 4dec563..8bdd757 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -214,6 +214,38 @@ fn search_command_returns_json_from_mock_api() { assert_eq!(body["data"][0]["title"], "Rust Programming Language"); } +#[test] +fn search_command_returns_toon_from_mock_api() { + let server = MockServer::start(); + let _search = server.mock(|when, then| { + when.method(GET) + .path("/api/v0/search") + .query_param("q", "rust programming") + .header("authorization", "Bot test-api-token"); + then.status(200) + .header("content-type", "application/json") + .json_body(search_payload( + "Rust Programming Language", + "https://www.rust-lang.org", + "Reliable systems programming.", + )); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let env = test_env(&server); + let output = run_kagi( + &["search", "rust programming", "--format", "toon"], + &env_refs(&env), + tempdir.path(), + ); + + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("data")); + assert!(stdout.contains("Rust Programming Language")); + assert!(stdout.contains("https://www.rust-lang.org")); +} + #[test] fn search_command_pretty_format_prints_ranked_results() { let server = MockServer::start();