diff --git a/README.md b/README.md index e47ca10..8b9bcd6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ export KAGI_API_TOKEN='...' | credential | what it unlocks | | --- | --- | -| `KAGI_SESSION_TOKEN` | base search, `search --lens`, `ask-page`, `assistant`, `summarize --subscriber` | +| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, filtered search, `ask-page`, `assistant`, `summarize --subscriber` | | `KAGI_API_TOKEN` | public `summarize`, `fastgpt`, `enrich web`, `enrich news` | | none | `news`, `smallweb`, `auth status`, `--help` | @@ -124,7 +124,7 @@ notes: - environment variables override `.kagi.toml` - base `kagi search` defaults to the session-token path when both credentials are present - set `[auth] preferred_auth = "api"` if you want base search to prefer the API path instead -- `search --lens` always requires `KAGI_SESSION_TOKEN` +- `search --lens` and all runtime search filters require `KAGI_SESSION_TOKEN` - `auth check` validates the selected primary credential without using search fallback logic for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr.dev/reference/auth-matrix) docs page. @@ -133,8 +133,8 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | command | purpose | | --- | --- | -| `kagi search` | search Kagi with JSON by default or `--format pretty` for terminal output | -| `kagi batch` | run multiple searches in parallel with JSON, compact, pretty, markdown, or csv output | +| `kagi search` | search Kagi with JSON by default, optional live filters, or `--format pretty` for terminal output | +| `kagi batch` | run multiple searches in parallel with JSON, compact, pretty, markdown, or csv output and shared filters | | `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 | @@ -183,6 +183,12 @@ scope search to one of your lenses: kagi search --lens 2 "developer documentation" ``` +run a filtered search against the subscriber web-product path: + +```bash +kagi search --region us --time month --order recency "rust release notes" +``` + run a few searches in parallel: ```bash diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 8aba6b1..510c60e 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -3,7 +3,7 @@ ## Current support in this CLI ### Implemented -- **Search / session-token HTML search** - fully implemented and live-verified for base search and lens-aware search +- **Search / session-token HTML search** - fully implemented and live-verified for base search, lens-aware search, and runtime search filters (`r`, `dr`, `from_date`, `to_date`, `order`, `verbatim`, `personalized`) - **Search / official API-token path** - implemented for base search only; if Kagi rejects the API-token search path, base search falls back to session-token search when available - **Universal Summarizer API** - implemented on the documented paid public API path - **FastGPT API** - implemented on the documented paid public API path @@ -35,7 +35,7 @@ This CLI also implements non-public or product-only seams: ## Notes -- Lens support is not documented on the official Search API. In this CLI it works through Kagi's live HTML/session flow using the `l=` query parameter. +- Lens support and runtime search filters are not documented on the official Search API. In this CLI they work through Kagi's live HTML/session flow using the `l`, `r`, `dr`, `from_date`, `to_date`, `order`, `verbatim`, and `personalized` query parameters. - The official Search API uses `Authorization: Bot ` on `https://kagi.com/api/v0/search`. - Search API access is still account-gated in practice, and API-token search can also fail for billing reasons. - Base-search fallback to session-token search happens on the user-facing `search` command only. `auth check` validates the selected primary credential without fallback. diff --git a/docs/commands/search.mdx b/docs/commands/search.mdx index 10bc698..0b69950 100644 --- a/docs/commands/search.mdx +++ b/docs/commands/search.mdx @@ -1,11 +1,11 @@ --- title: "search" -description: "Complete reference for *kagi* search command - syntax, flags, authentication, output formats, and real-world examples." +description: "Complete reference for *kagi* search command - syntax, filters, authentication, output formats, and examples." --- # `kagi search` -Search Kagi and return structured results in JSON or human-readable format. This is the primary command for retrieving search results from your terminal. +Search Kagi and return structured results in JSON or human-readable format. ![Search demo](/images/demos/search.gif) @@ -17,32 +17,43 @@ kagi search [OPTIONS] ## Description -The `kagi search` command performs a search query against Kagi's search index and returns results in a structured format. By default, output is JSON suitable for programmatic processing. With the `--format pretty` flag, results are formatted for human readability. +`kagi search` is the main search command. By default it emits JSON for scripts and agents. Use `--format pretty` for terminal reading. -This command has sophisticated authentication behavior: it prefers the API token path when available but can automatically fall back to the session token path if the API path is rejected. This ensures maximum utility regardless of which token type you have configured. +There are now two search modes: -## Authentication +- **Base search** uses your configured base-search auth preference. By default this repo prefers the subscriber session path. If you set `[auth.preferred_auth] = "api"`, base search uses the Search API first and can fall back to the subscriber web-product path on API auth rejection. +- **Filtered search** uses Kagi's authenticated HTML/session search path and requires `KAGI_SESSION_TOKEN`. + +This matters because the Kagi Search API is still base-search only in this CLI. As soon as you add live search filters like `--region`, `--time`, `--order`, `--verbatim`, or `--personalized`, the request becomes session-only. -### Base Search (No Lens) +## Authentication -**Preferred:** `KAGI_API_TOKEN` -**Fallback:** `KAGI_SESSION_TOKEN` (if API path is rejected) +### Base Search -The base search command is unique in that it attempts both authentication paths: +**Default:** `KAGI_SESSION_TOKEN` +**Optional API-first mode:** set `[auth.preferred_auth] = "api"` -1. **First attempt:** Uses `KAGI_API_TOKEN` if available, querying the Search API -2. **On API rejection:** Falls back to `KAGI_SESSION_TOKEN`, querying via the web product -3. **On success:** Returns results from whichever path succeeded +Plain base search keeps the existing repo behavior: -**Why this matters:** You can use base search with either token type, and the CLI intelligently selects the best available option. +- default session-first search when a session token is available +- API-first only when you explicitly configure `[auth.preferred_auth] = "api"` +- session fallback only when the API path was selected first and rejected -### Lens Search (`--lens`) +### Filtered Search **Required:** `KAGI_SESSION_TOKEN` -Lens-aware search requires a subscriber session token. This feature is not available via the Search API and requires web-product authentication. +Any of these flags make the request session-only: -**What is a lens?** Kagi lenses are custom search scopes you create in your Kagi account. They allow you to search within specific domains, topics, or content types you've defined. +- `--lens` +- `--region` +- `--time` +- `--from-date` +- `--to-date` +- `--order` +- `--verbatim` +- `--personalized` +- `--no-personalized` ## Options @@ -50,311 +61,211 @@ Lens-aware search requires a subscriber session token. This feature is not avail The search query string. -**Constraints:** -- Minimum length: 1 character -- Maximum length: 2000 characters -- Supports all standard search operators - -**Examples:** ```bash kagi search "rust programming language" kagi search "site:github.com rust cli" -kagi search '"exact phrase match"' ``` -### `--format pretty` +### `--format ` -Render output in human-readable format instead of JSON. +Output format. -**Default:** `false` (JSON output) -**When to use:** Interactive terminal sessions, quick lookups, reading results +Possible values: -**JSON Output (default):** -```json -{ - "data": [ - { - "t": 0, - "url": "https://www.rust-lang.org", - "title": "Rust Programming Language", - "snippet": "A language empowering everyone to build reliable and efficient software.", - "rank": 1 - } - ] -} -``` - -**Pretty Output (`--format pretty`):** -``` -1. Rust Programming Language - https://www.rust-lang.org +- `json` - pretty JSON +- `compact` - minified JSON +- `pretty` - terminal-friendly output +- `markdown` - markdown list output +- `csv` - CSV table output - A language empowering everyone to build reliable and efficient software. +### `--no-color` -2. The Rust Programming Language - Documentation - https://doc.rust-lang.org/book/ - - The Rust Programming Language book. -``` - -**Note:** `--format pretty` only changes output formatting. It does not affect: -- Which authentication is used -- Transport method (API vs web) -- Command semantics -- Error behavior +Disable ANSI colors in `--format pretty`. ### `--lens ` -Search using a specific Kagi lens. - -**Requires:** `KAGI_SESSION_TOKEN` -**Type:** String value representing your numeric lens index -**Example:** `--lens 2` - -**Finding your lens index:** -1. Log into Kagi at [kagi.com](https://kagi.com) -2. Navigate to your lenses in settings -3. The lens index is the number shown for each lens +Scope search to one of your enabled Kagi lenses. -**Example:** ```bash -# Use lens #2 (your "Developer Documentation" lens) -kagi search --lens 2 "error handling" - -# Combine with pretty output -kagi search --lens 2 --format pretty "rust async" +kagi search --lens 2 "developer documentation" ``` -**Important:** Lens indices are user-specific. Your lens #2 is different from another user's lens #2. - -## Output Format +### `--region ` -### JSON Structure +Restrict results to a Kagi region code such as `us`, `gb`, `jp`, or `no_region`. -```json -{ - "data": [ - { - "t": 0, // Result type (0 = organic, see types below) - "rank": 1, // Position in results - "title": "...", // Page title - "url": "...", // Full URL - "snippet": "...", // Description/excerpt - "published": "..." // Optional: publication date - } - ] -} +```bash +kagi search --region us "rust conferences" +kagi search --region no_region "rust ownership" ``` -### Result Types +### `--time ` -| Type | Description | -|------|-------------| -| 0 | Organic search result | -| 1 | News result | -| 2 | Image result | -| 3 | Video result | -| 4 | Map/local result | +Restrict results to a recent update window. -### Pretty Output Format +Possible values: -Results are numbered and formatted with title, URL, and optional snippet: +- `day` +- `week` +- `month` +- `year` +```bash +kagi search --time week "rust release notes" ``` -{rank}. {title} - {url} - {snippet} -``` +### `--from-date ` -## Examples +Restrict results to pages updated on or after the given date. -### Basic Search +### `--to-date ` -```bash -# Simple search with JSON output -kagi search "rust programming language" +Restrict results to pages updated on or before the given date. -# Human-readable output -kagi search --format pretty "rust programming language" +```bash +kagi search --from-date 2026-01-01 --to-date 2026-03-01 "rust compiler" ``` -### Lens Search +**Important:** `--time` cannot be combined with `--from-date` or `--to-date`. -```bash -# Search within your developer documentation lens (lens #2) -kagi search --lens 2 "error handling patterns" +### `--order ` -# Search within your tech news lens (lens #5) -kagi search --lens 5 "artificial intelligence" -``` +Reorder results using Kagi's live HTML search sort options. + +Possible values: -### Advanced Queries +- `default` +- `recency` +- `website` +- `trackers` ```bash -# Site-specific search -kagi search "site:github.com rust cli" +kagi search --order recency "rust changelog" +``` -# Exact phrase -kagi search '"async await rust"' +### `--verbatim` -# Exclude terms -kagi search "rust -game -gaming" +Enable verbatim search for this request. -# Date range -kagi search "rust tutorial after:2023-01-01" +```bash +kagi search --verbatim "\"rust async runtime\"" ``` -### Processing Results - -```bash -# Extract URLs -kagi search "rust" | jq -r '.data[].url' +### `--personalized` -# Extract titles -kagi search "rust" | jq -r '.data[].title' +Force personalized search on for this request. -# Get first result URL -kagi search "rust" | jq -r '.data[0].url' +### `--no-personalized` -# Filter organic results only -kagi search "rust" | jq '.data | map(select(.t == 0))' +Force personalized search off for this request. -# Count results -kagi search "rust" | jq '.data | length' +```bash +kagi search --no-personalized "rust web framework" ``` -### Combining with Other Tools +## Not Supported as Runtime Flags -```bash -# Open first result in browser -kagi search "rust" | jq -r '.data[0].url' | xargs open +`safe_search` is currently an account setting, not a search-time flag in this CLI. Configure it in the Kagi web settings instead of expecting `kagi search` to override it per request. -# Save results to file -kagi search "rust" > rust-search-results.json +## Output Format -# Create markdown links -kagi search "rust" | jq -r '.data[0:5] | .[] | "- [\(.title)](\(.url))"' > results.md +The search response shape is unchanged: -# Generate CSV -kagi search "rust" | jq -r '.data[] | [.title, .url] | @csv' > results.csv +```json +{ + "data": [ + { + "t": 0, + "rank": 1, + "title": "Rust Programming Language", + "url": "https://www.rust-lang.org", + "snippet": "A language empowering everyone to build reliable and efficient software.", + "published": null + } + ] +} ``` -## Exit Codes +## Examples -| Code | Meaning | -|------|---------| -| 0 | Success - search completed, results returned | -| 1 | Error - see stderr for details | - -**Common errors:** -- Missing credentials -- Invalid authentication -- Network failure -- Rate limiting - -## Authentication Flow Details - -### Base Search Flow - -```mermaid -flowchart TD - Start([User runs: kagi search "query"]) --> CheckCreds[CLI checks credentials] - - CheckCreds --> APIToken{API token configured?} - APIToken -->|Yes| TrySearchAPI[Try Search API] - TrySearchAPI -->|Success| ReturnResults1[Return results] - TrySearchAPI -->|403/401 error| SessionCheck{Session token configured?} - SessionCheck -->|Yes| TryWeb1[Try web product] - TryWeb1 -->|Success| ReturnResults1 - SessionCheck -->|No| Error1[Error no fallback] - - APIToken -->|No| TrySession[Try session token] - TrySession -->|Success| ReturnResults2[Return results] - - CheckCreds --> Neither[Neither token configured] - Neither --> Error2[Error: missing credentials] +### Plain base search + +```bash +kagi search "rust programming language" ``` -### Lens Search Flow +### Human-readable output -```mermaid -flowchart TD - Start([User runs: kagi search --lens 2 "query"]) --> CheckSession{CLI checks: Session token configured?} - - CheckSession -->|Yes| QueryWeb[Query web product with lens] - QueryWeb -->|Success| ReturnResults[Return results] - - CheckSession -->|No| Error[Error - lens requires session token] +```bash +kagi search --format pretty "rust programming language" ``` -## Performance Considerations +### Region-aware search -### Response Times +```bash +kagi search --region us --format pretty "rust conferences" +``` -- **Base search:** 1-3 seconds typical -- **Lens search:** 2-5 seconds typical -- **With fallback:** May take 2x time if fallback occurs +### Recent results -### Rate Limiting +```bash +kagi search --time month --order recency "rust release notes" +``` -- Respect rate limits between requests -- Add delays when processing multiple queries: `sleep 1` -- Consider caching results for repeated queries +### Custom date range -### Best Practices +```bash +kagi search --from-date 2026-01-01 --to-date 2026-03-01 "rust async runtime" +``` -1. **Use appropriate token:** If you have both, base search uses API first -2. **Pretty for humans:** Use `--format pretty` for interactive use -3. **JSON for automation:** Default JSON is perfect for piping -4. **Lens for focus:** Use lenses to scope searches to relevant domains -5. **Limit results:** Use `--limit` to reduce payload size +### Verbatim plus personalization control -## Troubleshooting +```bash +kagi search --verbatim --no-personalized "\"rust book ownership\"" +``` -### "No results found" +### Lens plus filters -- Try a broader query -- Check your internet connection -- Verify authentication is working: `kagi auth check` +```bash +kagi search --lens 2 --region us --time year "developer documentation" +``` -### "missing credentials" +## Processing Results -- Set your session token: `kagi auth set --session-token '...'` -- Or set API token: `kagi auth set --api-token '...'` -- Check status: `kagi auth status` +```bash +# Extract URLs +kagi search "rust" | jq -r '.data[].url' -### "auth check failed" +# Get first result +kagi search "rust" | jq -r '.data[0].title' -- Token may be expired - regenerate in Kagi settings -- Check token hasn't been revoked -- Verify subscription/API credit status +# Human-readable markdown +kagi search --format markdown "rust ownership" +``` -### Slow responses +## Exit Codes -- May be experiencing fallback behavior -- Check network connectivity -- Consider rate limiting delays +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Error - see stderr | -## Comparison with Other Commands +## Common Errors -| Feature | `kagi search` | `kagi news` | `kagi smallweb` | -|---------|---------------|-------------|-----------------| -| Authentication | Optional (API or Session) | None | None | -| Output | JSON or pretty | JSON | JSON | -| Source | Search index | News feed | Small Web | -| Requires token | No (public) | No | No | +- `search filters require KAGI_SESSION_TOKEN` - you used runtime filters without a session token +- `search --time cannot be combined with --from-date or --to-date` - choose a preset window or a custom date range +- `search --from-date must use YYYY-MM-DD format` - dates must be zero-padded ISO dates +- `lens 'foo' must be a numeric index` - lens values are numeric Kagi lens indices ## Related Commands -- `kagi news` - Search news articles specifically -- `kagi enrich web` - Web enrichment with different API -- `kagi auth` - Manage authentication -- `kagi assistant` - Ask Kagi Assistant from the terminal +- `kagi batch` - run multiple searches with the same search filters +- `kagi assistant` - continue research with Kagi Assistant +- `kagi summarize` - summarize a page after you find it +- `kagi auth` - inspect which token path will be used ## See Also -- [Authentication Guide](/guides/authentication) - Detailed token setup -- [Workflows](/guides/workflows) - Real-world usage patterns -- [Troubleshooting](/guides/troubleshooting) - Common issues -- [Auth Matrix](/reference/auth-matrix) - Command auth requirements +- [Authentication Guide](/guides/authentication) +- [Auth Matrix](/reference/auth-matrix) +- [API and Product Coverage](/reference/coverage) diff --git a/docs/demo-assets/search.gif b/docs/demo-assets/search.gif index 1ca5c14..afbfff1 100644 Binary files a/docs/demo-assets/search.gif and b/docs/demo-assets/search.gif differ diff --git a/docs/demos.md b/docs/demos.md index 912be79..a3c9d44 100644 --- a/docs/demos.md +++ b/docs/demos.md @@ -18,7 +18,7 @@ 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 search --format pretty --region us --time year --order recency "rust release notes"` - `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 ...` diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index cca832c..2d1e29b 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -56,13 +56,13 @@ flowchart TD RequiresAuth --> LoadCreds[Load credentials from env/file] LoadCreds --> CheckReqs{Check command requirements} - SearchCmd --> PreferAPI[Prefer API fallback to session] + SearchCmd --> PreferConfigured[Use configured base-search preference] CheckReqs -->|Session Required| SessionReq[Use KAGI_SESSION_TOKEN] CheckReqs -->|API Required| APIReq[Use KAGI_API_TOKEN] - CheckReqs -->|Both Accepted| BothAccepted[Try API first fallback to session] - - PreferAPI --> BothAccepted + CheckReqs -->|Both Accepted| BothAccepted[Use configured search preference] + + PreferConfigured --> BothAccepted ``` ## Getting Your Tokens @@ -406,28 +406,30 @@ flowchart TD Fallback --> SessionPath ``` -### Fallback Behavior +### Base Search Behavior -The base search command implements intelligent fallback: +The base search command follows the configured base-search preference. -**Scenario 1: API token works** +**Scenario 1: API-first mode works** ```bash export KAGI_API_TOKEN='valid_token' +# .kagi.toml contains: [auth] preferred_auth = "api" kagi search "test" # Uses Search API, returns results ``` -**Scenario 2: API token rejected, fallback to session** +**Scenario 2: API-first mode rejected, fallback to session** ```bash export KAGI_API_TOKEN='valid_token' export KAGI_SESSION_TOKEN='also_valid' +# .kagi.toml contains: [auth] preferred_auth = "api" kagi search "test" # Tries Search API → gets auth error # Falls back to session token path # Returns results ``` -**Scenario 3: Only session token** +**Scenario 3: Default session-first behavior** ```bash export KAGI_SESSION_TOKEN='valid_token' kagi search "test" @@ -690,7 +692,7 @@ alias kagi-personal='KAGI_SESSION_TOKEN=personal_token kagi' 1. **Two token types:** Session (subscriber features) and API (paid API features) 2. **Clear precedence:** Environment variables override config files -3. **Smart fallback:** Base search tries API first, falls back to session +3. **Configured search preference:** Base search defaults to session unless `[auth.preferred_auth] = "api"` 4. **Security first:** Never commit tokens, rotate regularly, use secret managers 5. **Easy verification:** Use `kagi auth status` and `kagi auth check` @@ -698,7 +700,7 @@ alias kagi-personal='KAGI_SESSION_TOKEN=personal_token kagi' - Session Token = Personal subscription features - API Token = Paid API endpoints - Both can coexist, each serves different commands -- Base search is smart about which to use +- Base search follows the configured search preference ## Next Steps diff --git a/docs/guides/quickstart.mdx b/docs/guides/quickstart.mdx index 0a03b60..bdb616e 100644 --- a/docs/guides/quickstart.mdx +++ b/docs/guides/quickstart.mdx @@ -164,6 +164,9 @@ kagi search --format pretty "rust programming language" # Search with a lens (replace 2 with your lens index) kagi search --lens 2 "developer documentation" + +# Search with live session-only filters +kagi search --region us --time month --order recency "rust release notes" ``` **Search Demo:** @@ -421,6 +424,7 @@ kagi news --list-categories # Show available news categories kagi search "query" # JSON output kagi search --format pretty "query" # Human-readable kagi search --lens 2 "query" # With lens +kagi search --region us --time month "query" # With live filters kagi search --lens 2 --format pretty "query" # Combined ``` diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx index 9fb0da7..05bc3aa 100644 --- a/docs/guides/troubleshooting.mdx +++ b/docs/guides/troubleshooting.mdx @@ -394,9 +394,9 @@ echo 'nameserver 8.8.8.8' | sudo tee /etc/resolv.conf ``` **2. Fallback behavior:** -- Base search tries API first, then falls back -- If API is slow/down, fallback adds delay -- Set explicit token to skip fallback +- Base search only falls back when you have opted into API-first mode with `[auth.preferred_auth] = "api"` +- If the API path is slow or rejected, the session fallback adds delay +- Leave base search on the default session-first behavior if you want to avoid that extra hop **3. Rate limiting backoff:** - After many requests, service may slow responses diff --git a/docs/project/demos.mdx b/docs/project/demos.mdx index e191be9..081dada 100644 --- a/docs/project/demos.mdx +++ b/docs/project/demos.mdx @@ -43,7 +43,7 @@ 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 search --format pretty --region us --time year --order recency "rust release notes"` - `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 ...` diff --git a/docs/reference/auth-matrix.mdx b/docs/reference/auth-matrix.mdx index 9e0ba0e..225ee97 100644 --- a/docs/reference/auth-matrix.mdx +++ b/docs/reference/auth-matrix.mdx @@ -11,8 +11,9 @@ This reference provides a complete mapping of which commands require which authe | Command | Preferred Auth | Fallback | Notes | |---------|---------------|----------|-------| -| `search` (base) | `KAGI_API_TOKEN` | `KAGI_SESSION_TOKEN` on auth rejection | Smart fallback behavior | +| `search` (base) | Configured base-search preference | Session fallback only when API-first mode is enabled | Defaults to session unless `[auth.preferred_auth] = "api"` | | `search --lens` | `KAGI_SESSION_TOKEN` | None | Lens requires session token | +| `search` with filters | `KAGI_SESSION_TOKEN` | None | Region, time, date, order, verbatim, and personalization filters require session token | | `auth status` | None | None | Reads config only | | `auth check` | Primary credential | None | Tests selected token | | `auth set` | None | None | Saves credentials | @@ -34,22 +35,30 @@ This reference provides a complete mapping of which commands require which authe ```mermaid flowchart TD - Start([User Query]) --> CheckAPI{KAGI_API_TOKEN configured?} - - CheckAPI -->|Yes| TrySearchAPI[Try Search API] - TrySearchAPI -->|Success| ReturnResults1[Return results ✓] - TrySearchAPI -->|Auth error| CheckSession1{KAGI_SESSION_TOKEN configured?} - CheckSession1 -->|Yes| TryWeb1[Try web product] - TryWeb1 -->|Success| ReturnResults2[Success ✓] - CheckSession1 -->|No| Error1[Error: auth failed] - - CheckAPI -->|No| CheckSession2{KAGI_SESSION_TOKEN configured?} - CheckSession2 -->|Yes| TryWeb2[Try web product] - TryWeb2 -->|Success| ReturnResults3[Success ✓] - CheckSession2 -->|No| Error2[Error: missing credentials] + Start([User Query]) --> Pref{[auth.preferred_auth] = api?} + + Pref -->|No or unset| CheckSession{KAGI_SESSION_TOKEN configured?} + CheckSession -->|Yes| TryWeb[Try web product] + TryWeb -->|Success| ReturnResults1[Return results ✓] + CheckSession -->|No| CheckAPI1{KAGI_API_TOKEN configured?} + CheckAPI1 -->|Yes| TryAPI1[Try Search API] + TryAPI1 -->|Success| ReturnResults2[Return results ✓] + CheckAPI1 -->|No| Error1[Error: missing credentials] + + Pref -->|Yes| CheckAPI2{KAGI_API_TOKEN configured?} + CheckAPI2 -->|Yes| TryAPI2[Try Search API] + TryAPI2 -->|Success| ReturnResults3[Return results ✓] + TryAPI2 -->|Auth error| CheckSession2{KAGI_SESSION_TOKEN configured?} + CheckSession2 -->|Yes| TryWebFallback[Try web product] + TryWebFallback -->|Success| ReturnResults4[Success ✓] + CheckSession2 -->|No| Error2[Error: auth failed] + CheckAPI2 -->|No| CheckSession3{KAGI_SESSION_TOKEN configured?} + CheckSession3 -->|Yes| TryWeb2[Try web product] + TryWeb2 -->|Success| ReturnResults5[Return results ✓] + CheckSession3 -->|No| Error3[Error: missing credentials] ``` -**Key insight:** Base search is the only command with fallback behavior. This maximizes utility while maintaining predictable auth for other commands. +**Key insight:** Base search is the only command with fallback behavior, and that fallback only matters when you explicitly opt into API-first mode. #### Lens Search (`kagi search --lens `) @@ -65,6 +74,20 @@ flowchart TD **No fallback:** Lens search requires session token exclusively. +#### Filtered Search (`kagi search --region ...`, `--time ...`, etc.) + +```mermaid +flowchart TD + Start([User Query with Search Filters]) --> CheckSession{KAGI_SESSION_TOKEN configured?} + + CheckSession -->|Yes| QueryWeb[Query web product with filters] + QueryWeb -->|Success| ReturnResults[Success ✓] + + CheckSession -->|No| Error[Error: missing session token] +``` + +**No fallback:** filtered search requires session token exclusively. + ### Authentication Commands #### `kagi auth status` @@ -132,7 +155,7 @@ Both `enrich web` and `enrich news` require `KAGI_API_TOKEN`: Requires `KAGI_SESSION_TOKEN`: - ✅ Lens-aware search (`--lens`) -- ✅ Ask Page (`ask-page`) +- ✅ Filtered search (`--region`, `--time`, `--from-date`, `--to-date`, `--order`, `--verbatim`, personalization flags) - ✅ Kagi Assistant prompt and thread commands (`assistant`) - ✅ Ask Page (`ask-page`) - ✅ Subscriber Summarizer (`summarize --subscriber`) @@ -146,7 +169,7 @@ Requires `KAGI_API_TOKEN`: - ✅ Public Summarizer (`summarize`) - ✅ Web Enrichment (`enrich web`) - ✅ News Enrichment (`enrich news`) -- ✅ Base search (preferred path) +- ✅ Base search when `[auth.preferred_auth] = "api"` ### No Token Required @@ -177,7 +200,7 @@ Works without authentication: api_token = "api123" session_token = "session456" ``` -- `search`: Uses API token (preferred) +- `search`: Uses the configured base-search preference (session by default) - `assistant`: Uses session token - `news`: No token needed @@ -210,6 +233,8 @@ kagi auth set --session-token 'https://kagi.com/search?token=...' **Working commands:** - ✅ `kagi search "query"` (uses session path) - ✅ `kagi search --lens 2 "query"` +- ✅ `kagi search --region us --time month "query"` +- ✅ `kagi ask-page https://example.com "question"` - ✅ `kagi ask-page https://example.com "question"` - ✅ `kagi assistant "prompt"` - ✅ `kagi summarize --subscriber --url ...` @@ -238,6 +263,8 @@ kagi auth set --api-token 'your_api_token' **Non-working:** - ❌ `kagi search --lens 2` - 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 assistant` - requires session token - ❌ `kagi summarize --subscriber` - requires session token diff --git a/docs/reference/coverage.mdx b/docs/reference/coverage.mdx index 2fd453f..73593e2 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 | +| Filtered Search | `kagi search --region/--time/...` | ✅ Implemented | | Web Summarizer | `kagi summarize --subscriber` | ✅ Implemented | | Assistant prompt + thread management | `kagi assistant` | ✅ Implemented | | Ask Questions About a Page | `kagi ask-page` | ✅ Implemented | @@ -52,6 +53,7 @@ These require no authentication: |---------|-------------|------|--------| | `search` | Kagi search | API/Session | ✅ | | `search --lens` | Lens search | Session | ✅ | +| `search` with filters | Search with region, time, date, order, verbatim, or personalization filters | Session | ✅ | | `auth` | Credential management | None | ✅ | | `summarize` | Public API summarizer | API | ✅ | | `summarize --subscriber` | Web summarizer | Session | ✅ | @@ -69,6 +71,13 @@ These require no authentication: |--------|----------|--------| | `--format pretty` | search | ✅ | | `--lens` | search | ✅ | +| `--region` | search, batch | ✅ | +| `--time` | search, batch | ✅ | +| `--from-date` | search, batch | ✅ | +| `--to-date` | search, batch | ✅ | +| `--order` | search, batch | ✅ | +| `--verbatim` | search, batch | ✅ | +| `--personalized` / `--no-personalized` | search, batch | ✅ | | `--limit` | news, smallweb | ✅ | | `--category` | news | ✅ | | `--list-categories` | news | ✅ | @@ -128,7 +137,7 @@ These features were evaluated and left out of the public CLI surface: | Type | Support | Commands | |------|---------|----------| -| Session Token | ✅ | search, ask-page, summarize --subscriber, assistant | +| Session Token | ✅ | search, filtered search, ask-page, summarize --subscriber, assistant | | API Token | ✅ | search, summarize, fastgpt, enrich | ### Authentication Patterns diff --git a/docs/reference/error-reference.mdx b/docs/reference/error-reference.mdx index 50c3630..2c05831 100644 --- a/docs/reference/error-reference.mdx +++ b/docs/reference/error-reference.mdx @@ -157,6 +157,46 @@ kagi auth set --session-token 'YOUR_TOKEN' ## Command Errors +### "search filters require KAGI_SESSION_TOKEN" + +**Message:** +``` +Config error: search filters require KAGI_SESSION_TOKEN (env or .kagi.toml [auth.session_token]) +``` + +**Meaning:** You used session-only search filters like `--region`, `--time`, `--from-date`, `--to-date`, `--order`, `--verbatim`, or personalization flags without a configured session token. + +**Solution:** +```bash +# Add a session token +kagi auth set --session-token 'https://kagi.com/search?token=YOUR_TOKEN' + +# Or remove the session-only search filters +kagi search "rust release notes" +``` + +### "search --time cannot be combined with --from-date or --to-date" + +**Meaning:** Kagi's live search UI treats preset time windows and custom date ranges as separate filter modes. + +**Solution:** +```bash +# Use a preset time window +kagi search --time month "rust release notes" + +# Or use a custom date range +kagi search --from-date 2026-01-01 --to-date 2026-03-01 "rust release notes" +``` + +### "search --from-date must use YYYY-MM-DD format" + +**Meaning:** Search date filters require zero-padded ISO dates. + +**Solution:** +```bash +kagi search --from-date 2026-03-01 --to-date 2026-03-19 "rust compiler" +``` + ### "--length requires --subscriber" **Message:** diff --git a/images/demos/search.gif b/images/demos/search.gif index 70e9f21..afbfff1 100644 Binary files a/images/demos/search.gif and b/images/demos/search.gif differ diff --git a/project/demos.mdx b/project/demos.mdx index 6be043a..b6ccda0 100644 --- a/project/demos.mdx +++ b/project/demos.mdx @@ -43,7 +43,7 @@ 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 search --format pretty --region us --time year --order recency "rust release notes"` - `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 ...` diff --git a/scripts/demo-search.sh b/scripts/demo-search.sh index 1a56205..4b36e17 100755 --- a/scripts/demo-search.sh +++ b/scripts/demo-search.sh @@ -13,7 +13,7 @@ export PATH="/tmp/kagi-demo-bin:$PATH" printf '\033c' sleep 1.2 -printf '$ kagi search --format pretty "obsidian cli daily notes workflow"\n' +printf '$ kagi search --format pretty --region us --time year --order recency "rust release notes"\n' sleep 0.4 -kagi search --format pretty "obsidian cli daily notes workflow" | sed -n '1,12p' +kagi search --format pretty --region us --time year --order recency "rust release notes" | sed -n '1,12p' sleep 2 diff --git a/src/auth.rs b/src/auth.rs index 2d982b0..2dc23ea 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -80,6 +80,13 @@ pub struct SearchCredentials { pub fallback_session: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchAuthRequirement { + Base, + Lens, + Filtered, +} + #[derive(Debug, Clone)] pub struct CredentialInventory { pub api_token: Option, @@ -91,20 +98,30 @@ pub struct CredentialInventory { impl CredentialInventory { pub fn resolve_for_search( &self, - requires_session: bool, + requirement: SearchAuthRequirement, ) -> Result { - if requires_session { - let session = self.session_token.clone().ok_or_else(|| { - KagiError::Config( - "lens search requires KAGI_SESSION_TOKEN (env or .kagi.toml [auth.session_token])" - .to_string(), - ) - })?; - - return Ok(SearchCredentials { - primary: session, - fallback_session: None, - }); + match requirement { + SearchAuthRequirement::Lens | SearchAuthRequirement::Filtered => { + let session = self.session_token.clone().ok_or_else(|| { + KagiError::Config(match requirement { + SearchAuthRequirement::Lens => { + "lens search requires KAGI_SESSION_TOKEN (env or .kagi.toml [auth.session_token])" + .to_string() + } + SearchAuthRequirement::Filtered => { + "search filters require KAGI_SESSION_TOKEN (env or .kagi.toml [auth.session_token])" + .to_string() + } + SearchAuthRequirement::Base => unreachable!(), + }) + })?; + + return Ok(SearchCredentials { + primary: session, + fallback_session: None, + }); + } + SearchAuthRequirement::Base => {} } match self.search_preference { @@ -456,12 +473,32 @@ mod tests { }; let error = inventory - .resolve_for_search(true) + .resolve_for_search(SearchAuthRequirement::Lens) .expect_err("lens should require session token"); assert!(matches!(error, KagiError::Config(_))); assert!(error.to_string().contains("requires KAGI_SESSION_TOKEN")); } + #[test] + fn requires_session_for_filtered_search() { + let inventory = CredentialInventory { + api_token: Some(Credential { + kind: CredentialKind::ApiToken, + source: CredentialSource::Env, + value: "api".to_string(), + }), + session_token: None, + search_preference: SearchAuthPreference::Session, + config_path: PathBuf::from(DEFAULT_CONFIG_PATH), + }; + + let error = inventory + .resolve_for_search(SearchAuthRequirement::Filtered) + .expect_err("filtered search should require session token"); + assert!(matches!(error, KagiError::Config(_))); + assert!(error.to_string().contains("search filters require")); + } + #[test] fn base_search_keeps_api_token_as_fallback_when_session_is_preferred() { let inventory = CredentialInventory { @@ -480,7 +517,7 @@ mod tests { }; let credentials = inventory - .resolve_for_search(false) + .resolve_for_search(SearchAuthRequirement::Base) .expect("base search resolves credential"); assert_eq!(credentials.primary.kind, CredentialKind::SessionToken); assert_eq!( @@ -510,7 +547,7 @@ mod tests { }; let credentials = inventory - .resolve_for_search(false) + .resolve_for_search(SearchAuthRequirement::Base) .expect("base search resolves credential"); assert_eq!(credentials.primary.kind, CredentialKind::SessionToken); } @@ -533,7 +570,7 @@ mod tests { }; let credentials = inventory - .resolve_for_search(false) + .resolve_for_search(SearchAuthRequirement::Base) .expect("base search resolves credential"); assert_eq!(credentials.primary.kind, CredentialKind::ApiToken); } @@ -590,13 +627,6 @@ mod tests { assert_eq!(token, "abc123.def456"); } - #[test] - fn rejects_session_link_without_token_param() { - let error = normalize_session_token_input("https://kagi.com/search?q=test") - .expect_err("missing token param should fail"); - assert!(error.to_string().contains("token=")); - } - #[test] fn builds_env_session_credential_from_session_link() { let credential = build_session_credential( @@ -622,4 +652,11 @@ mod tests { 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") + .expect_err("missing token param should fail"); + assert!(error.to_string().contains("token=")); + } } diff --git a/src/cli.rs b/src/cli.rs index 057e2c5..d618b9e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,6 +42,22 @@ impl std::fmt::Display for OutputFormat { } } +#[derive(Debug, Clone, ValueEnum)] +pub enum SearchOrder { + Default, + Recency, + Website, + Trackers, +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum SearchTime { + Day, + Week, + Month, + Year, +} + #[derive(Debug, Parser)] #[command( name = "kagi", @@ -78,6 +94,7 @@ pub enum Commands { /// • Multiple output formats: json (default), pretty, compact, markdown, csv /// • Colorized pretty output (disable with --no-color) /// • Lens support for scoped searches + /// • Region, time, date, order, verbatim, and personalization filters Search(SearchArgs), /// Inspect and validate configured credentials Auth(AuthCommand), @@ -104,6 +121,7 @@ pub enum Commands { /// • Token bucket rate limiting to respect API limits /// • All output formats supported (json, pretty, compact, markdown, csv) /// • Lens support for scoped searches + /// • Shared region, time, date, order, verbatim, and personalization filters /// • Color output control with --no-color Batch(BatchSearchArgs), } @@ -130,6 +148,38 @@ pub struct SearchArgs { /// 3. Check the URL for the "l=" parameter value #[arg(long, value_name = "INDEX")] pub lens: Option, + + /// Restrict results to a Kagi region code such as "us", "gb", or "no_region" + #[arg(long, value_name = "REGION")] + pub region: Option, + + /// Restrict results to a recent time window + #[arg(long, value_name = "WINDOW", value_enum)] + pub time: Option, + + /// Restrict results to pages updated on or after this date + #[arg(long, value_name = "YYYY-MM-DD")] + pub from_date: Option, + + /// Restrict results to pages updated on or before this date + #[arg(long, value_name = "YYYY-MM-DD")] + pub to_date: Option, + + /// Reorder search results + #[arg(long, value_name = "ORDER", value_enum)] + pub order: Option, + + /// Enable verbatim search mode for this request + #[arg(long)] + pub verbatim: bool, + + /// Force personalized search on for this request + #[arg(long, conflicts_with = "no_personalized")] + pub personalized: bool, + + /// Force personalized search off for this request + #[arg(long, conflicts_with = "personalized")] + pub no_personalized: bool, } #[derive(Debug, Args)] @@ -157,6 +207,38 @@ pub struct BatchSearchArgs { /// Scope all searches to a Kagi lens by numeric index #[arg(long, value_name = "INDEX")] pub lens: Option, + + /// Restrict results to a Kagi region code such as "us", "gb", or "no_region" + #[arg(long, value_name = "REGION")] + pub region: Option, + + /// Restrict results to a recent time window + #[arg(long, value_name = "WINDOW", value_enum)] + pub time: Option, + + /// Restrict results to pages updated on or after this date + #[arg(long, value_name = "YYYY-MM-DD")] + pub from_date: Option, + + /// Restrict results to pages updated on or before this date + #[arg(long, value_name = "YYYY-MM-DD")] + pub to_date: Option, + + /// Reorder search results + #[arg(long, value_name = "ORDER", value_enum)] + pub order: Option, + + /// Enable verbatim search mode for all batch requests + #[arg(long)] + pub verbatim: bool, + + /// Force personalized search on for all batch requests + #[arg(long, conflicts_with = "no_personalized")] + pub personalized: bool, + + /// Force personalized search off for all batch requests + #[arg(long, conflicts_with = "personalized")] + pub no_personalized: bool, } impl BatchSearchArgs { diff --git a/src/main.rs b/src/main.rs index 4702181..5cccd2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,12 +17,12 @@ use crate::api::{ execute_summarize, }; use crate::auth::{ - Credential, CredentialKind, SearchCredentials, format_status, load_credential_inventory, - save_credentials, + Credential, CredentialKind, SearchAuthRequirement, SearchCredentials, format_status, + load_credential_inventory, save_credentials, }; use crate::cli::{ AssistantSubcommand, AssistantThreadExportFormat, AssistantThreadSubcommand, AuthSetArgs, - AuthSubcommand, Cli, Commands, CompletionShell, EnrichSubcommand, + AuthSubcommand, Cli, Commands, CompletionShell, EnrichSubcommand, SearchOrder, SearchTime, }; use crate::error::KagiError; use crate::types::{ @@ -33,6 +33,19 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Semaphore; +#[derive(Debug, Clone)] +struct SearchRequestOptions { + lens: Option, + region: Option, + time: Option, + from_date: Option, + to_date: Option, + order: Option, + verbatim: bool, + personalized: bool, + no_personalized: bool, +} + #[tokio::main] async fn main() { if let Err(error) = run().await { @@ -60,12 +73,18 @@ async fn run() -> Result<(), KagiError> { .ok_or_else(|| KagiError::Config("missing command".to_string()))? { Commands::Search(args) => { - let request = search::SearchRequest::new(args.query); - let request = if let Some(lens) = args.lens { - request.with_lens(lens) - } else { - request + let options = SearchRequestOptions { + lens: args.lens, + region: args.region, + time: args.time, + from_date: args.from_date, + to_date: args.to_date, + order: args.order, + verbatim: args.verbatim, + personalized: args.personalized, + no_personalized: args.no_personalized, }; + let request = build_search_request(args.query, &options); let format_str = match args.format { cli::OutputFormat::Json => "json", cli::OutputFormat::Pretty => "pretty", @@ -226,7 +245,6 @@ async fn run() -> Result<(), KagiError> { print_json(&response) } Commands::Batch(args) => { - // Validate batch arguments args.validate().map_err(KagiError::Config)?; let format_str = match args.format { @@ -242,7 +260,17 @@ async fn run() -> Result<(), KagiError> { args.rate_limit, format_str.to_string(), !args.no_color, - args.lens, + SearchRequestOptions { + lens: args.lens, + region: args.region, + time: args.time, + from_date: args.from_date, + to_date: args.to_date, + order: args.order, + verbatim: args.verbatim, + personalized: args.personalized, + no_personalized: args.no_personalized, + }, ) .await } @@ -277,7 +305,7 @@ fn run_auth_set(args: AuthSetArgs) -> Result<(), KagiError> { async fn run_auth_check() -> Result<(), KagiError> { let inventory = load_credential_inventory()?; - let credentials = inventory.resolve_for_search(false)?; + let credentials = inventory.resolve_for_search(SearchAuthRequirement::Base)?; let request = search::SearchRequest::new("rust lang"); let selected_kind = credentials.primary.kind; @@ -349,6 +377,59 @@ fn resolve_session_token() -> Result { }) } +fn build_search_request(query: String, options: &SearchRequestOptions) -> search::SearchRequest { + let mut request = search::SearchRequest::new(query); + + if let Some(lens) = options.lens.clone() { + request = request.with_lens(lens); + } + if let Some(region) = options.region.clone() { + request = request.with_region(region); + } + if let Some(time) = options.time.clone() { + request = request.with_time_filter(match time { + SearchTime::Day => "1", + SearchTime::Week => "2", + SearchTime::Month => "3", + SearchTime::Year => "4", + }); + } + if let Some(from_date) = options.from_date.clone() { + request = request.with_from_date(from_date); + } + if let Some(to_date) = options.to_date.clone() { + request = request.with_to_date(to_date); + } + if let Some(order) = options.order.clone() { + request = match order { + SearchOrder::Default => request, + SearchOrder::Recency => request.with_order("2"), + SearchOrder::Website => request.with_order("3"), + SearchOrder::Trackers => request.with_order("4"), + }; + } + if options.verbatim { + request = request.with_verbatim(true); + } + if options.personalized { + request = request.with_personalized(true); + } else if options.no_personalized { + request = request.with_personalized(false); + } + + request +} + +fn search_auth_requirement(request: &search::SearchRequest) -> SearchAuthRequirement { + if request.lens.is_some() { + SearchAuthRequirement::Lens + } else if request.has_runtime_filters() { + SearchAuthRequirement::Filtered + } else { + SearchAuthRequirement::Base + } +} + fn print_json(value: &T) -> Result<(), KagiError> { let output = serde_json::to_string_pretty(value) .map_err(|error| KagiError::Parse(format!("failed to serialize JSON output: {error}")))?; @@ -362,7 +443,7 @@ async fn run_search( use_color: bool, ) -> Result<(), KagiError> { let inventory = load_credential_inventory()?; - let credentials = inventory.resolve_for_search(request.lens.is_some())?; + let credentials = inventory.resolve_for_search(search_auth_requirement(&request))?; let response = execute_search_request(&request, credentials).await?; let output = match format.as_str() { @@ -520,10 +601,11 @@ async fn run_batch_search( rate_limit: u32, format: String, use_color: bool, - lens: Option, + options: SearchRequestOptions, ) -> Result<(), KagiError> { let inventory = load_credential_inventory()?; - let credentials = inventory.resolve_for_search(lens.is_some())?; + let auth_probe_request = build_search_request("auth probe".to_string(), &options); + let credentials = inventory.resolve_for_search(search_auth_requirement(&auth_probe_request))?; let rate_limiter = Arc::new(RateLimiter::new(rate_limit, rate_limit)); let semaphore = Arc::new(Semaphore::new(concurrency)); @@ -534,7 +616,7 @@ async fn run_batch_search( let rate_limiter_clone = Arc::clone(&rate_limiter); let semaphore_clone = Arc::clone(&semaphore); let credentials_clone = credentials.clone(); - let lens_clone = lens.clone(); + let options_clone = options.clone(); let format_clone = format.clone(); let query_clone = query.clone(); @@ -543,12 +625,7 @@ async fn run_batch_search( let _permit = semaphore_clone.acquire().await; rate_limiter_clone.acquire().await?; - let request = search::SearchRequest::new(query_clone); - let request = if let Some(lens) = lens_clone { - request.with_lens(lens) - } else { - request - }; + let request = build_search_request(query_clone, &options_clone); let response = execute_search_request(&request, credentials_clone).await?; @@ -642,9 +719,10 @@ async fn run_batch_search( #[cfg(test)] mod tests { use super::{ - RateLimiter, format_csv_response, format_markdown_response, format_pretty_response, - should_fallback_to_session, + RateLimiter, SearchRequestOptions, build_search_request, format_csv_response, + format_markdown_response, format_pretty_response, should_fallback_to_session, }; + use crate::cli::{SearchOrder, SearchTime}; use crate::error::KagiError; use crate::types::{SearchResponse, SearchResult}; use std::sync::Arc; @@ -729,6 +807,28 @@ mod tests { assert!(output.contains("\x1b[0m")); } + #[test] + fn build_search_request_treats_default_order_as_no_order_filter() { + let request = build_search_request( + "rust".to_string(), + &SearchRequestOptions { + lens: None, + region: None, + time: Some(SearchTime::Month), + from_date: None, + to_date: None, + order: Some(SearchOrder::Default), + verbatim: false, + personalized: false, + no_personalized: false, + }, + ); + + assert_eq!(request.time_filter.as_deref(), Some("3")); + assert_eq!(request.order, None); + assert!(request.has_runtime_filters()); + } + #[tokio::test] async fn test_rate_limiter_basic_functionality() { let rate_limiter = RateLimiter::new(10, 60); diff --git a/src/search.rs b/src/search.rs index a296d3f..77ddb96 100644 --- a/src/search.rs +++ b/src/search.rs @@ -14,21 +14,17 @@ const UNAUTHENTICATED_MARKERS: [&str; 3] = [ "paid search engine that gives power back to the user", ]; -/// Typed search request that can carry optional lens scoping. -/// -/// LENS FORMAT: The lens value should be a numeric index (e.g., "0", "1", "2"). -/// Lens indices are user-specific and correspond to the order of enabled lenses -/// in your Kagi account settings. Use `kagi search --help` to see how to discover -/// your available lens indices. -/// -/// To find your lens indices: -/// 1. Visit https://kagi.com/settings/lenses to see your enabled lenses -/// 2. Perform a search in the Kagi web UI and note the `l=` parameter in the URL -/// 3. The index corresponds to the position in your lens dropdown (0-indexed) #[derive(Debug, Clone)] pub struct SearchRequest { pub query: String, pub lens: Option, + pub region: Option, + pub time_filter: Option, + pub from_date: Option, + pub to_date: Option, + pub order: Option, + pub verbatim: Option, + pub personalized: Option, } impl SearchRequest { @@ -36,6 +32,13 @@ impl SearchRequest { Self { query: query.into(), lens: None, + region: None, + time_filter: None, + from_date: None, + to_date: None, + order: None, + verbatim: None, + personalized: None, } } @@ -43,11 +46,145 @@ impl SearchRequest { self.lens = Some(lens.into()); self } + + pub fn with_region(mut self, region: impl Into) -> Self { + self.region = Some(region.into()); + self + } + + pub fn with_time_filter(mut self, time_filter: impl Into) -> Self { + self.time_filter = Some(time_filter.into()); + self + } + + pub fn with_from_date(mut self, from_date: impl Into) -> Self { + self.from_date = Some(from_date.into()); + self + } + + pub fn with_to_date(mut self, to_date: impl Into) -> Self { + self.to_date = Some(to_date.into()); + self + } + + pub fn with_order(mut self, order: impl Into) -> Self { + self.order = Some(order.into()); + self + } + + pub fn with_verbatim(mut self, verbatim: bool) -> Self { + self.verbatim = Some(verbatim); + self + } + + pub fn with_personalized(mut self, personalized: bool) -> Self { + self.personalized = Some(personalized); + self + } + + pub fn has_runtime_filters(&self) -> bool { + self.region.is_some() + || self.time_filter.is_some() + || self.from_date.is_some() + || self.to_date.is_some() + || self.order.is_some() + || self.verbatim.unwrap_or(false) + || self.personalized.is_some() + } + + pub fn requires_session_auth(&self) -> bool { + self.lens.is_some() || self.has_runtime_filters() + } + + pub fn validate(&self) -> Result<(), KagiError> { + if self.query.trim().is_empty() { + return Err(KagiError::Config( + "search query cannot be empty".to_string(), + )); + } + + let lens = trimmed_optional(self.lens.as_deref()); + if self.lens.is_some() && lens.is_none() { + return Err(KagiError::Config( + "search --lens cannot be empty".to_string(), + )); + } + if let Some(lens) = lens { + validate_lens_value(lens)?; + } + + let region = trimmed_optional(self.region.as_deref()); + if self.region.is_some() && region.is_none() { + return Err(KagiError::Config( + "search --region cannot be empty".to_string(), + )); + } + + let time_filter = trimmed_optional(self.time_filter.as_deref()); + if self.time_filter.is_some() && time_filter.is_none() { + return Err(KagiError::Config( + "search --time cannot be empty".to_string(), + )); + } + + let order = trimmed_optional(self.order.as_deref()); + if self.order.is_some() && order.is_none() { + return Err(KagiError::Config( + "search --order cannot be empty".to_string(), + )); + } + + let from_date = trimmed_optional(self.from_date.as_deref()); + if self.from_date.is_some() && from_date.is_none() { + return Err(KagiError::Config( + "search --from-date cannot be empty".to_string(), + )); + } + + let to_date = trimmed_optional(self.to_date.as_deref()); + if self.to_date.is_some() && to_date.is_none() { + return Err(KagiError::Config( + "search --to-date cannot be empty".to_string(), + )); + } + + if time_filter.is_some() && (from_date.is_some() || to_date.is_some()) { + return Err(KagiError::Config( + "search --time cannot be combined with --from-date or --to-date".to_string(), + )); + } + + if let Some(date) = from_date { + validate_iso_date("search --from-date", date)?; + } + if let Some(date) = to_date { + validate_iso_date("search --to-date", date)?; + } + if let (Some(from_date), Some(to_date)) = (from_date, to_date) + && from_date > to_date + { + return Err(KagiError::Config( + "search --from-date cannot be after --to-date".to_string(), + )); + } + + Ok(()) + } +} + +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. pub async fn search_with_lens(request: &SearchRequest, token: &str) -> Result { if token.trim().is_empty() { return Err(KagiError::Auth( @@ -56,22 +193,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 - ))); - } - lens_value = lens.clone(); - query_params.push(("l", lens_value.as_str())); - } + let query_params = build_search_query_params(request)?; let response = client .get(KAGI_SEARCH_URL) @@ -117,17 +239,16 @@ pub async fn execute_api_search( )); } - if request.lens.is_some() { - return Err(KagiError::Config( - "lens search requires KAGI_SESSION_TOKEN; Kagi API token search is currently base-search only" - .to_string(), - )); + request.validate()?; + + if request.requires_session_auth() { + return Err(KagiError::Config(api_session_requirement_message(request))); } let client = build_client()?; let response = client .get(KAGI_API_SEARCH_URL) - .query(&[("q", request.query.as_str())]) + .query(&[("q", request.query.trim())]) .header(header::AUTHORIZATION, format!("Bot {token}")) .send() .await @@ -161,14 +282,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, @@ -178,6 +291,114 @@ pub async fn execute_search( Ok(SearchResponse { data }) } +fn build_search_query_params( + request: &SearchRequest, +) -> Result, KagiError> { + request.validate()?; + + let mut query_params = vec![("q", request.query.trim().to_string())]; + + if let Some(lens) = trimmed_optional(request.lens.as_deref()) { + query_params.push(("l", lens.to_string())); + } + if let Some(region) = trimmed_optional(request.region.as_deref()) { + query_params.push(("r", region.to_string())); + } + if let Some(time_filter) = trimmed_optional(request.time_filter.as_deref()) { + query_params.push(("dr", time_filter.to_string())); + } + if let Some(from_date) = trimmed_optional(request.from_date.as_deref()) { + query_params.push(("from_date", from_date.to_string())); + } + if let Some(to_date) = trimmed_optional(request.to_date.as_deref()) { + query_params.push(("to_date", to_date.to_string())); + } + if let Some(order) = trimmed_optional(request.order.as_deref()) + && !order.is_empty() + { + query_params.push(("order", order.to_string())); + } + if request.verbatim == Some(true) { + query_params.push(("verbatim", "1".to_string())); + } + if let Some(personalized) = request.personalized { + query_params.push(( + "personalized", + if personalized { "1" } else { "0" }.to_string(), + )); + } + + Ok(query_params) +} + +fn trimmed_optional(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn validate_iso_date(label: &str, date: &str) -> Result<(), KagiError> { + if !is_valid_iso_date(date) { + return Err(KagiError::Config(format!( + "{label} must use YYYY-MM-DD format" + ))); + } + + Ok(()) +} + +fn is_valid_iso_date(date: &str) -> bool { + if date.len() != 10 { + return false; + } + + let bytes = date.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return false; + } + + let year = match date[0..4].parse::() { + Ok(year) => year, + Err(_) => return false, + }; + let month = match date[5..7].parse::() { + Ok(month) => month, + Err(_) => return false, + }; + let day = match date[8..10].parse::() { + Ok(day) => day, + Err(_) => return false, + }; + + if month == 0 || month > 12 || day == 0 { + return false; + } + + day <= days_in_month(year, month) +} + +fn days_in_month(year: u32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + 2 => 28, + _ => 0, + } +} + +fn is_leap_year(year: u32) -> bool { + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) +} + +fn api_session_requirement_message(request: &SearchRequest) -> String { + if request.lens.is_some() { + "lens search requires KAGI_SESSION_TOKEN; the Kagi Search API only supports plain base search" + .to_string() + } else { + "search filters require KAGI_SESSION_TOKEN; the Kagi Search API only supports plain base search" + .to_string() + } +} + fn looks_unauthenticated(body: &str) -> bool { UNAUTHENTICATED_MARKERS .iter() @@ -246,6 +467,7 @@ mod tests { let request = SearchRequest::new("rust lang"); assert_eq!(request.query, "rust lang"); assert!(request.lens.is_none()); + assert!(!request.requires_session_auth()); } #[test] @@ -253,14 +475,94 @@ mod tests { let request = SearchRequest::new("rust lang").with_lens("2"); assert_eq!(request.query, "rust lang"); assert_eq!(request.lens, Some("2".to_string())); + assert!(request.requires_session_auth()); } #[test] - fn search_request_with_lens_can_be_chained() { - let request = SearchRequest::new("test query") - .with_lens("1") - .with_lens("2"); - assert_eq!(request.lens, Some("2".to_string())); + fn search_request_with_filters_requires_session_auth() { + let request = SearchRequest::new("rust lang") + .with_region("us") + .with_time_filter("2") + .with_order("4") + .with_verbatim(true) + .with_personalized(false); + + assert!(request.has_runtime_filters()); + assert!(request.requires_session_auth()); + } + + #[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(_))); + } + + #[test] + fn reject_time_filter_with_date_range() { + let error = SearchRequest::new("rust") + .with_time_filter("2") + .with_from_date("2026-03-01") + .validate() + .expect_err("time filter and custom date range should conflict"); + + assert!(matches!(error, KagiError::Config(_))); + assert!(error.to_string().contains("--time")); + } + + #[test] + fn rejects_invalid_from_date_format() { + let error = SearchRequest::new("rust") + .with_from_date("2026-2-1") + .validate() + .expect_err("invalid date should fail"); + + assert!(matches!(error, KagiError::Config(_))); + assert!(error.to_string().contains("YYYY-MM-DD")); + } + + #[test] + fn rejects_nonexistent_iso_dates() { + let error = SearchRequest::new("rust") + .with_to_date("2026-02-30") + .validate() + .expect_err("nonexistent date should fail"); + + assert!(matches!(error, KagiError::Config(_))); + } + + #[test] + fn rejects_inverted_date_range() { + let error = SearchRequest::new("rust") + .with_from_date("2026-03-02") + .with_to_date("2026-03-01") + .validate() + .expect_err("inverted date range should fail"); + + assert!(matches!(error, KagiError::Config(_))); + assert!(error.to_string().contains("cannot be after")); + } + + #[test] + fn builds_query_params_for_search_filters() { + let request = SearchRequest::new("rust lang") + .with_lens("2") + .with_region("us") + .with_order("4") + .with_from_date("2026-03-01") + .with_to_date("2026-03-02") + .with_verbatim(true) + .with_personalized(false); + + let params = build_search_query_params(&request).expect("query params should build"); + + assert!(params.contains(&("q", "rust lang".to_string()))); + assert!(params.contains(&("l", "2".to_string()))); + assert!(params.contains(&("r", "us".to_string()))); + assert!(params.contains(&("order", "4".to_string()))); + assert!(params.contains(&("from_date", "2026-03-01".to_string()))); + assert!(params.contains(&("to_date", "2026-03-02".to_string()))); + assert!(params.contains(&("verbatim", "1".to_string()))); + assert!(params.contains(&("personalized", "0".to_string()))); } #[tokio::test] @@ -285,7 +587,7 @@ mod tests { } #[tokio::test] - async fn execute_search_without_lens_attempts_transport() { + async fn execute_search_without_filters_attempts_transport() { let request = SearchRequest::new("test query"); let result = execute_search(&request, "").await; @@ -316,6 +618,20 @@ mod tests { assert!(err.to_string().contains("requires KAGI_SESSION_TOKEN")); } + #[tokio::test] + async fn execute_api_search_rejects_filtered_requests() { + let request = SearchRequest::new("test query").with_region("us"); + let result = execute_api_search(&request, "api-token").await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, KagiError::Config(_))); + assert!( + err.to_string() + .contains("search filters require KAGI_SESSION_TOKEN") + ); + } + #[test] fn parses_api_response_shape_into_search_response() { let raw = r#"{