From 3307342443b9ca125c7052e5c280e4b919683827 Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 19 Mar 2026 04:00:21 +0000 Subject: [PATCH] feat: add quick answer command --- README.md | 17 +- docs/api-coverage.md | 3 + docs/commands/quick.mdx | 201 ++++++++ docs/demo-assets/quick.gif | Bin 0 -> 35842 bytes docs/demos.md | 6 +- docs/docs.json | 1 + docs/guides/quickstart.mdx | 8 + docs/index.mdx | 23 +- docs/llms.txt | 1 + docs/project/demos.mdx | 10 +- docs/reference/auth-matrix.mdx | 6 + docs/reference/coverage.mdx | 14 +- docs/reference/error-reference.mdx | 39 ++ docs/reference/output-contract.mdx | 60 ++- images/demos/quick.gif | Bin 0 -> 35842 bytes project/demos.mdx | 10 +- scripts/demo-quick.sh | 19 + src/auth.rs | 52 +- src/cli.rs | 44 ++ src/main.rs | 49 +- src/quick.rs | 766 +++++++++++++++++++++++++++++ src/search.rs | 36 +- src/types.rs | 50 ++ 23 files changed, 1373 insertions(+), 42 deletions(-) create mode 100644 docs/commands/quick.mdx create mode 100644 docs/demo-assets/quick.gif create mode 100644 images/demos/quick.gif create mode 100755 scripts/demo-quick.sh create mode 100644 src/quick.rs diff --git a/README.md b/README.md index 712e700..34b1464 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ --- -`kagi` is a terminal CLI for Kagi that gives you command-line access to search, lenses, assistant, summarization, feeds, and paid API commands. it is built for people who want one command surface for interactive use, shell workflows, and structured JSON output. +`kagi` is a terminal CLI for Kagi that gives you command-line access to search, quick answers, lenses, assistant, summarization, feeds, and paid API commands. it is built for people who want one command surface for interactive use, shell workflows, and structured JSON output. the main setup path is your existing Kagi session-link URL. paste it into `kagi auth set --session-token` and the CLI extracts the token for you. if you also use Kagi's paid API, add `KAGI_API_TOKEN` and the public API commands are available too. @@ -29,7 +29,7 @@ if you already use Kagi and want to access it from scripts, shell workflows, or - use your existing session-link URL for subscriber features - get structured JSON for scripts, agents, and other tooling -- use one CLI for search, assistant, summarization, and feeds +- use one CLI for search, quick answers, assistant, summarization, and feeds - add `KAGI_API_TOKEN` only when you want the paid public API commands ## quickstart @@ -101,7 +101,7 @@ export KAGI_API_TOKEN='...' | credential | what it unlocks | | --- | --- | -| `KAGI_SESSION_TOKEN` | base search, `search --lens`, `assistant`, `summarize --subscriber` | +| `KAGI_SESSION_TOKEN` | base search, `search --lens`, `quick`, `assistant`, `summarize --subscriber` | | `KAGI_API_TOKEN` | public `summarize`, `fastgpt`, `enrich web`, `enrich news` | | none | `news`, `smallweb`, `auth status`, `--help` | @@ -138,6 +138,7 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | `kagi auth` | inspect, validate, and save credentials | | `kagi summarize` | use the paid public summarizer API or the subscriber summarizer with `--subscriber` | | `kagi news` | read Kagi News from public JSON endpoints | +| `kagi quick` | get a Quick Answer with references from the subscriber web product | | `kagi assistant` | prompt Kagi Assistant with a subscriber session token | | `kagi fastgpt` | query FastGPT through the paid API | | `kagi enrich` | query Kagi's web and news enrichment indexes | @@ -200,6 +201,12 @@ continue research with assistant: kagi assistant "plan a focused research session in the terminal" ``` +get a quick answer with references: + +```bash +kagi quick --format pretty "what is rust" +``` + use the subscriber summarizer: ```bash @@ -227,7 +234,9 @@ kagi enrich news "browser privacy" ## what it looks like -if you want a quick feel for the cli before installing it, this is the kind of output you get from the subscriber summarizer, assistant, and public news feed: +if you want a quick feel for the cli before installing it, this is the kind of output you get from quick answer, the subscriber summarizer, assistant, and public news feed: + +![quick demo](images/demos/quick.gif) ![summarize demo](images/demos/summarize.gif) diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 21287e0..ff4ccde 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -11,6 +11,7 @@ - **Small Web RSS feed** - implemented and live-verified - **Subscriber web Summarizer** - implemented on the session-token web-product path via `kagi summarize --subscriber ...` - **Kagi News public product endpoints** - implemented via `kagi news ...` +- **Subscriber web Quick Answer flow** - implemented on Kagi's authenticated Quick Answer stream via `kagi quick ...` - **Subscriber web Assistant prompt flow** - implemented on Kagi Assistant's authenticated tagged stream via `kagi assistant ...` ## Source of truth @@ -24,6 +25,7 @@ According to Kagi's public API docs, the documented API families are: This CLI also implements non-public or product-only seams: - subscriber web Summarizer via Kagi session-token auth +- subscriber web Quick Answer via Kagi session-token auth - subscriber web Assistant prompt flow via Kagi session-token auth - Kagi News product endpoints @@ -42,4 +44,5 @@ This CLI also implements non-public or product-only seams: - Live verification on March 16, 2026 showed that `https://translate.kagi.com/api/auth` returns `null` even when the same `KAGI_SESSION_TOKEN` works on `kagi.com`. - Because the repo is marketed around Session Link auth, `translate` was removed from the CLI surface until that mismatch is solved. - Assistant requires `KAGI_SESSION_TOKEN` and currently targets `/assistant/prompt` with the same tagged stream protocol used by the web app. +- Quick Answer requires `KAGI_SESSION_TOKEN` and currently targets `POST /mother/context?q=...` with `Accept: application/vnd.kagi.stream`. - News uses `https://news.kagi.com/api/...` JSON endpoints and does not require auth. diff --git a/docs/commands/quick.mdx b/docs/commands/quick.mdx new file mode 100644 index 0000000..626d2b8 --- /dev/null +++ b/docs/commands/quick.mdx @@ -0,0 +1,201 @@ +--- +title: "quick" +description: "Complete reference for *kagi* quick command - get Kagi Quick Answer responses from the terminal." +--- + +# `kagi quick` + +Generate a Kagi Quick Answer from the authenticated web product and return the full answer envelope, references, and follow-up questions. + +![Quick Answer demo](/images/demos/quick.gif) + +## Synopsis + +```bash +kagi quick [OPTIONS] +``` + +## Description + +`kagi quick` targets Kagi's Quick Answer web-product flow, not FastGPT and not the public Search API. It is useful when you want a fast answer plus references without opening a browser tab. + +This command is ideal for: +- factual questions with references +- shell workflows that want a single answer envelope +- quick terminal lookups with `pretty` or `markdown` output +- automations that want the Quick Answer thread id for later inspection + +## Authentication + +**Required:** `KAGI_SESSION_TOKEN` + +Quick Answer uses subscriber session-token auth. The CLI accepts either: +- the raw session token +- the full Session Link URL such as `https://kagi.com/search?token=...` + +## Arguments + +### `` (Required) + +The text to send to Quick Answer. + +The CLI sends the query verbatim. It does not auto-append a trailing question mark, so both of these are valid: + +```bash +kagi quick "what is rust" +kagi quick "what is rust?" +``` + +## Options + +### `--format ` + +Output format for the response. + +**Supported values:** `json`, `compact`, `pretty`, `markdown` + +**Default:** `json` + +```bash +kagi quick --format pretty "what is rust" +kagi quick --format markdown "what is rust" +``` + +### `--no-color` + +Disable ANSI colors in `--format pretty`. + +```bash +kagi quick --format pretty --no-color "what is rust" +``` + +### `--lens ` + +Scope the query to one of your Kagi lenses by numeric index. + +```bash +kagi quick --lens 0 "best rust tutorials" +``` + +Lens indices are user-specific. Use the same `l=` numeric value you would use with `kagi search --lens`. + +## Output Format + +Default JSON output: + +```json +{ + "meta": { + "version": "202603171911.stage.707e740", + "trace": "trace-123" + }, + "query": "what is rust", + "lens": null, + "message": { + "id": "msg-1", + "thread_id": "thread-1", + "created_at": "2026-03-19T00:00:00Z", + "state": "done", + "prompt": "what is rust", + "html": "

