diff --git a/README.md b/README.md index 82cc944..c16e2f0 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, ask-page, assistant, translate, 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, ask-page, assistant, translate, 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, translate, summarization, and feeds +- use one CLI for search, quick answers, assistant, translate, 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 fallback, `search --lens`, filtered search, `ask-page`, `assistant`, `translate`, `summarize --subscriber` | +| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `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, continue threads, and manage thread list/export/delete with a subscriber session token | | `kagi ask-page` | ask Kagi Assistant about a specific web page | | `kagi translate` | translate text through Kagi Translate with a subscriber session token | @@ -221,6 +222,12 @@ kagi assistant thread list kagi assistant thread export ``` +get a quick answer with references: + +```bash +kagi quick --format pretty "what is rust" +``` + translate text and keep all text-mode extras: ```bash @@ -268,7 +275,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 translate, ask-page, the subscriber summarizer, assistant, and the 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, translate, ask-page, the subscriber summarizer, assistant, and the public news feed: + +![quick demo](images/demos/quick.gif) ![translate demo](images/demos/translate.gif) diff --git a/docs/api-coverage.md b/docs/api-coverage.md index dee184e..0c6b738 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 ...` - **Subscriber web Assistant thread list/open/delete/export flows** - implemented on the authenticated Assistant thread endpoints via `kagi assistant thread ...` - **Kagi Translate text mode** - implemented via `kagi translate ...` with runtime bootstrap from `KAGI_SESSION_TOKEN` @@ -26,6 +27,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 - subscriber web Assistant thread management via Kagi session-token auth - Kagi News product endpoints @@ -41,4 +43,5 @@ This CLI also implements non-public or product-only seams: - Live verification on March 18, 2026 showed that direct HTTP bootstrap can mint `translate_session` from the same `KAGI_SESSION_TOKEN` by reading the `Set-Cookie` header from `https://translate.kagi.com/`. - After bootstrap, the CLI uses normal Rust HTTP requests for `/api/detect`, `/api/translate`, `/api/alternative-translations`, `/api/text-alignments`, `/api/translation-suggestions`, and `/api/word-insights`. - Assistant requires `KAGI_SESSION_TOKEN` and currently targets `/assistant/prompt`, `/assistant/thread_list`, `/assistant/thread_open`, `/assistant/thread_delete`, and `/assistant//download`. +- 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 0000000..ea639db Binary files /dev/null and b/docs/demo-assets/quick.gif differ diff --git a/docs/demos.md b/docs/demos.md index bb574ed..bd39875 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/ask-page.gif` @@ -20,16 +21,16 @@ Subscriber demos require `KAGI_SESSION_TOKEN` in the environment. API-token demo The current demo commands are: - `kagi search --format pretty --region us --time year --order recency "rust release notes"` +- `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 ask-page https://rust-lang.org/ "What is this page about in one sentence?" | jq -M ...` - `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` - `kagi translate "Bonjour tout le monde" --to ja | jq -M ...` - `RESPONSE=$(kagi assistant --model gpt-5-mini "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` - - `kagi translate "Bonjour tout le monde" --to ja | jq -M ...` ```bash -chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-ask-page.sh scripts/demo-assistant.sh scripts/demo-translate.sh +chmod +x scripts/demo-search.sh scripts/demo-quick.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-ask-page.sh scripts/demo-assistant.sh scripts/demo-translate.sh mkdir -p docs/demo-assets /tmp/kagi-demos @@ -37,6 +38,7 @@ 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-ask-page.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/ask-page.cast @@ -44,6 +46,7 @@ KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-assistant.sh -q -i 0.2 KAGI_SESSION_TOKEN='...' asciinema rec -c ./scripts/demo-translate.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/translate.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/ask-page.cast docs/demo-assets/ask-page.gif diff --git a/docs/docs.json b/docs/docs.json index 547e0eb..6374c12 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,8 +84,9 @@ { "group": "AI & Enrichment", "pages": [ - "commands/ask-page", "commands/assistant", + "commands/quick", + "commands/ask-page", "commands/translate", "commands/fastgpt", "commands/enrich" diff --git a/docs/guides/quickstart.mdx b/docs/guides/quickstart.mdx index bdc5c1b..8f78dc1 100644 --- a/docs/guides/quickstart.mdx +++ b/docs/guides/quickstart.mdx @@ -174,6 +174,9 @@ kagi search --region us --time month --order recency "rust release notes" ![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?" @@ -190,6 +193,10 @@ kagi assistant thread list 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) @@ -440,6 +447,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 25b274f..cca892e 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -48,7 +48,7 @@ The CLI is built to work cleanly in scripted and tool-driven environments: 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, continue conversations, and manage threads across sessions. - **Ask Page**: Start a page-focused Assistant thread directly from one URL and one question. @@ -109,11 +109,12 @@ flowchart TB subgraph SubscriberWeb["Subscriber Web Product"] web1["search --lens"] - web2["assistant"] - web3["summarize --subscriber"] - web4["translate"] - web5["search (session)"] - web6["ask-page"] + web2["quick"] + web3["assistant"] + web4["summarize --subscriber"] + web5["translate"] + web6["search (session)"] + web7["ask-page"] end subgraph PublicFeeds["Public Feeds (No Auth)"] @@ -193,6 +194,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 27c253e..d4bc5d6 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. - [ask-page](https://kagi.micr.dev/commands/ask-page): Complete reference for *kagi* ask-page command - ask Kagi Assistant about a specific web page. - [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. diff --git a/docs/project/demos.mdx b/docs/project/demos.mdx index 41c8c70..41a6235 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 - ask-page @@ -20,6 +21,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) @@ -49,6 +54,7 @@ The demo scripts build the local debug binary, expose it as `kagi` through `/tmp The current demo commands are: - `kagi search --format pretty --region us --time year --order recency "rust release notes"` +- `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 ask-page https://rust-lang.org/ "What is this page about in one sentence?" | jq -M ...` @@ -57,12 +63,13 @@ The current demo commands are: - `kagi translate "Bonjour tout le monde" --to ja | jq -M ...` ```bash -chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-ask-page.sh scripts/demo-assistant.sh scripts/demo-translate.sh +chmod +x scripts/demo-search.sh scripts/demo-quick.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-ask-page.sh scripts/demo-assistant.sh scripts/demo-translate.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-ask-page.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/ask-page.cast @@ -76,6 +83,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/ask-page.cast docs/demo-assets/ask-page.gif diff --git a/docs/reference/auth-matrix.mdx b/docs/reference/auth-matrix.mdx index 3888d62..4422408 100644 --- a/docs/reference/auth-matrix.mdx +++ b/docs/reference/auth-matrix.mdx @@ -20,6 +20,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 | | `ask-page` | `KAGI_SESSION_TOKEN` | None | Subscriber feature | | `assistant` | `KAGI_SESSION_TOKEN` | None | Subscriber feature | | `translate` | `KAGI_SESSION_TOKEN` | None | Text mode only; bootstraps `translate_session` over HTTP | @@ -127,6 +128,7 @@ flowchart TD | Command | Token | Purpose | |---------|-------|---------| +| `quick` | `KAGI_SESSION_TOKEN` | Quick answers with references | | `ask-page` | `KAGI_SESSION_TOKEN` | Ask Assistant about one page URL | | `assistant` | `KAGI_SESSION_TOKEN` | Conversational AI with threads | | `translate` | `KAGI_SESSION_TOKEN` | Kagi Translate text mode | @@ -157,6 +159,7 @@ Both `enrich web` and `enrich news` require `KAGI_API_TOKEN`: Requires `KAGI_SESSION_TOKEN`: - ✅ Lens-aware search (`--lens`) +- ✅ Quick Answer (`quick`) - ✅ Filtered search (`--region`, `--time`, `--from-date`, `--to-date`, `--order`, `--verbatim`, personalization flags) - ✅ Kagi Assistant prompt and thread commands (`assistant`) - ✅ Ask Page (`ask-page`) @@ -236,9 +239,10 @@ 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 search --region us --time month "query"` - ✅ `kagi ask-page https://example.com "question"` -- ✅ `kagi ask-page https://example.com "question"` +- ✅ `kagi quick "what is rust"` - ✅ `kagi assistant "prompt"` - ✅ `kagi translate "Bonjour tout le monde"` - ✅ `kagi summarize --subscriber --url ...` @@ -267,9 +271,10 @@ kagi auth set --api-token 'your_api_token' **Non-working:** - ❌ `kagi search --lens 2` - requires session token +- ❌ `kagi quick` - requires session token - ❌ `kagi search --region us "query"` - requires session token - ❌ `kagi ask-page https://example.com "question"` - requires session token -- ❌ `kagi ask-page https://example.com "question"` - requires session token +- ❌ `kagi quick` - requires session token - ❌ `kagi assistant` - requires session token - ❌ `kagi summarize --subscriber` - requires session token @@ -287,7 +292,9 @@ 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 - `ask-page`: Uses session +- `quick`: Uses session - `assistant`: Uses session - `fastgpt`: Uses API diff --git a/docs/reference/coverage.mdx b/docs/reference/coverage.mdx index 58accb0..e248dc1 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 | | Filtered Search | `kagi search --region/--time/...` | ✅ Implemented | | Web Summarizer | `kagi summarize --subscriber` | ✅ Implemented | | Assistant prompt + thread management | `kagi assistant` | ✅ Implemented | @@ -59,6 +60,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 with thread management | Session | ✅ | | `ask-page` | Page-focused Assistant question | Session | ✅ | | `translate` | Kagi Translate text mode | Session | ✅ | @@ -71,8 +73,8 @@ These require no authentication: | Option | Commands | Status | |--------|----------|--------| -| `--format pretty` | search | ✅ | -| `--lens` | search | ✅ | +| `--format pretty` | search, batch, quick | ✅ | +| `--lens` | search, batch, quick | ✅ | | `--region` | search, batch | ✅ | | `--time` | search, batch | ✅ | | `--from-date` | search, batch | ✅ | @@ -107,6 +109,7 @@ These require no authentication: | `--web-access` / `--no-web-access` | assistant | ✅ | | `--personalized` / `--no-personalized` | assistant | ✅ | | `thread list/get/delete/export` | assistant | ✅ | +| `--no-color` | search, batch, quick | ✅ | ## Not Available @@ -138,7 +141,7 @@ There is no public CLI support today for document, website, image, proofread, di | Type | Support | Commands | |------|---------|----------| -| Session Token | ✅ | search, filtered search, ask-page, summarize --subscriber, assistant, translate | +| Session Token | ✅ | search, filtered search, quick, ask-page, summarize --subscriber, assistant, translate | | API Token | ✅ | search, summarize, fastgpt, enrich | ### Authentication Patterns @@ -160,6 +163,8 @@ All commands output JSON: | Command | Schema Stability | |---------|-----------------| | search | Stable | +| batch | Stable | +| quick | Stable | | news | Stable | | smallweb | Stable | | summarize | Stable | @@ -174,6 +179,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 | @@ -211,6 +218,8 @@ All commands output JSON: |---------|-----|-----| | Search | ✅ | ✅ | | Lens Search | ✅ | ✅ | +| Quick Answer | ✅ | ✅ | +| Quick Answer | ✅ | ✅ | | Assistant | ✅ (prompt + threads) | ✅ (full) | | Ask Questions about Page | ✅ | ✅ | | Summarizer | ✅ | ✅ | diff --git a/docs/reference/error-reference.mdx b/docs/reference/error-reference.mdx index 3af2b8f..53f55b7 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:** @@ -233,6 +254,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 c0c5c6e..2a59c59 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 translate` ```json @@ -259,6 +296,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: @@ -281,6 +333,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' + # Translate text kagi translate "Bonjour tout le monde" | jq -r '.translation.translation' diff --git a/images/demos/quick.gif b/images/demos/quick.gif new file mode 100644 index 0000000..ea639db Binary files /dev/null and b/images/demos/quick.gif differ diff --git a/project/demos.mdx b/project/demos.mdx index 1e75710..ab98dec 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 - ask-page @@ -20,6 +21,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) @@ -49,6 +54,7 @@ The demo scripts build the local debug binary, expose it as `kagi` through `/tmp The current demo commands are: - `kagi search --format pretty --region us --time year --order recency "rust release notes"` +- `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 ask-page https://rust-lang.org/ "What is this page about in one sentence?" | jq -M ...` @@ -57,12 +63,13 @@ The current demo commands are: - `kagi translate "Bonjour tout le monde" --to ja | jq -M ...` ```bash -chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-ask-page.sh scripts/demo-assistant.sh scripts/demo-translate.sh +chmod +x scripts/demo-search.sh scripts/demo-quick.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-ask-page.sh scripts/demo-assistant.sh scripts/demo-translate.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-ask-page.sh -q -i 0.2 --cols 92 --rows 22 /tmp/kagi-demos/ask-page.cast @@ -76,6 +83,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/ask-page.cast docs/demo-assets/ask-page.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/cli.rs b/src/cli.rs index 026561e..daf12ce 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,6 +42,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, Clone, ValueEnum)] pub enum SearchOrder { Default, @@ -104,6 +127,8 @@ pub enum Commands { News(NewsArgs), /// Prompt Kagi Assistant and manage Assistant threads Assistant(AssistantArgs), + /// Generate a Kagi Quick Answer from live search results + Quick(QuickArgs), /// Ask Kagi Assistant about a specific web page AskPage(AskPageArgs), /// Translate text through Kagi Translate using session-token auth @@ -542,6 +567,25 @@ pub struct TranslateArgs { pub no_alignments: bool, } +#[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 7c3fd4a..2f78cbf 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; @@ -26,8 +27,9 @@ use crate::cli::{ TranslateArgs, }; use crate::error::KagiError; +use crate::quick::{execute_quick, format_quick_markdown, format_quick_pretty}; use crate::types::{ - AskPageRequest, AssistantPromptRequest, FastGptRequest, SearchResponse, + AskPageRequest, AssistantPromptRequest, FastGptRequest, QuickResponse, SearchResponse, SubscriberSummarizeRequest, SummarizeRequest, TranslateCommandRequest, }; use serde_json::Value; @@ -224,6 +226,23 @@ async fn run() -> Result<(), KagiError> { let response = execute_ask_page(&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::Translate(args) => { let token = resolve_session_token()?; let request = build_translate_request(*args)?; @@ -498,6 +517,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/types.rs b/src/types.rs index 889ec64..8751df2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -413,6 +413,56 @@ 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, +} + #[derive(Debug, Clone, PartialEq)] pub struct TranslateCommandRequest { pub text: String,