Rust is a systems programming language.

", + "markdown": "Rust is a systems programming language." + }, + "references": { + "markdown": "[^1]: [Rust](https://www.rust-lang.org/) (26%)", + "items": [ + { + "index": 1, + "title": "Rust", + "domain": "www.rust-lang.org", + "url": "https://www.rust-lang.org/", + "contribution_pct": 26 + } + ] + }, + "followup_questions": [ + "Why is Rust memory-safe?" + ] +} +``` + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `meta` | object | Stream metadata such as version and trace id | +| `query` | string | The original query text sent to Quick Answer | +| `lens` | string or null | The requested lens index, if one was supplied | +| `message` | object | Final Quick Answer message payload | +| `references` | object | Reference markdown plus parsed reference items | +| `followup_questions` | array | Follow-up suggestions returned by Kagi | + +## Examples + +### Basic Queries + +```bash +kagi quick "what is rust" +kagi quick "best way to clean cast iron" +``` + +### Pretty Terminal Output + +```bash +kagi quick --format pretty "what is rust" +``` + +### Markdown for Notes + +```bash +kagi quick --format markdown "what is rust?" > quick-answer.md +``` + +### Lens-Scoped Quick Answers + +```bash +kagi quick --lens 0 "best rust tutorials" +``` + +### JSON Processing + +```bash +# Extract the answer body +kagi quick "what is rust" | jq -r '.message.markdown' + +# Extract reference URLs +kagi quick "what is rust" | jq -r '.references.items[].url' + +# Save the Quick Answer thread id +kagi quick "what is rust" | jq -r '.message.thread_id' +``` + +## Output Modes + +### `json` + +Pretty-printed JSON envelope for scripts and inspection. + +### `compact` + +Minified JSON envelope for pipelines or storage-sensitive paths. + +### `pretty` + +Terminal-friendly answer text followed by a references section and any follow-up questions. + +### `markdown` + +The answer markdown, then the references markdown, then a follow-up section if present. + +## Limitations + +- Requires an active Kagi subscription and session token +- This command is single-turn only +- The returned `message.thread_id` is informational today; there is no `--thread-id` follow-up mode yet +- Output quality and references depend on the live Quick Answer product + +## See Also + +- [assistant](/commands/assistant) - Conversational multi-turn AI via Kagi Assistant +- [search](/commands/search) - Full search results instead of a single synthesized answer +- [Authentication](/guides/authentication) - Session-token setup diff --git a/docs/demo-assets/quick.gif b/docs/demo-assets/quick.gif new file mode 100644 index 0000000000000000000000000000000000000000..ea639db100df1d277a9ed655b87bd6e46b1a6a5e GIT binary patch literal 35842 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 CW=g04 literal 0 HcmV?d00001 diff --git a/docs/demos.md b/docs/demos.md index d02963e..9c485fd 100644 --- a/docs/demos.md +++ b/docs/demos.md @@ -7,6 +7,7 @@ The demo scripts build the local debug binary, expose it as `kagi` through `/tmp ## Assets - `docs/demo-assets/search.gif` +- `docs/demo-assets/quick.gif` - `docs/demo-assets/summarize.gif` - `docs/demo-assets/news.gif` - `docs/demo-assets/assistant.gif` @@ -18,12 +19,13 @@ Subscriber demos require `KAGI_SESSION_TOKEN` in the environment. API-token demo The current demo commands are: - `kagi search --format pretty "obsidian cli daily notes workflow"` +- `kagi quick --format pretty "what is rust"` - `kagi summarize --subscriber --url https://mullvad.net/en/browser | jq -M ...` - `kagi news --category tech --limit 1 | jq -M ...` - `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` ```bash -chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh +chmod +x scripts/demo-search.sh scripts/demo-quick.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh mkdir -p docs/demo-assets /tmp/kagi-demos @@ -31,11 +33,13 @@ agg --version # expected: "asciinema gif generator" KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-search.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/search.cast +KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-quick.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/quick.cast KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-summarize.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/summarize.cast asciinema rec -c ./scripts/demo-news.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/news.cast KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-assistant.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/assistant.cast agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/search.cast docs/demo-assets/search.gif +agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/quick.cast docs/demo-assets/quick.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/summarize.cast docs/demo-assets/summarize.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/news.cast docs/demo-assets/news.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/assistant.cast docs/demo-assets/assistant.gif diff --git a/docs/docs.json b/docs/docs.json index 8285979..1a19287 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,6 +84,7 @@ { "group": "AI & Enrichment", "pages": [ + "commands/quick", "commands/assistant", "commands/fastgpt", "commands/enrich" diff --git a/docs/guides/quickstart.mdx b/docs/guides/quickstart.mdx index 18b8719..93482d7 100644 --- a/docs/guides/quickstart.mdx +++ b/docs/guides/quickstart.mdx @@ -171,6 +171,9 @@ kagi search --lens 2 "developer documentation" ![Search command demo](/images/demos/search.gif) ```bash +# Test Quick Answer +kagi quick --format pretty "what is rust" + # Test Assistant kagi assistant "What are the key features of Rust?" @@ -178,6 +181,10 @@ kagi assistant "What are the key features of Rust?" kagi summarize --subscriber --url https://www.rust-lang.org --summary-type keypoints --length digest ``` +**Quick Answer Demo:** + +![Quick command demo](/images/demos/quick.gif) + **Assistant Demo:** ![Assistant command demo](/images/demos/assistant.gif) @@ -419,6 +426,7 @@ kagi search --lens 2 --format pretty "query" # Combined ```bash kagi summarize --url https://example.com # Public API kagi summarize --subscriber --url https://example.com # Subscriber +kagi quick "what is rust" # Quick Answer kagi fastgpt "question" # Quick answer kagi assistant "prompt" # AI assistant kagi enrich web "query" # Web enrichment diff --git a/docs/index.mdx b/docs/index.mdx index 6fdb7c7..25dafdc 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -49,6 +49,7 @@ Unlock the full potential of your Kagi subscription: - **Lens-Aware Search**: Use your custom Kagi lenses directly from the command line. +- **Quick Answers**: Get Kagi Quick Answer responses with references and follow-up questions. - **Assistant Integration**: Prompt Kagi Assistant programmatically and continue conversations across sessions. - **Subscriber Features**: Access subscriber-only capabilities like the web-based Summarizer with full control over output length and style. @@ -104,9 +105,10 @@ flowchart TB subgraph SubscriberWeb["Subscriber Web Product"] web1["search --lens"] - web2["assistant"] - web3["summarize --subscriber"] - web4["search (session)"] + web2["quick"] + web3["assistant"] + web4["summarize --subscriber"] + web5["search (session)"] end subgraph PublicFeeds["Public Feeds (No Auth)"] @@ -187,6 +189,21 @@ kagi search --lens 2 "developer documentation" Each variant uses the same underlying command but adapts output and capabilities based on flags and authentication. The command intelligently selects the appropriate transport (API vs session) and handles fallback automatically. +### Example: Quick Answers With References + +The `kagi quick` command is a good fit when you want one synthesized answer instead of a full result list: + +```bash +# Structured JSON envelope +kagi quick "what is rust" + +# Terminal-friendly output +kagi quick --format pretty "what is rust" + +# Capture reference URLs +kagi quick "what is rust" | jq -r '.references.items[].url' +``` + ### Example: Authentication Precedence The CLI follows a clear precedence model: diff --git a/docs/llms.txt b/docs/llms.txt index 5f99fe3..0e283e1 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -19,6 +19,7 @@ - [summarize](https://kagi.micr.dev/commands/summarize): Complete reference for *kagi* summarize command - summarize URLs and text using subscriber or public API modes. - [news](https://kagi.micr.dev/commands/news): Complete reference for *kagi* news command - fetch Kagi News from public JSON endpoints with category filtering. - [smallweb](https://kagi.micr.dev/commands/smallweb): Complete reference for *kagi* smallweb command - fetch the Kagi Small Web feed of independent websites. +- [quick](https://kagi.micr.dev/commands/quick): Complete reference for *kagi* quick command - get Kagi Quick Answer responses with references from the terminal. - [assistant](https://kagi.micr.dev/commands/assistant): Complete reference for *kagi* assistant command - interact with Kagi AI Assistant programmatically. - [fastgpt](https://kagi.micr.dev/commands/fastgpt): Complete reference for *kagi* fastgpt command - get quick answers using Kagi's FastGPT API. - [enrich](https://kagi.micr.dev/commands/enrich): Complete reference for *kagi* enrich command - query Kagi's enrichment indexes for web and news. diff --git a/docs/project/demos.mdx b/docs/project/demos.mdx index 2812cff..cb1533c 100644 --- a/docs/project/demos.mdx +++ b/docs/project/demos.mdx @@ -8,6 +8,7 @@ description: "Recorded terminal demos and how to regenerate them." The docs site ships with recorded terminal GIFs from the repo: - search +- quick answer - subscriber summarize - news - assistant @@ -18,6 +19,10 @@ The docs site ships with recorded terminal GIFs from the repo: ![Search demo](/images/demos/search.gif) +### Quick Answer + +![Quick Answer demo](/images/demos/quick.gif) + ### Subscriber Summarize ![Summarize demo](/images/demos/summarize.gif) @@ -39,17 +44,19 @@ The demo scripts build the local debug binary, expose it as `kagi` through `/tmp The current demo commands are: - `kagi search --format pretty "obsidian cli daily notes workflow"` +- `kagi quick --format pretty "what is rust"` - `kagi summarize --subscriber --url https://mullvad.net/en/browser | jq -M ...` - `kagi news --category tech --limit 1 | jq -M ...` - `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` ```bash -chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh +chmod +x scripts/demo-search.sh scripts/demo-quick.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh mkdir -p docs/demo-assets /tmp/kagi-demos agg --version KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-search.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/search.cast +KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-quick.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/quick.cast KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-summarize.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/summarize.cast asciinema rec -c ./scripts/demo-news.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/news.cast KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-assistant.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/assistant.cast @@ -61,6 +68,7 @@ Use the official asciinema `agg` binary when exporting GIFs. On this machine, th ```bash agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/search.cast docs/demo-assets/search.gif +agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/quick.cast docs/demo-assets/quick.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/summarize.cast docs/demo-assets/summarize.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/news.cast docs/demo-assets/news.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 4 /tmp/kagi-demos/assistant.cast docs/demo-assets/assistant.gif diff --git a/docs/reference/auth-matrix.mdx b/docs/reference/auth-matrix.mdx index 611f22a..1df9855 100644 --- a/docs/reference/auth-matrix.mdx +++ b/docs/reference/auth-matrix.mdx @@ -19,6 +19,7 @@ This reference provides a complete mapping of which commands require which authe | `summarize` | `KAGI_API_TOKEN` | None | Paid public API | | `summarize --subscriber` | `KAGI_SESSION_TOKEN` | None | Subscriber web product | | `news` | None | None | Public endpoint | +| `quick` | `KAGI_SESSION_TOKEN` | None | Quick Answer web product | | `assistant` | `KAGI_SESSION_TOKEN` | None | Subscriber feature | | `fastgpt` | `KAGI_API_TOKEN` | None | Paid public API | | `enrich web` | `KAGI_API_TOKEN` | None | Paid public API | @@ -102,6 +103,7 @@ flowchart TD | Command | Token | Purpose | |---------|-------|---------| +| `quick` | `KAGI_SESSION_TOKEN` | Quick answers with references | | `assistant` | `KAGI_SESSION_TOKEN` | Conversational AI with threads | | `fastgpt` | `KAGI_API_TOKEN` | Quick factual answers | @@ -130,6 +132,7 @@ Both `enrich web` and `enrich news` require `KAGI_API_TOKEN`: Requires `KAGI_SESSION_TOKEN`: - ✅ Lens-aware search (`--lens`) +- ✅ Quick Answer (`quick`) - ✅ Kagi Assistant (`assistant`) - ✅ Subscriber Summarizer (`summarize --subscriber`) - ✅ Base search (fallback) @@ -206,6 +209,7 @@ kagi auth set --session-token 'https://kagi.com/search?token=...' **Working commands:** - ✅ `kagi search "query"` (uses session path) - ✅ `kagi search --lens 2 "query"` +- ✅ `kagi quick "what is rust"` - ✅ `kagi assistant "prompt"` - ✅ `kagi summarize --subscriber --url ...` - ✅ `kagi news` @@ -233,6 +237,7 @@ kagi auth set --api-token 'your_api_token' **Non-working:** - ❌ `kagi search --lens 2` - requires session token +- ❌ `kagi quick` - requires session token - ❌ `kagi assistant` - requires session token - ❌ `kagi summarize --subscriber` - requires session token @@ -250,6 +255,7 @@ kagi auth set --session-token '...' --api-token '...' - `search`: Uses API (preferred), falls back to session if needed - `summarize` without `--subscriber`: Uses API - `summarize --subscriber`: Uses session +- `quick`: Uses session - `assistant`: Uses session - `fastgpt`: Uses API diff --git a/docs/reference/coverage.mdx b/docs/reference/coverage.mdx index 7222877..dd58e7f 100644 --- a/docs/reference/coverage.mdx +++ b/docs/reference/coverage.mdx @@ -29,6 +29,7 @@ These features use the subscriber web product (Session Token): |---------|---------|--------| | Base Search | `kagi search` | ✅ Implemented (session path) | | Lens Search | `kagi search --lens` | ✅ Implemented | +| Quick Answer | `kagi quick` | ✅ Implemented | | Web Summarizer | `kagi summarize --subscriber` | ✅ Implemented | | Assistant | `kagi assistant` | ✅ Implemented | @@ -55,6 +56,7 @@ These require no authentication: | `summarize` | Public API summarizer | API | ✅ | | `summarize --subscriber` | Web summarizer | Session | ✅ | | `news` | News feed | None | ✅ | +| `quick` | Quick Answer | Session | ✅ | | `assistant` | AI assistant | Session | ✅ | | `fastgpt` | Fast answers | API | ✅ | | `enrich web` | Web enrichment | API | ✅ | @@ -65,8 +67,8 @@ These require no authentication: | Option | Commands | Status | |--------|----------|--------| -| `--format pretty` | search | ✅ | -| `--lens` | search | ✅ | +| `--format pretty` | search, batch, quick | ✅ | +| `--lens` | search, batch, quick | ✅ | | `--limit` | news, smallweb | ✅ | | `--category` | news | ✅ | | `--list-categories` | news | ✅ | @@ -81,6 +83,7 @@ These require no authentication: | `--web-search` | fastgpt | ✅ | | `--target-language` | summarize | ✅ | | `--thread-id` | assistant | ✅ | +| `--no-color` | search, batch, quick | ✅ | ## Not Available @@ -120,7 +123,7 @@ These features were evaluated and left out of the public CLI surface: | Type | Support | Commands | |------|---------|----------| -| Session Token | ✅ | search, summarize --subscriber, assistant | +| Session Token | ✅ | search, quick, summarize --subscriber, assistant | | API Token | ✅ | search, summarize, fastgpt, enrich | ### Authentication Patterns @@ -142,6 +145,8 @@ All commands output JSON: | Command | Schema Stability | |---------|-----------------| | search | Stable | +| batch | Stable | +| quick | Stable | | news | Stable | | smallweb | Stable | | summarize | Stable | @@ -154,6 +159,8 @@ All commands output JSON: | Command | Pretty Mode | |---------|------------| | search | ✅ Yes | +| batch | ✅ Yes | +| quick | ✅ Yes | | news | ❌ No (use jq) | | smallweb | ❌ No (use jq) | | Others | ❌ JSON only | @@ -191,6 +198,7 @@ All commands output JSON: |---------|-----|-----| | Search | ✅ | ✅ | | Lens Search | ✅ | ✅ | +| Quick Answer | ✅ | ✅ | | Assistant | ✅ (basic) | ✅ (full) | | Summarizer | ✅ | ✅ | | Translate | ❌ | ✅ | diff --git a/docs/reference/error-reference.mdx b/docs/reference/error-reference.mdx index f12d42a..9ce898c 100644 --- a/docs/reference/error-reference.mdx +++ b/docs/reference/error-reference.mdx @@ -60,6 +60,13 @@ kagi auth set --session-token 'https://kagi.com/search?token=YOUR_TOKEN' kagi summarize --subscriber --url https://example.com # Use --subscriber for session ``` +This same requirement applies to session-token web-product commands such as: + +```bash +kagi quick "what is rust" +kagi assistant "Explain Rust ownership" +``` + ### "this command requires KAGI_API_TOKEN" **Message:** @@ -133,6 +140,20 @@ config path: .kagi.toml **Solution:** This is informational. Run `kagi auth set` to create it, or use environment variables. +### "quick query cannot be empty" + +**Message:** +``` +configuration error: quick query cannot be empty +``` + +**Meaning:** `kagi quick` received an empty string after trimming whitespace. + +**Solution:** +```bash +kagi quick "what is rust" +``` + ### "Permission denied" **Message:** @@ -172,6 +193,24 @@ Error: Config: --length requires --subscriber kagi summarize --subscriber --url https://example.com --length digest ``` +### "lens 'abc' must be a numeric index" + +**Message:** +``` +configuration error: lens 'abc' must be a numeric index (e.g., '0', '1', '2'). +``` + +**Meaning:** Lens-aware commands only accept the numeric `l=` value from Kagi's web UI. + +**Solution:** +```bash +# Search +kagi search --lens 2 "developer documentation" + +# Quick Answer +kagi quick --lens 2 "best rust tutorials" +``` + ### "--engine is only supported for the paid public summarizer API" **Message:** diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index a2cef36..eb5da0d 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` and `kagi batch` support human-readable output via `--format pretty`. +2. `kagi search`, `kagi batch`, and `kagi quick` support human-readable output via format flags. 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. @@ -137,6 +137,43 @@ Subscriber mode: } ``` +### `kagi quick` + +```json +{ + "meta": { + "version": "202603171911.stage.707e740", + "trace": "trace-123" + }, + "query": "what is rust", + "lens": null, + "message": { + "id": "msg-1", + "thread_id": "thread-1", + "created_at": "2026-03-19T00:00:00Z", + "state": "done", + "prompt": "what is rust", + "html": "

Rust is a systems programming language.

", + "markdown": "Rust is a systems programming language." + }, + "references": { + "markdown": "[^1]: [Rust](https://www.rust-lang.org/) (26%)", + "items": [ + { + "index": 1, + "title": "Rust", + "domain": "www.rust-lang.org", + "url": "https://www.rust-lang.org/", + "contribution_pct": 26 + } + ] + }, + "followup_questions": [ + "Why is Rust memory-safe?" + ] +} +``` + ### `kagi fastgpt` ```json @@ -199,6 +236,21 @@ If the result set is empty, the CLI prints: No results found. ``` +## Pretty Quick Output + +`kagi quick --format pretty` renders: + +```text +Quick Answer + +Rust is a systems programming language... + +References + +1. Learn (35%) + https://www.rust-lang.org/learn +``` + ## Error Behavior Errors are plain text on stderr. Typical examples: @@ -221,6 +273,12 @@ kagi fastgpt "What is Rust?" | jq -r '.data.output' # Assistant markdown reply kagi assistant "Hello" | jq -r '.message.markdown' +# Quick Answer markdown reply +kagi quick "what is rust" | jq -r '.message.markdown' + +# Quick Answer references +kagi quick "what is rust" | jq -r '.references.items[].url' + # Subscriber summary text kagi summarize --subscriber --url https://example.com | jq -r '.data.output' diff --git a/images/demos/quick.gif b/images/demos/quick.gif new file mode 100644 index 0000000000000000000000000000000000000000..ea639db100df1d277a9ed655b87bd6e46b1a6a5e GIT binary patch literal 35842 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 CW=g04 literal 0 HcmV?d00001 diff --git a/project/demos.mdx b/project/demos.mdx index 92069a8..09190a6 100644 --- a/project/demos.mdx +++ b/project/demos.mdx @@ -8,6 +8,7 @@ description: "Recorded terminal demos and how to regenerate them." The docs site ships with recorded terminal GIFs from the repo: - search +- quick answer - subscriber summarize - news - assistant @@ -18,6 +19,10 @@ The docs site ships with recorded terminal GIFs from the repo: ![Search demo](/images/demos/search.gif) +### Quick Answer + +![Quick Answer demo](/images/demos/quick.gif) + ### Subscriber Summarize ![Summarize demo](/images/demos/summarize.gif) @@ -39,17 +44,19 @@ The demo scripts build the local debug binary, expose it as `kagi` through `/tmp The current demo commands are: - `kagi search --format pretty "obsidian cli daily notes workflow"` +- `kagi quick --format pretty "what is rust"` - `kagi summarize --subscriber --url https://mullvad.net/en/browser | jq -M ...` - `kagi news --category tech --limit 1 | jq -M ...` - `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` ```bash -chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh +chmod +x scripts/demo-search.sh scripts/demo-quick.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh mkdir -p docs/demo-assets /tmp/kagi-demos agg --version KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-search.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/search.cast +KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-quick.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/quick.cast KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-summarize.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/summarize.cast asciinema rec -c ./scripts/demo-news.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/news.cast KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-assistant.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/assistant.cast @@ -61,6 +68,7 @@ Use the official asciinema `agg` binary when exporting GIFs. On this machine, th ```bash agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 2 /tmp/kagi-demos/search.cast docs/demo-assets/search.gif +agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 2 /tmp/kagi-demos/quick.cast docs/demo-assets/quick.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 2 /tmp/kagi-demos/summarize.cast docs/demo-assets/summarize.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 2 /tmp/kagi-demos/news.cast docs/demo-assets/news.gif agg --theme asciinema --font-size 14 --idle-time-limit 2 --last-frame-duration 2 /tmp/kagi-demos/assistant.cast docs/demo-assets/assistant.gif diff --git a/scripts/demo-quick.sh b/scripts/demo-quick.sh new file mode 100755 index 0000000..451090c --- /dev/null +++ b/scripts/demo-quick.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +: "${KAGI_SESSION_TOKEN:?set KAGI_SESSION_TOKEN before running this demo}" +unset KAGI_API_TOKEN + +cargo build --quiet +mkdir -p /tmp/kagi-demo-bin +ln -sf "$PWD/target/debug/kagi" /tmp/kagi-demo-bin/kagi +export PATH="/tmp/kagi-demo-bin:$PATH" + +printf '\033c' +sleep 1.2 +printf '$ kagi quick --format pretty "what is rust"\n' +sleep 0.4 +kagi quick --format pretty "what is rust" | sed -n '1,18p' +sleep 2 diff --git a/src/auth.rs b/src/auth.rs index 6809cd6..f3d9713 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -183,11 +183,9 @@ pub fn load_credential_inventory() -> Result { source: CredentialSource::Env, value, }); - let env_session = read_env_credential(SESSION_TOKEN_ENV).map(|value| Credential { - kind: CredentialKind::SessionToken, - source: CredentialSource::Env, - value, - }); + let env_session = read_env_credential(SESSION_TOKEN_ENV) + .map(|value| build_session_credential(&value, CredentialSource::Env)) + .transpose()?; let config_api = config .auth @@ -207,11 +205,8 @@ pub fn load_credential_inventory() -> Result { .and_then(|auth| auth.session_token.as_ref()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .map(|value| Credential { - kind: CredentialKind::SessionToken, - source: CredentialSource::Config, - value, - }); + .map(|value| build_session_credential(&value, CredentialSource::Config)) + .transpose()?; Ok(CredentialInventory { api_token: env_api.or(config_api), @@ -257,6 +252,17 @@ fn read_env_credential(key: &str) -> Option { .filter(|value| !value.is_empty()) } +fn build_session_credential( + raw_value: &str, + source: CredentialSource, +) -> Result { + Ok(Credential { + kind: CredentialKind::SessionToken, + source, + value: normalize_session_token_input(raw_value)?, + }) +} + pub fn save_credentials( api_token: Option<&str>, session_input: Option<&str>, @@ -584,6 +590,32 @@ mod tests { assert_eq!(token, "abc123.def456"); } + #[test] + fn builds_env_session_credential_from_session_link() { + let credential = build_session_credential( + "https://kagi.com/search?token=abc123.def456", + CredentialSource::Env, + ) + .expect("session link should normalize"); + + assert_eq!(credential.kind, CredentialKind::SessionToken); + assert_eq!(credential.source, CredentialSource::Env); + assert_eq!(credential.value, "abc123.def456"); + } + + #[test] + fn builds_config_session_credential_from_session_link() { + let credential = build_session_credential( + "https://kagi.com/search?token=abc123.def456", + CredentialSource::Config, + ) + .expect("session link should normalize"); + + assert_eq!(credential.kind, CredentialKind::SessionToken); + assert_eq!(credential.source, CredentialSource::Config); + assert_eq!(credential.value, "abc123.def456"); + } + #[test] fn rejects_session_link_without_token_param() { let error = normalize_session_token_input("https://kagi.com/search?q=test") diff --git a/src/cli.rs b/src/cli.rs index 5b86e4f..fcd6086 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,29 @@ impl std::fmt::Display for OutputFormat { } } +#[derive(Debug, Clone, ValueEnum)] +pub enum QuickOutputFormat { + /// JSON output (default) - structured data for scripts and APIs + Json, + /// Pretty formatted output with colors - human-readable terminal display + Pretty, + /// Compact JSON output - minified JSON for reduced size + Compact, + /// Markdown formatted output - optimized for documentation and notes + Markdown, +} + +impl std::fmt::Display for QuickOutputFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + QuickOutputFormat::Json => write!(f, "json"), + QuickOutputFormat::Pretty => write!(f, "pretty"), + QuickOutputFormat::Compact => write!(f, "compact"), + QuickOutputFormat::Markdown => write!(f, "markdown"), + } + } +} + #[derive(Debug, Parser)] #[command( name = "kagi", @@ -81,6 +104,8 @@ pub enum Commands { News(NewsArgs), /// Prompt Kagi Assistant with subscriber session-token auth Assistant(AssistantArgs), + /// Generate a Kagi Quick Answer from live search results + Quick(QuickArgs), /// Answer a query with Kagi's FastGPT API Fastgpt(FastGptArgs), /// Query Kagi's enrichment indexes @@ -274,6 +299,25 @@ pub struct AssistantArgs { pub thread_id: Option, } +#[derive(Debug, Args)] +pub struct QuickArgs { + /// Query to answer with Kagi Quick Answer + #[arg(value_name = "QUERY")] + pub query: String, + + /// Output format + #[arg(long, value_name = "FORMAT", default_value_t = QuickOutputFormat::Json)] + pub format: QuickOutputFormat, + + /// Disable colored terminal output (only affects pretty format) + #[arg(long)] + pub no_color: bool, + + /// Scope quick answer to a Kagi lens by numeric index + #[arg(long, value_name = "INDEX")] + pub lens: Option, +} + #[derive(Debug, Args)] pub struct EnrichCommand { #[command(subcommand)] diff --git a/src/main.rs b/src/main.rs index ce19788..58a6671 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod auth; mod cli; mod error; mod parser; +mod quick; mod search; mod types; @@ -20,9 +21,10 @@ use crate::auth::{ }; use crate::cli::{AuthSetArgs, AuthSubcommand, Cli, Commands, CompletionShell, EnrichSubcommand}; use crate::error::KagiError; +use crate::quick::{execute_quick, format_quick_markdown, format_quick_pretty}; use crate::types::{ - AssistantPromptRequest, FastGptRequest, SearchResponse, SubscriberSummarizeRequest, - SummarizeRequest, + AssistantPromptRequest, FastGptRequest, QuickResponse, SearchResponse, + SubscriberSummarizeRequest, SummarizeRequest, }; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -139,6 +141,23 @@ async fn run() -> Result<(), KagiError> { let response = execute_assistant_prompt(&request, &token).await?; print_json(&response) } + Commands::Quick(args) => { + let token = resolve_session_token()?; + let request = search::SearchRequest::new(args.query.trim().to_string()); + let request = if let Some(lens) = args.lens { + request.with_lens(lens) + } else { + request + }; + let format_str = match args.format { + cli::QuickOutputFormat::Json => "json", + cli::QuickOutputFormat::Pretty => "pretty", + cli::QuickOutputFormat::Compact => "compact", + cli::QuickOutputFormat::Markdown => "markdown", + }; + let response = execute_quick(&request, &token).await?; + print_quick_response(&response, format_str, !args.no_color) + } Commands::Fastgpt(args) => { let request = FastGptRequest { query: args.query, @@ -292,6 +311,32 @@ fn print_json(value: &T) -> Result<(), KagiError> { Ok(()) } +fn print_compact_json(value: &T) -> Result<(), KagiError> { + let output = serde_json::to_string(value) + .map_err(|error| KagiError::Parse(format!("failed to serialize JSON output: {error}")))?; + println!("{output}"); + Ok(()) +} + +fn print_quick_response( + response: &QuickResponse, + format: &str, + use_color: bool, +) -> Result<(), KagiError> { + match format { + "pretty" => { + println!("{}", format_quick_pretty(response, use_color)); + Ok(()) + } + "compact" => print_compact_json(response), + "markdown" => { + println!("{}", format_quick_markdown(response)); + Ok(()) + } + _ => print_json(response), + } +} + async fn run_search( request: search::SearchRequest, format: String, diff --git a/src/quick.rs b/src/quick.rs new file mode 100644 index 0000000..251c0d5 --- /dev/null +++ b/src/quick.rs @@ -0,0 +1,766 @@ +use reqwest::{Client, StatusCode, Url, header}; +use scraper::Html; +use serde::Deserialize; + +use crate::error::KagiError; +use crate::search::{SearchRequest, validate_lens_value}; +use crate::types::{ + QuickMessage, QuickMeta, QuickReferenceCollection, QuickReferenceItem, QuickResponse, +}; + +const USER_AGENT: &str = "kagi-cli/0.1.0 (+https://github.com/)"; +const KAGI_QUICK_ANSWER_URL: &str = "https://kagi.com/mother/context"; + +pub async fn execute_quick( + request: &SearchRequest, + token: &str, +) -> Result { + if token.trim().is_empty() { + return Err(KagiError::Auth( + "missing Kagi session token (expected KAGI_SESSION_TOKEN)".to_string(), + )); + } + + let query = request.query.trim(); + if query.is_empty() { + return Err(KagiError::Config("quick query cannot be empty".to_string())); + } + + let lens = request + .lens + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if request.lens.is_some() && lens.is_none() { + return Err(KagiError::Config( + "quick --lens cannot be empty".to_string(), + )); + } + if let Some(lens) = lens { + validate_lens_value(lens)?; + } + + let client = build_client()?; + let mut query_params = vec![("q", query)]; + if let Some(lens) = lens { + query_params.push(("l", lens)); + } + + let response = client + .post(KAGI_QUICK_ANSWER_URL) + .body(String::new()) + .query(&query_params) + .header(header::COOKIE, format!("kagi_session={token}")) + .header(header::ACCEPT, "application/vnd.kagi.stream") + .header(header::CONTENT_LENGTH, "0") + .header(header::CACHE_CONTROL, "no-store") + .send() + .await + .map_err(map_transport_error)?; + + match response.status() { + StatusCode::OK => { + let body = response.text().await.map_err(|error| { + KagiError::Network(format!( + "failed to read quick answer response body: {error}" + )) + })?; + + if looks_like_html_document(&body) { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + + parse_quick_answer_stream(&body, query, lens) + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )), + status if status.is_client_error() => { + let body = response.text().await.unwrap_or_else(|_| String::new()); + Err(KagiError::Config(format!( + "Kagi Quick Answer request rejected: HTTP {status}{}", + format_client_error_suffix(&body) + ))) + } + status if status.is_server_error() => Err(KagiError::Network(format!( + "Kagi Quick Answer server error: HTTP {status}" + ))), + status => Err(KagiError::Network(format!( + "unexpected Kagi Quick Answer response status: HTTP {status}" + ))), + } +} + +pub fn format_quick_pretty(response: &QuickResponse, use_color: bool) -> String { + let heading_color = if use_color { "\x1b[1;34m" } else { "" }; + let url_color = if use_color { "\x1b[36m" } else { "" }; + let reset_color = if use_color { "\x1b[0m" } else { "" }; + let answer = render_pretty_answer(response); + + let mut sections = Vec::new(); + sections.push(format!( + "{heading_color}Quick Answer{reset_color}\n\n{}", + answer + )); + + if !response.references.items.is_empty() { + let references = response + .references + .items + .iter() + .map(|reference| { + let contribution = reference + .contribution_pct + .map(|value| format!(" ({value}%)")) + .unwrap_or_default(); + format!( + "{}{}. {}{}{}\n {}{}{}", + heading_color, + reference.index, + reference.title, + contribution, + reset_color, + url_color, + reference.url, + reset_color + ) + }) + .collect::>() + .join("\n\n"); + sections.push(format!( + "{heading_color}References{reset_color}\n\n{references}" + )); + } + + if !response.followup_questions.is_empty() { + let followups = response + .followup_questions + .iter() + .map(|question| format!("- {question}")) + .collect::>() + .join("\n"); + sections.push(format!( + "{heading_color}Follow-up Questions{reset_color}\n\n{followups}" + )); + } + + sections.join("\n\n") +} + +pub fn format_quick_markdown(response: &QuickResponse) -> String { + let mut sections = Vec::new(); + sections.push(render_markdown_answer(response)); + + if !response.references.markdown.trim().is_empty() { + sections.push(response.references.markdown.trim().to_string()); + } + + if !response.followup_questions.is_empty() { + let followups = response + .followup_questions + .iter() + .map(|question| format!("- {question}")) + .collect::>() + .join("\n"); + sections.push(format!("## Follow-up Questions\n\n{followups}")); + } + + sections + .into_iter() + .filter(|section| !section.is_empty()) + .collect::>() + .join("\n\n") +} + +fn parse_quick_answer_stream( + body: &str, + query: &str, + lens: Option<&str>, +) -> Result { + let mut meta = QuickMeta::default(); + let mut last_tokens_html = String::new(); + let mut message = None; + + for frame in body.split("\0\n").filter(|frame| !frame.trim().is_empty()) { + let Some((tag, payload)) = frame.split_once(':') else { + continue; + }; + + match tag { + "hi" => { + let hello: QuickHello = serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse quick answer hello frame: {error}")) + })?; + meta.version = hello.v; + meta.trace = hello.trace; + } + "tokens.json" => { + let token_frame: QuickTokensFrame = + serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!( + "failed to parse quick answer token frame: {error}" + )) + })?; + last_tokens_html = token_frame.text; + } + "new_message.json" => { + let payload: QuickMessagePayload = + serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!( + "failed to parse quick answer message frame: {error}" + )) + })?; + message = Some(payload); + } + "limit_notice.html" => { + let detail = html_to_text(payload); + return Err(KagiError::Config(if detail.is_empty() { + "Kagi Quick Answer is currently unavailable for this account or request" + .to_string() + } else { + format!("Kagi Quick Answer is currently unavailable: {detail}") + })); + } + "unauthorized" => { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + _ => {} + } + } + + let message = message.ok_or_else(|| { + if last_tokens_html.is_empty() { + KagiError::Parse( + "quick answer response did not include a new_message.json frame".to_string(), + ) + } else { + KagiError::Parse( + "quick answer response ended before the final new_message.json frame".to_string(), + ) + } + })?; + + if message.state == "error" { + let detail = if message.md.trim().is_empty() { + html_to_text(&message.reply) + } else { + message.md.trim().to_string() + }; + return Err(KagiError::Network(if detail.is_empty() { + "Kagi Quick Answer returned an error state".to_string() + } else { + format!("Kagi Quick Answer failed: {detail}") + })); + } + + Ok(QuickResponse { + meta, + query: query.to_string(), + lens: lens.map(|value| value.to_string()), + message: QuickMessage { + id: message.id, + thread_id: message.thread_id, + created_at: message.created_at, + state: message.state, + prompt: message.prompt, + html: if message.reply.trim().is_empty() { + last_tokens_html + } else { + message.reply + }, + markdown: message.md, + }, + references: QuickReferenceCollection { + markdown: message.references_md.clone(), + items: parse_quick_reference_markdown(&message.references_md), + }, + followup_questions: message.followup_questions, + }) +} + +fn parse_quick_reference_markdown(markdown: &str) -> Vec { + markdown + .lines() + .filter_map(parse_quick_reference_line) + .collect() +} + +fn parse_quick_reference_line(line: &str) -> Option { + let line = line.trim(); + let rest = line.strip_prefix("[^")?; + let (index_raw, rest) = rest.split_once("]: ")?; + let index = index_raw.parse::().ok()?; + let rest = rest.strip_prefix('[')?; + let title_end = rest.find("](")?; + let title = rest[..title_end].trim().to_string(); + let remainder = &rest[(title_end + 2)..]; + + let (url, contribution_pct) = if let Some(split_index) = remainder.rfind(") (") { + let url = remainder[..split_index].trim().to_string(); + let contribution = remainder[(split_index + 3)..] + .trim_end_matches(')') + .trim() + .trim_end_matches('%') + .parse::() + .ok(); + (url, contribution) + } else { + (remainder.trim_end_matches(')').trim().to_string(), None) + }; + + Some(QuickReferenceItem { + index, + title, + domain: Url::parse(&url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| host.to_string())), + url, + contribution_pct, + }) +} + +fn render_pretty_answer(response: &QuickResponse) -> String { + if !response.message.markdown.trim().is_empty() { + prettify_markdown(&response.message.markdown) + } else { + html_to_text(&response.message.html) + } +} + +fn render_markdown_answer(response: &QuickResponse) -> String { + if !response.message.markdown.trim().is_empty() { + response.message.markdown.trim().to_string() + } else { + html_to_text(&response.message.html) + } +} + +fn prettify_markdown(markdown: &str) -> String { + let stripped_lines = markdown + .lines() + .filter(|line| !line.trim_start().starts_with("[^")) + .map(strip_inline_footnote_refs) + .map(|line| { + cleanup_spacing_before_punctuation( + &line.replace("**", "").replace("__", "").replace('`', ""), + ) + }) + .collect::>(); + + collapse_blank_lines(&stripped_lines.join("\n")) +} + +fn strip_inline_footnote_refs(line: &str) -> String { + let mut output = String::with_capacity(line.len()); + let mut index = 0; + + while index < line.len() { + let remainder = &line[index..]; + if let Some(rest) = remainder.strip_prefix("[^") + && let Some(end_index) = rest.find(']') + { + let footnote_id = &rest[..end_index]; + if !footnote_id.is_empty() && footnote_id.chars().all(|ch| ch.is_ascii_digit()) { + index += 2 + end_index + 1; + continue; + } + } + + let ch = remainder + .chars() + .next() + .expect("line slice should contain at least one char"); + output.push(ch); + index += ch.len_utf8(); + } + + output.trim_end().to_string() +} + +fn cleanup_spacing_before_punctuation(text: &str) -> String { + text.replace(" .", ".") + .replace(" ,", ",") + .replace(" ;", ";") + .replace(" :", ":") + .replace(" !", "!") + .replace(" ?", "?") +} + +fn collapse_blank_lines(text: &str) -> String { + let mut lines = Vec::new(); + let mut previous_blank = false; + + for line in text.lines().map(str::trim_end) { + if line.trim().is_empty() { + if !previous_blank { + lines.push(String::new()); + } + previous_blank = true; + } else { + lines.push(line.to_string()); + previous_blank = false; + } + } + + lines.join("\n").trim().to_string() +} + +fn html_to_text(html: &str) -> String { + let normalized = html + .replace("
", "\n") + .replace("
", "\n") + .replace("
", "\n") + .replace("

", "\n\n") + .replace("", "\n") + .replace("
  • ", "- ") + .replace("", "\n") + .replace("", "\n") + .replace("", "\n\n") + .replace("", "\n\n") + .replace("", "\n\n") + .replace("", "\n\n"); + let fragment = Html::parse_fragment(&normalized); + let text = fragment.root_element().text().collect::>().join(""); + + collapse_blank_lines(&text) +} + +fn looks_like_html_document(body: &str) -> bool { + body.contains(" String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return String::new(); + } + + if let Ok(payload) = serde_json::from_str::(trimmed) { + return format!("; {}", payload); + } + + let detail = html_to_text(trimmed); + if detail.is_empty() { + String::new() + } else { + format!("; {detail}") + } +} + +fn build_client() -> Result { + Client::builder() + .user_agent(USER_AGENT) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| KagiError::Network(format!("failed to build HTTP client: {error}"))) +} + +fn map_transport_error(error: reqwest::Error) -> KagiError { + if error.is_timeout() { + return KagiError::Network("request to Kagi timed out".to_string()); + } + + if error.is_connect() { + return KagiError::Network(format!("failed to connect to Kagi: {error}")); + } + + KagiError::Network(format!("request to Kagi failed: {error}")) +} + +#[derive(Debug, Deserialize)] +struct QuickHello { + v: Option, + trace: Option, +} + +#[derive(Debug, Deserialize)] +struct QuickTokensFrame { + #[serde(default)] + text: String, +} + +#[derive(Debug, Deserialize)] +struct QuickMessagePayload { + id: String, + thread_id: String, + created_at: String, + state: String, + prompt: String, + #[serde(default)] + reply: String, + #[serde(default)] + md: String, + #[serde(default)] + references_md: String, + #[serde(default)] + followup_questions: Vec, +} + +#[cfg(test)] +mod tests { + use super::{ + format_quick_markdown, format_quick_pretty, parse_quick_answer_stream, + parse_quick_reference_markdown, strip_inline_footnote_refs, + }; + use crate::auth::load_credential_inventory; + use crate::error::KagiError; + use crate::search::SearchRequest; + use crate::types::{QuickMessage, QuickMeta, QuickReferenceCollection, QuickResponse}; + + #[test] + fn parses_quick_reference_markdown_items() { + let references = parse_quick_reference_markdown( + "[^1]: [Intro to Rust](https://www.rust-lang.org/learn) (26%)\n\ + [^2]: [Rust (programming language) - Wikipedia](https://en.wikipedia.org/wiki/Rust_(programming_language)) (12%)", + ); + + assert_eq!(references.len(), 2); + assert_eq!(references[0].index, 1); + assert_eq!(references[0].title, "Intro to Rust"); + assert_eq!(references[0].contribution_pct, Some(26)); + assert_eq!(references[1].index, 2); + assert_eq!( + references[1].url, + "https://en.wikipedia.org/wiki/Rust_(programming_language)" + ); + } + + #[test] + fn parses_quick_answer_stream() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-123\"}\0\n", + "tokens.json:{\"text\":\"

    Partial answer

    \"}\0\n", + "new_message.json:{", + "\"id\":\"msg-1\",", + "\"thread_id\":\"thread-1\",", + "\"created_at\":\"2026-03-19T00:00:00Z\",", + "\"state\":\"done\",", + "\"prompt\":\"what is rust?\",", + "\"reply\":\"

    Rust is a systems programming language.

    \",", + "\"md\":\"Rust is a systems programming language.\",", + "\"references_md\":\"[^1]: [Rust](https://www.rust-lang.org/) (26%)\",", + "\"followup_questions\":[\"Why is Rust memory-safe?\"]", + "}\0\n" + ); + + let parsed = parse_quick_answer_stream(raw, "what is rust?", Some("0")) + .expect("quick stream parses"); + + assert_eq!(parsed.meta.trace.as_deref(), Some("trace-123")); + assert_eq!(parsed.lens.as_deref(), Some("0")); + assert_eq!(parsed.message.id, "msg-1"); + assert_eq!(parsed.references.items.len(), 1); + assert_eq!( + parsed.followup_questions, + vec!["Why is Rust memory-safe?".to_string()] + ); + } + + #[test] + fn rejects_quick_stream_without_final_message() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-123\"}\0\n", + "tokens.json:{\"text\":\"

    Partial answer

    \"}\0\n" + ); + + let error = parse_quick_answer_stream(raw, "what is rust?", None) + .expect_err("stream without final message should fail"); + assert!(matches!(error, KagiError::Parse(_))); + } + + #[test] + fn format_quick_markdown_appends_followups() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-123\"}\0\n", + "new_message.json:{", + "\"id\":\"msg-1\",", + "\"thread_id\":\"thread-1\",", + "\"created_at\":\"2026-03-19T00:00:00Z\",", + "\"state\":\"done\",", + "\"prompt\":\"what is rust?\",", + "\"reply\":\"

    Rust

    \",", + "\"md\":\"Rust answer\",", + "\"references_md\":\"[^1]: [Rust](https://www.rust-lang.org/) (26%)\",", + "\"followup_questions\":[\"Why is Rust memory-safe?\"]", + "}\0\n" + ); + let parsed = + parse_quick_answer_stream(raw, "what is rust?", None).expect("quick stream parses"); + let markdown = format_quick_markdown(&parsed); + assert!(markdown.contains("Rust answer")); + assert!(markdown.contains("[^1]: [Rust]")); + assert!(markdown.contains("## Follow-up Questions")); + } + + #[test] + fn format_quick_pretty_renders_sections() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-123\"}\0\n", + "new_message.json:{", + "\"id\":\"msg-1\",", + "\"thread_id\":\"thread-1\",", + "\"created_at\":\"2026-03-19T00:00:00Z\",", + "\"state\":\"done\",", + "\"prompt\":\"what is rust?\",", + "\"reply\":\"

    Rust is fast.

    \",", + "\"md\":\"Rust is fast.\",", + "\"references_md\":\"[^1]: [Rust](https://www.rust-lang.org/) (26%)\",", + "\"followup_questions\":[\"Why is Rust memory-safe?\"]", + "}\0\n" + ); + let parsed = + parse_quick_answer_stream(raw, "what is rust?", None).expect("quick stream parses"); + let pretty = format_quick_pretty(&parsed, false); + assert!(pretty.contains("Quick Answer")); + assert!(pretty.contains("Rust is fast.")); + assert!(pretty.contains("References")); + assert!(pretty.contains("Follow-up Questions")); + } + + #[test] + fn format_quick_pretty_prefers_markdown_and_strips_footnotes() { + let response = QuickResponse { + meta: QuickMeta::default(), + query: "what is rust".to_string(), + lens: None, + message: QuickMessage { + id: "msg-1".to_string(), + thread_id: "thread-1".to_string(), + created_at: "2026-03-19T00:00:00Z".to_string(), + state: "done".to_string(), + prompt: "what is rust".to_string(), + html: "

    Rust source title noise

    ".to_string(), + markdown: "Rust is **fast**[^1].\n\n- `safe`\n".to_string(), + }, + references: QuickReferenceCollection { + markdown: "[^1]: [Rust](https://www.rust-lang.org/) (26%)".to_string(), + items: Vec::new(), + }, + followup_questions: Vec::new(), + }; + + let pretty = format_quick_pretty(&response, false); + + assert!(pretty.contains("Rust is fast.")); + assert!(pretty.contains("- safe")); + assert!(!pretty.contains("[^1]")); + assert!(!pretty.contains("source title noise")); + } + + #[test] + fn strip_inline_footnote_refs_removes_numeric_markers() { + assert_eq!( + strip_inline_footnote_refs("Rust[^1] is safe[^23]."), + "Rust is safe." + ); + assert_eq!( + strip_inline_footnote_refs("Leave [^alpha] alone."), + "Leave [^alpha] alone." + ); + } + + #[test] + fn prettify_markdown_cleans_spacing_after_footnote_removal() { + let response = QuickResponse { + meta: QuickMeta::default(), + query: "what is rust".to_string(), + lens: None, + message: QuickMessage { + id: "msg-1".to_string(), + thread_id: "thread-1".to_string(), + created_at: "2026-03-19T00:00:00Z".to_string(), + state: "done".to_string(), + prompt: "what is rust".to_string(), + html: String::new(), + markdown: "Rust is reliable [^1].".to_string(), + }, + references: QuickReferenceCollection { + markdown: "[^1]: [Rust](https://www.rust-lang.org/) (26%)".to_string(), + items: Vec::new(), + }, + followup_questions: Vec::new(), + }; + + let pretty = format_quick_pretty(&response, false); + assert!(pretty.contains("Rust is reliable.")); + assert!(!pretty.contains("reliable .")); + } + + #[test] + fn rejects_quick_limit_notice_stream() { + let raw = "limit_notice.html:

    Daily limit reached

    \0\n"; + let error = parse_quick_answer_stream(raw, "what is rust?", None) + .expect_err("limit notice should fail"); + assert!(matches!(error, KagiError::Config(_))); + } + + #[test] + fn rejects_quick_unauthorized_stream() { + let raw = "unauthorized:\0\n"; + let error = parse_quick_answer_stream(raw, "what is rust?", None) + .expect_err("unauthorized stream should fail"); + assert!(matches!(error, KagiError::Auth(_))); + } + + fn live_session_token() -> Option { + load_credential_inventory() + .ok()? + .session_token + .map(|credential| credential.value) + } + + #[tokio::test] + #[ignore = "requires live Kagi session token"] + async fn live_quick_query_without_question_mark() { + let token = live_session_token().expect("missing session token for live quick test"); + let response = super::execute_quick(&SearchRequest::new("what is rust"), &token) + .await + .expect("quick answer should succeed"); + + assert_eq!(response.query, "what is rust"); + assert_eq!(response.message.state, "done"); + assert!(!response.message.markdown.trim().is_empty()); + assert!(!response.references.items.is_empty()); + assert!(!response.followup_questions.is_empty()); + } + + #[tokio::test] + #[ignore = "requires live Kagi session token"] + async fn live_quick_query_with_question_mark() { + let token = live_session_token().expect("missing session token for live quick test"); + let response = super::execute_quick(&SearchRequest::new("what is rust?"), &token) + .await + .expect("quick answer should succeed"); + + assert_eq!(response.message.state, "done"); + assert!(!response.message.markdown.trim().is_empty()); + } + + #[tokio::test] + #[ignore = "requires live Kagi session token"] + async fn live_quick_query_with_lens() { + let token = live_session_token().expect("missing session token for live quick test"); + let request = SearchRequest::new("best rust tutorials").with_lens("0".to_string()); + let response = super::execute_quick(&request, &token) + .await + .expect("quick answer should succeed with lens"); + + assert_eq!(response.lens.as_deref(), Some("0")); + assert_eq!(response.message.state, "done"); + assert!(!response.message.markdown.trim().is_empty()); + } + + #[tokio::test] + #[ignore = "requires network access"] + async fn live_quick_invalid_token_is_rejected() { + let error = super::execute_quick(&SearchRequest::new("what is rust"), "bogus.invalid") + .await + .expect_err("invalid token should fail"); + + assert!(matches!(error, KagiError::Auth(_) | KagiError::Config(_))); + } +} diff --git a/src/search.rs b/src/search.rs index a296d3f..899c4bd 100644 --- a/src/search.rs +++ b/src/search.rs @@ -45,6 +45,19 @@ impl SearchRequest { } } +pub fn validate_lens_value(lens: &str) -> Result<(), KagiError> { + if lens.parse::().is_err() { + return Err(KagiError::Config(format!( + "lens '{}' must be a numeric index (e.g., '0', '1', '2'). \ + Visit https://kagi.com/settings/lenses to see your enabled lenses, \ + then use the index from the 'l=' parameter in your browser URL.", + lens + ))); + } + + Ok(()) +} + /// Perform a search request against Kagi's HTML endpoint. /// /// If a lens is specified in the request, it will be passed as the `l` query parameter. @@ -61,14 +74,7 @@ pub async fn search_with_lens(request: &SearchRequest, token: &str) -> Result().is_err() { - return Err(KagiError::Config(format!( - "lens '{}' must be a numeric index (e.g., '0', '1', '2'). \ - Visit https://kagi.com/settings/lenses to see your enabled lenses, \ - then use the index from the 'l=' parameter in your browser URL.", - lens - ))); - } + validate_lens_value(lens)?; lens_value = lens.clone(); query_params.push(("l", lens_value.as_str())); } @@ -161,14 +167,6 @@ pub async fn execute_api_search( } } -/// Legacy search function for backward compatibility. -/// Consider using `search_with_lens` for lens support. -#[allow(dead_code)] -pub async fn search(query: &str, token: &str) -> Result { - let request = SearchRequest::new(query); - search_with_lens(&request, token).await -} - pub async fn execute_search( request: &SearchRequest, token: &str, @@ -263,6 +261,12 @@ mod tests { assert_eq!(request.lens, Some("2".to_string())); } + #[test] + fn validate_lens_value_rejects_non_numeric_indices() { + let error = validate_lens_value("forums").expect_err("non-numeric lens should fail"); + assert!(matches!(error, KagiError::Config(_))); + } + #[tokio::test] async fn execute_search_rejects_non_numeric_lens() { let request = SearchRequest::new("rust lang").with_lens("forums"); diff --git a/src/types.rs b/src/types.rs index c9b39e0..2bbf5da 100644 --- a/src/types.rs +++ b/src/types.rs @@ -320,3 +320,53 @@ pub struct EnrichResponse { pub struct SmallWebFeed { pub xml: String, } + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuickMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuickMessage { + pub id: String, + pub thread_id: String, + pub created_at: String, + pub state: String, + pub prompt: String, + pub html: String, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuickReferenceItem { + pub index: usize, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contribution_pct: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuickReferenceCollection { + #[serde(default)] + pub markdown: String, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuickResponse { + pub meta: QuickMeta, + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lens: Option, + pub message: QuickMessage, + pub references: QuickReferenceCollection, + #[serde(default)] + pub followup_questions: Vec, +}