diff --git a/.env.example b/.env.example index b492327..9820a06 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ -# Preferred for base search when your account has Search API access. +# Current Kagi API key for /api/v1 endpoints such as Search API and Extract. +KAGI_API_KEY= + +# Legacy Kagi API token for /api/v0 endpoints such as FastGPT, Summarizer, and Enrich. KAGI_API_TOKEN= # Required for lens-aware search and the proven HTML/session path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 25566f0..5326bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ Before `1.0.0`, breaking changes may still ship in minor releases. ## [Unreleased] +### Added + +- Added `KAGI_API_KEY`, `[auth].api_key`, and `kagi auth set --api-key` for current `/api/v1` Search and Extract API credentials. + +### Changed + +- Breaking: split current API keys from legacy API tokens. `KAGI_API_TOKEN` and `[auth].api_token` now represent legacy `/api/v0` credentials only, while base Search API mode requires `KAGI_API_KEY` or `[auth].api_key`. + ## [0.6.2] ### Added - Added `kagi_extract` to the built-in MCP server tool list, matching the existing paid Extract API command behavior. +- `kagi extract` and MCP `kagi_extract` now reject session-only Extract on Kagi accounts whose API portal disallows Extract API access, avoiding legacy token regeneration loops. ## [0.6.1] diff --git a/README.md b/README.md index 414a3fd..67ad0d5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ `kagi` is a terminal CLI for Kagi that gives you command-line access to search, quick answers, ask-page, assistant, translate, summarization, public feeds through `news` and `smallweb`, paid API commands like `fastgpt` and `enrich`, and account-level settings like lenses, custom assistants, custom bangs, and redirect rules. it is built for people who want one command surface for interactive use, shell workflows, and structured JSON output. -the main setup path is `kagi auth`. on a real terminal it opens a guided setup flow where you choose `Session Link` or `API Token`, get the official instructions inline, paste the credential, save it to `./.kagi.toml`, and validate it immediately. if you also use Kagi's paid API, the same wizard can add that too. +the main setup path is `kagi auth`. on a real terminal it opens a guided setup flow where you choose `Session Link`, `API Key`, or `Legacy API Token`, get the official instructions inline, paste the credential, save it to `./.kagi.toml`, and validate it immediately. if you also use Kagi's paid API, the same wizard can add that too. [documentation](https://kagi.micr.dev) | [npm](https://www.npmjs.com/package/kagi-cli) | [mcp](https://github.com/Microck/kagi-mcp) @@ -31,7 +31,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, quick answers, assistant, translate, summarization, `news`, and `smallweb` -- add `KAGI_API_TOKEN` only when you want the paid public API commands +- add `KAGI_API_KEY` for current `/api/v1` commands, or `KAGI_API_TOKEN` for legacy `/api/v0` commands ## quickstart @@ -79,7 +79,8 @@ kagi auth the wizard is the default setup path. it guides you through: - `Session Link` from `https://kagi.com/settings/user_details` -- `API Token` from `https://kagi.com/settings/api` +- `API Key` from `https://kagi.com/api/keys` +- `Legacy API Token` from `https://kagi.com/settings/api` - saving into `./.kagi.toml` - immediate validation @@ -101,29 +102,29 @@ kagi auth set --session-token 'https://kagi.com/search?token=...' kagi auth check ``` -add an api token when you want the paid public api commands: +add an api key when you want current paid api commands: how to get it: 1. click the top-right menu icon 2. go into `Settings` -3. click `Advanced` in the left sidebar -4. go into `Open API Portal` -5. under `API Token`, click `Generate New Token` +3. open `https://kagi.com/api/keys` +4. generate or copy an API key ![api token tutorial](images/tutorials/api-token.gif) ```bash -export KAGI_API_TOKEN='...' +export KAGI_API_KEY='...' ``` ## auth model | credential | what it unlocks | | --- | --- | -| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber` | -| `KAGI_API_TOKEN` | public `summarize`, `extract`, `fastgpt`, `enrich web`, `enrich news` | +| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber`, and Extract eligibility checks through the authenticated API portal | +| `KAGI_API_KEY` | current `/api/v1` Search API and Extract API with `Bearer` auth | +| `KAGI_API_TOKEN` | legacy `/api/v0` public `summarize`, `fastgpt`, `enrich web`, and `enrich news` with `Bot` auth | | none | `news`, `smallweb`, `auth status`, `--help` | example config: @@ -133,15 +134,19 @@ example config: # Full Kagi session-link URL or just the raw token value. session_token = "https://kagi.com/search?token=kagi_session_demo_1234567890abcdef" -# Paid API token for summarize, fastgpt, and enrich commands. -api_token = "kagi_api_demo_abcdef1234567890" +# Current API key for Search API and Extract. +api_key = "kagi_api_key_demo_abcdef1234567890" + +# Legacy API token for summarize, fastgpt, and enrich commands. +api_token = "kagi_api_token_demo_abcdef1234567890" # Base `kagi search` auth preference: "session" or "api". preferred_auth = "api" [profiles.work.auth] session_token = "https://kagi.com/search?token=work_session_demo" -api_token = "kagi_api_work_demo" +api_key = "kagi_api_key_work_demo" +api_token = "kagi_api_token_work_demo" preferred_auth = "session" ``` notes: @@ -165,7 +170,7 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | `kagi batch` | run multiple searches in parallel with JSON, TOON, compact, pretty, markdown, or csv output and shared filters | | `kagi auth` | launch the auth wizard, or inspect, validate, and save credentials | | `kagi summarize` | use the paid public summarizer API or the subscriber summarizer with `--subscriber` | -| `kagi extract` | extract a page's full content as markdown through the paid API | +| `kagi extract` | extract a page's full content as markdown through the current paid API, using `KAGI_API_KEY` directly; session-only auth is rejected when Kagi's API portal does not allow Extract access | | `kagi watch` | rerun a search on an interval and emit added/removed result URLs | | `kagi notify` | send search or news output to a webhook | | `kagi history` | inspect local command history and aggregate query stats | diff --git a/docs/SKILL.md b/docs/SKILL.md index 80b156c..d8abfb5 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -47,7 +47,7 @@ kagi auth ``` Opens a guided TTY wizard that walks through: -- Choosing Session Link (subscriber, free) or API Token (paid) +- Choosing Session Link, API Key, or Legacy API Token - Pasting credentials - Saving to `./.kagi.toml` - Immediate validation @@ -61,7 +61,10 @@ kagi auth set --session-token 'https://kagi.com/search?token=...' # Or via environment variable export KAGI_SESSION_TOKEN='...' -# API token (from https://kagi.com/settings/api) +# API key for current /api/v1 endpoints (from https://kagi.com/api/keys) +export KAGI_API_KEY='...' + +# Legacy API token for /api/v0 endpoints (from https://kagi.com/settings/api) export KAGI_API_TOKEN='...' ``` @@ -70,10 +73,11 @@ export KAGI_API_TOKEN='...' | Credential | What It Unlocks | |------------|-----------------| | `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, `search --news`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber` | +| `KAGI_API_KEY` | search API, extract | | `KAGI_API_TOKEN` | summarize, fastgpt, enrich web, enrich news | | none | news, smallweb, auth status, --help | -Environment variables override `./.kagi.toml`. When both tokens are present, base `kagi search` defaults to session token; set `[auth] preferred_auth = "api"` in config to prefer API. +Environment variables override `./.kagi.toml`. When a session token and API key are both present, base `kagi search` defaults to the session token; set `[auth] preferred_auth = "api"` in config to prefer the API key. ## Commands diff --git a/docs/commands/auth.mdx b/docs/commands/auth.mdx index f605185..992d64e 100644 --- a/docs/commands/auth.mdx +++ b/docs/commands/auth.mdx @@ -5,7 +5,7 @@ description: "Reference for the kagi auth wizard and non-interactive credential # `kagi auth` -`kagi auth` is the main onboarding path for this CLI. On a real terminal, it launches an interactive setup wizard that lets you choose `Session Link` or `API Token`, shows the official Kagi settings page for that credential, accepts a paste, saves into `./.kagi.toml`, and validates the selected credential immediately. +`kagi auth` is the main onboarding path for this CLI. On a real terminal, it launches an interactive setup wizard that lets you choose `Session Link`, `API Key`, or `Legacy API Token`, shows the official Kagi settings page for that credential, accepts a paste, saves into `./.kagi.toml`, and validates the selected credential immediately. ![Auth command demo](/images/demos/auth.gif) @@ -29,7 +29,7 @@ kagi auth The wizard flow is: 1. shows your current auth state and config path -2. lets you choose `Session Link` or `API Token` +2. lets you choose `Session Link`, `API Key`, or `Legacy API Token` 3. shows the official place to get that credential 4. accepts the pasted value 5. asks before overwriting an existing config value of the same type @@ -50,7 +50,19 @@ It accepts either: - the full Session Link URL - the raw token value -### API Token Path +### API Key Path + +The wizard points you to: + +```text +https://kagi.com/api/keys +``` + +It accepts: + +- the raw API key + +### Legacy API Token Path The wizard points you to: @@ -60,7 +72,7 @@ https://kagi.com/settings/api It accepts: -- the raw API token +- the raw legacy API token ### Non-TTY Behavior @@ -94,7 +106,8 @@ Example: selected: session-token (config) profile: default preferred auth for base search: session -api token: not configured +api key: not configured +legacy api token: not configured session token: configured via config config path: .kagi.toml precedence: env > selected profile config > default config; base search defaults to session unless preferred_auth = "api"; lens search requires session token @@ -118,21 +131,27 @@ auth check passed: session-token (config) auth check passed: api-token (env) ``` +```text +auth check passed: api-key (env) +``` + ## `kagi auth set` Use this when you want scripting or explicit non-interactive config writes. ```bash kagi auth set --session-token 'https://kagi.com/search?token=...' +kagi auth set --api-key '...' kagi auth set --api-token '...' -kagi auth set --session-token 'https://kagi.com/search?token=...' --api-token '...' +kagi auth set --session-token 'https://kagi.com/search?token=...' --api-key '...' --api-token '...' kagi --profile work auth set --session-token 'https://kagi.com/search?token=...' ``` Options: - `--session-token ` saves a Session Link or raw session token -- `--api-token ` saves a raw API token +- `--api-key ` saves a current API key for `/api/v1` endpoints +- `--api-token ` saves a legacy API token for `/api/v0` endpoints Behavior: @@ -145,7 +164,7 @@ Behavior: The CLI resolves credentials in this order: -1. `KAGI_API_TOKEN` / `KAGI_SESSION_TOKEN` +1. `KAGI_API_KEY` / `KAGI_API_TOKEN` / `KAGI_SESSION_TOKEN` 2. selected profile config, such as `[profiles.work.auth]` 3. default `./.kagi.toml` `[auth]` @@ -154,7 +173,8 @@ Environment variables override the config file. Use `--profile ` to select ```toml [profiles.work.auth] session_token = "work-session" -api_token = "work-api" +api_key = "work-api-key" +api_token = "work-legacy-api-token" preferred_auth = "session" ``` @@ -184,17 +204,17 @@ kagi auth set --session-token 'https://kagi.com/search?token=...' kagi auth check ``` -### Add API Token Later +### Add API Credentials Later ```bash kagi auth ``` -Choose `API Token`, paste it, then verify: +Choose `API Key` for current Search API or Extract access. Choose `Legacy API Token` for FastGPT, public Summarizer, or Enrich. ```bash kagi auth status -kagi fastgpt "what changed in rust 1.86?" +kagi search --format pretty "what changed in rust 1.86?" ``` ## Security Notes diff --git a/docs/commands/enrich.mdx b/docs/commands/enrich.mdx index b6046a4..d7ece2f 100644 --- a/docs/commands/enrich.mdx +++ b/docs/commands/enrich.mdx @@ -26,7 +26,7 @@ These endpoints provide enhanced search capabilities beyond standard search, off **Required:** `KAGI_API_TOKEN` -Enrichment APIs require API access and consume API credit. +Enrichment currently uses Kagi's legacy `/api/v0` API with `Bot` auth. It requires API access and consumes API credit. ## Subcommands diff --git a/docs/commands/extract.mdx b/docs/commands/extract.mdx index 03395d4..b1e5b4a 100644 --- a/docs/commands/extract.mdx +++ b/docs/commands/extract.mdx @@ -21,9 +21,9 @@ The command uses JSON mode internally because that is the stable envelope return ## Authentication -**Required:** `KAGI_API_TOKEN` +**Required:** `KAGI_API_KEY` -Extract is part of Kagi's paid API surface and consumes API credit per request. +Extract is part of Kagi's current `/api/v1` paid API surface, uses `Bearer` auth, and consumes API credit per request. ## Arguments diff --git a/docs/commands/fastgpt.mdx b/docs/commands/fastgpt.mdx index f92acb5..918ac5b 100644 --- a/docs/commands/fastgpt.mdx +++ b/docs/commands/fastgpt.mdx @@ -27,7 +27,7 @@ The `kagi fastgpt` command queries Kagi's FastGPT API, which provides quick, fac **Required:** `KAGI_API_TOKEN` -FastGPT requires API access and consumes API credit per request. +FastGPT currently uses Kagi's legacy `/api/v0` API with `Bot` auth. It requires API access and consumes API credit per request. ## Arguments diff --git a/docs/commands/mcp.mdx b/docs/commands/mcp.mdx index 24ae86d..2adab25 100644 --- a/docs/commands/mcp.mdx +++ b/docs/commands/mcp.mdx @@ -17,7 +17,7 @@ kagi mcp - `kagi_search` - search Kagi - `kagi_summarize` - summarize a URL or text through the public API -- `kagi_extract` - extract a page's full content as markdown through the paid Extract API. Accepts `url`. +- `kagi_extract` - extract a page's full content as markdown through the current `/api/v1` Extract API. Uses `KAGI_API_KEY` directly and rejects session-only auth when Kagi's API portal does not allow Extract access. Accepts `url`. - `kagi_quick` - get a Kagi Quick Answer - `kagi_news` - fetch Kagi News stories (no auth required). Accepts `category` (default `world`), `limit` (default `12`), and `lang` (default `default`). - `kagi_news_search` - search the News tab of kagi.com and return story clusters (session token required). Accepts `query`, optional `region`, `freshness` (`day`/`week`/`month`), `order` (`default`/`recency`/`website`), and `limit`. diff --git a/docs/commands/search.mdx b/docs/commands/search.mdx index 7e2286f..b97f341 100644 --- a/docs/commands/search.mdx +++ b/docs/commands/search.mdx @@ -38,7 +38,7 @@ This matters because the Kagi Search API is still base-search only in this CLI. Plain base search keeps the existing repo behavior: - default session-first search when a session token is available -- API-first only when you explicitly configure `[auth.preferred_auth] = "api"` +- API-first only when you configure `[auth.preferred_auth] = "api"` and provide `KAGI_API_KEY` - session fallback only when the API path was selected first and rejected ### Filtered Search diff --git a/docs/commands/summarize.mdx b/docs/commands/summarize.mdx index 18e7a24..56f2c67 100644 --- a/docs/commands/summarize.mdx +++ b/docs/commands/summarize.mdx @@ -19,7 +19,7 @@ kagi summarize [OPTIONS] The `kagi summarize` command provides access to two different summarization systems: -1. **Public API Mode** - Uses Kagi's paid Universal Summarizer API (requires `KAGI_API_TOKEN`) +1. **Public API Mode** - Uses Kagi's legacy paid Universal Summarizer API (requires `KAGI_API_TOKEN`) 2. **Subscriber Mode** - Uses your Kagi subscription's web-based Summarizer (requires `KAGI_SESSION_TOKEN`) The two modes have different capabilities, pricing models, and output options. Choose the mode that best fits your use case. @@ -30,7 +30,7 @@ The two modes have different capabilities, pricing models, and output options. C **Requires:** `KAGI_API_TOKEN` -Uses the documented Universal Summarizer API endpoint. This is a paid service that consumes API credit. +Uses the legacy `/api/v0` Universal Summarizer API endpoint with `Bot` auth. This is a paid service that consumes API credit. **Features:** - Multiple summary engines (cecil, agnes, etc.) diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index 5ae9bbb..3e5fd6e 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -1,11 +1,11 @@ --- title: "Authentication" -description: "Complete guide to understanding *kagi* CLI authentication - session tokens, API tokens, precedence rules, and security best practices." +description: "Complete guide to understanding *kagi* CLI authentication - session tokens, API keys, legacy API tokens, precedence rules, and security best practices." --- # Authentication Guide -Authentication is the foundation of the *kagi* CLI's functionality. This comprehensive guide explains the two credential types, how they're used, when each is required, and how to configure them securely. +Authentication is the foundation of the *kagi* CLI's functionality. This comprehensive guide explains the three credential types, how they're used, when each is required, and how to configure them securely. ## Recommended Setup Path @@ -15,13 +15,13 @@ On a real terminal, the default setup path is: kagi auth ``` -The wizard lets you choose `Session Link` or `API Token`, points you to the correct Kagi settings page, accepts a paste, saves into `./.kagi.toml`, and validates the selected credential immediately. +The wizard lets you choose `Session Link`, `API Key`, or `Legacy API Token`, points you to the correct Kagi settings page, accepts a paste, saves into `./.kagi.toml`, and validates the selected credential immediately. Use `kagi auth set ...` only when you want a non-interactive or scripted flow. ## Authentication Overview -The *kagi* CLI supports two distinct credential types that unlock different capabilities: +The *kagi* CLI supports three distinct credential types that unlock different capabilities: ### Session Token (`KAGI_SESSION_TOKEN`) @@ -46,18 +46,32 @@ The *kagi* CLI supports two distinct credential types that unlock different capa **Cost:** Included with your Kagi subscription (no additional charges) -### API Token (`KAGI_API_TOKEN`) +### API Key (`KAGI_API_KEY`) -**What it is:** A token for Kagi's documented public APIs. +**What it is:** A key for Kagi's current documented `/api/v1` APIs. -**Where it comes from:** Generated in your Kagi account settings under API. +**Where it comes from:** Generated in the Kagi API dashboard at `https://kagi.com/api/keys`. + +**What it unlocks:** +- Search API (`kagi search` when `[auth.preferred_auth] = "api"`) +- Extract API (`kagi extract`) + +**Wire format:** `Authorization: Bearer ` + +**Cost:** Requires available API credit (separate from subscription) + +### Legacy API Token (`KAGI_API_TOKEN`) + +**What it is:** A token for Kagi's older `/api/v0` APIs still used by some CLI commands. + +**Where it comes from:** Generated in older Kagi API settings. **What it unlocks:** -- Public API endpoints - FastGPT (`kagi fastgpt`) - Public Summarizer (`kagi summarize` without `--subscriber`) - Web/News Enrichment (`kagi enrich`) -- Base search via the Search API (if you have API access) + +**Wire format:** `Authorization: Bot ` **Cost:** Requires available API credit (separate from subscription) @@ -79,7 +93,8 @@ flowchart TD SearchCmd --> PreferConfigured[Use configured base-search preference] CheckReqs -->|Session Required| SessionReq[Use KAGI_SESSION_TOKEN] - CheckReqs -->|API Required| APIReq[Use KAGI_API_TOKEN] + CheckReqs -->|v1 API Required| APIKeyReq[Use KAGI_API_KEY] + CheckReqs -->|legacy API Required| APITokenReq[Use KAGI_API_TOKEN] CheckReqs -->|Both Accepted| BothAccepted[Use configured search preference] PreferConfigured --> BothAccepted @@ -105,14 +120,14 @@ Your Session Token is available in your Kagi account settings: - Don't share it in public forums or chat - Rotate it periodically if you suspect compromise -### Obtaining an API Token +### Obtaining an API Key -Your API Token is available separately in API settings: +Your API Key is available separately in the Kagi API dashboard: 1. **Log into Kagi** at [kagi.com](https://kagi.com) -2. **Open** [API settings](https://kagi.com/settings/api) -3. **Generate a new token** if you don't have one -4. **Copy the token** (long alphanumeric string) +2. **Open** [API keys](https://kagi.com/api/keys) +3. **Generate a new key** if you don't have one +4. **Copy the key** (long alphanumeric string) **Important API Notes:** - API access requires available credit @@ -127,7 +142,8 @@ Your API Token is available separately in API settings: | Token Type | Lifetime | Renewal | |------------|----------|---------| | Session Token | Long-lived (months) | Automatic via Kagi web session | -| API Token | Long-lived (months) | Manual regeneration in settings | +| API Key | Long-lived (months) | Manual regeneration in the API dashboard | +| Legacy API Token | Long-lived (months) | Manual regeneration in settings | ### Token Rotation @@ -261,7 +277,7 @@ $content | Out-File -FilePath ".kagi.toml" -Encoding utf8 Credentials are resolved in this order (first match wins): ``` -1. Environment Variables (KAGI_API_TOKEN, KAGI_SESSION_TOKEN) +1. Environment Variables (KAGI_API_KEY, KAGI_API_TOKEN, KAGI_SESSION_TOKEN) 2. Configuration File (`./.kagi.toml`) 3. Missing (error for commands requiring auth) ``` @@ -308,7 +324,8 @@ kagi auth status No credentials: ``` selected: none -api token: not configured +api key: not configured +legacy api token: not configured session token: not configured config path: .kagi.toml ``` @@ -316,15 +333,17 @@ config path: .kagi.toml Session token only: ``` selected: session-token (config) -api token: not configured +api key: not configured +legacy api token: not configured session token: configured via config config path: .kagi.toml ``` -Both tokens: +API key and session token: ``` -selected: api-token (env) -api token: configured via env +selected: api-key (env) +api key: configured via env +legacy api token: not configured session token: configured via config config path: .kagi.toml ``` @@ -340,7 +359,7 @@ kagi auth check This command: 1. Loads credentials using precedence rules 2. Attempts a test search -3. Reports which token was used +3. Reports which credential was used 4. Confirms authentication succeeds **Example outputs:** @@ -350,9 +369,9 @@ Session token valid: auth check passed: session-token (config) ``` -API token valid: +API key valid: ``` -auth check passed: api-token (env) +auth check passed: api-key (env) ``` Invalid or missing: @@ -389,9 +408,16 @@ These commands require `KAGI_SESSION_TOKEN`: - `kagi redirect` - redirect rule CRUD and enable/disable - `kagi translate` - Kagi Translate text mode - `kagi summarize --subscriber` - Subscriber Summarizer -- Base search (if no API token configured) +- Base search (if no API key is configured) + +### API Key Required + +These commands require `KAGI_API_KEY`: + +- `kagi extract` - Extract API +- Base search when `[auth.preferred_auth] = "api"` -### API Token Required +### Legacy API Token Required These commands require `KAGI_API_TOKEN`: @@ -399,15 +425,14 @@ These commands require `KAGI_API_TOKEN`: - `kagi enrich web` - Web enrichment - `kagi enrich news` - News enrichment - `kagi summarize` (without `--subscriber`) - Public Summarizer -- Base search (preferred path when API token available) -### Dual Token Support +### Session and API Key Search Support -Base search (`kagi search` without `--lens` or runtime filters) supports both tokens: +Base search (`kagi search` without `--lens` or runtime filters) supports session tokens and API keys: - **Defaults to session** unless `[auth.preferred_auth] = "api"` - **Falls back to session token** only when API-first mode is enabled and the Search API rejects the request -- **Enables seamless operation** regardless of which token you have +- **Requires an API key** for API-first Search API mode See the [Auth Matrix](/reference/auth-matrix) for a complete reference. @@ -430,12 +455,12 @@ flowchart TD Preference -->|No or unset| PreferSession{Session token configured?} PreferSession -->|Yes| SessionPath2[Use web product path] SessionPath2 --> ReturnResults2[Return results] - PreferSession -->|No| PreferApiFallback{API token configured?} + PreferSession -->|No| PreferApiFallback{API key configured?} PreferApiFallback -->|Yes| ApiPath1[Use Search API] ApiPath1 --> ReturnResults3[Return results] PreferApiFallback -->|No| Error1[Error] - Preference -->|Yes| PreferApi{API token configured?} + Preference -->|Yes| PreferApi{API key configured?} PreferApi -->|Yes| ApiPath2[Use Search API] ApiPath2 -->|Success| ReturnResults4[Return results] ApiPath2 -->|Auth error| SessionFallback{Session token configured?} @@ -462,7 +487,7 @@ kagi search "test" **Scenario 2: API-first mode works** ```bash -export KAGI_API_TOKEN='valid_token' +export KAGI_API_KEY='valid_key' # .kagi.toml contains: [auth] preferred_auth = "api" kagi search "test" # Uses Search API, returns results @@ -470,11 +495,11 @@ kagi search "test" **Scenario 3: API-first mode rejected, fallback to session** ```bash -export KAGI_API_TOKEN='valid_token' +export KAGI_API_KEY='valid_key' export KAGI_SESSION_TOKEN='also_valid' # .kagi.toml contains: [auth] preferred_auth = "api" kagi search "test" -# Tries Search API → gets auth error +# Tries Search API and gets auth error # Falls back to session token path # Returns results ``` @@ -621,7 +646,7 @@ KAGI_API_TOKEN=... ### "this command requires KAGI_SESSION_TOKEN" -**Cause:** Attempting to use subscriber features with only API token. +**Cause:** Attempting to use subscriber features with only an API key or legacy API token. **Solutions:** 1. Get Session Token from Kagi settings @@ -630,14 +655,23 @@ KAGI_API_TOKEN=... ### "this command requires KAGI_API_TOKEN" -**Cause:** Attempting to use API features without API token. +**Cause:** Attempting to use legacy `/api/v0` API features without a legacy API token. **Solutions:** -1. Get API Token from Kagi settings +1. Get a legacy API Token from Kagi settings 2. Check you have API credit available 3. Set API token: `kagi auth` or `kagi auth set --api-token '...'` 4. Verify: `kagi auth status` +### "base search requires KAGI_API_KEY" + +**Cause:** Attempting to use API-first base search with only a legacy API token. + +**Solutions:** +1. Get an API Key from the Kagi API dashboard +2. Set API key: `kagi auth` or `kagi auth set --api-key '...'` +3. Or set a session token and use the default session-backed search path + ### Token Not Being Used from Config File **Cause:** Environment variable is overriding file. @@ -655,6 +689,7 @@ env | grep KAGI # Unset if needed unset KAGI_SESSION_TOKEN +unset KAGI_API_KEY unset KAGI_API_TOKEN ``` @@ -703,9 +738,11 @@ For users with multiple Kagi accounts, prefer named profiles in one `.kagi.toml` ```toml [auth] session_token = "personal_token" +api_key = "personal_api_key" [profiles.work.auth] session_token = "work_token" +api_key = "work_api_key" api_token = "work_api_token" preferred_auth = "session" ``` @@ -721,7 +758,7 @@ Environment variables still take precedence when you need a process-level overri **Key takeaways:** -1. **Two token types:** Session (subscriber features) and API (paid API features) +1. **Three credential types:** Session (subscriber features), API Key (current `/api/v1` features), and Legacy API Token (older `/api/v0` features) 2. **Best local setup path:** `kagi auth` 3. **Clear precedence:** Environment variables override config files 4. **Configured search preference:** Base search defaults to session unless `[auth.preferred_auth] = "api"` @@ -730,8 +767,9 @@ Environment variables still take precedence when you need a process-level overri **Remember:** - Session Token = Personal subscription features -- API Token = Paid API endpoints -- Both can coexist, each serves different commands +- API Key = Current `/api/v1` endpoints +- Legacy API Token = Older `/api/v0` endpoints +- All can coexist, each serves different commands - Base search follows the configured search preference ## Next Steps diff --git a/docs/guides/quickstart.mdx b/docs/guides/quickstart.mdx index 9dcb8c4..b454e66 100644 --- a/docs/guides/quickstart.mdx +++ b/docs/guides/quickstart.mdx @@ -16,7 +16,7 @@ Step 2: Verify Installation (1 minute) ↓ Step 3: Subscriber Setup with Session Token (3 minutes) ↓ -Step 4: Paid API Setup with API Token (optional) +Step 4: Paid API Setup with API Key or Legacy API Token (optional) ↓ Step 5: Persistent Configuration (2 minutes) ↓ @@ -87,7 +87,8 @@ At this stage, `kagi auth status` will likely show: ``` selected: none -api token: not configured +api key: not configured +legacy api token: not configured session token: not configured config path: .kagi.toml ``` @@ -163,7 +164,8 @@ Expected output: ``` selected: session-token (config) -api token: not configured +api key: not configured +legacy api token: not configured session token: configured via config config path: .kagi.toml ``` @@ -236,20 +238,20 @@ kagi summarize --subscriber --url https://www.rust-lang.org --summary-type keypo ![Summarize command demo](/images/demos/summarize.gif) -## Step 4: Paid API Setup with API Token (Optional) +## Step 4: Paid API Setup with API Key or Legacy API Token (Optional) -If you have Kagi API access (separate from your subscription), you can add an API token for additional commands. +If you have Kagi API access (separate from your subscription), add an API key for current `/api/v1` commands or a legacy API token for older `/api/v0` commands. -### Getting Your API Token +### Getting Your API Key 1. **Log into Kagi** in your web browser -2. **Go to Settings** → [API Settings](https://kagi.com/settings/api) -3. **Generate a new API token** if you don't have one -4. **Copy the token** (looks like a long alphanumeric string) +2. **Open** [API keys](https://kagi.com/api/keys) +3. **Generate a new API key** if you don't have one +4. **Copy the key** (looks like a long alphanumeric string) **Note:** API access requires available credit. Check your Kagi account for API credit balance. -### Setting Up the API Token +### Setting Up the API Key **Option A: Auth Wizard** @@ -257,21 +259,21 @@ If you have Kagi API access (separate from your subscription), you can add an AP kagi auth ``` -Choose `API Token`, paste it, and let the wizard add it alongside your existing Session Link. +Choose `API Key`, paste it, and let the wizard add it alongside your existing Session Link. **Option B: Environment Variable** ```bash -export KAGI_API_TOKEN='your_api_token_here' +export KAGI_API_KEY='your_api_key_here' ``` **Option C: Persistent Configuration** ```bash -kagi auth set --api-token 'your_api_token_here' +kagi auth set --api-key 'your_api_key_here' ``` -**Note:** If you previously set a session token, this will add the API token alongside it. Both can coexist. +**Note:** If you previously set a session token, this will add the API key alongside it. Both can coexist. Use `--api-token` only for legacy FastGPT, Summarizer, and Enrich commands. ### Testing Paid API Commands @@ -289,12 +291,13 @@ kagi fastgpt "Explain quantum computing" kagi enrich web "artificial intelligence" ``` -### Understanding Dual Token Setup +### Understanding Multi-Credential Setup -When you have both tokens configured: +When you have multiple credentials configured: - **Session Token**: Enables lens search, Quick Answer, filtered search, ask-page, assistant, translate, and subscriber Summarizer -- **API Token**: Enables FastGPT, public Summarizer, Enrichment APIs +- **API Key**: Enables current Search API and Extract API +- **Legacy API Token**: Enables FastGPT, public Summarizer, and Enrichment APIs - **Base Search**: Defaults to session unless `[auth.preferred_auth] = "api"` See the [Auth Matrix](/reference/auth-matrix) for the complete command-to-token mapping. diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx index 71ab40e..3d08219 100644 --- a/docs/guides/troubleshooting.mdx +++ b/docs/guides/troubleshooting.mdx @@ -145,7 +145,7 @@ sh install-kagi.sh **Symptoms:** ``` -Config error: missing credentials: set KAGI_API_TOKEN or KAGI_SESSION_TOKEN (env), or add [auth] api_token/session_token to .kagi.toml +Config error: missing credentials: set KAGI_API_KEY or KAGI_SESSION_TOKEN (env), or add [auth] api_key/session_token to .kagi.toml ``` **Diagnostic:** @@ -336,7 +336,7 @@ kagi search "python" # instead of "python 3.12.1 specific error on macOS" **2. Rate limiting:** - Wait a few minutes - Reduce request frequency -- Check if using both tokens (fallback can help) +- Check if both session token and API key are configured (fallback can help) **3. Network issues:** ```bash @@ -749,8 +749,9 @@ If you've tried the solutions above and still have issues: ### Token Confusion - **Session Token**: For subscriber features (lens, assistant) -- **API Token**: For paid API (fastgpt, summarize, enrich) -- Both can exist, different commands need different ones +- **API Key**: For current paid API commands (search, extract) +- **Legacy API Token**: For older paid API commands (fastgpt, summarize, enrich) +- All three can exist, different commands need different ones ### Bare `kagi auth` Fails in a Script diff --git a/docs/index.mdx b/docs/index.mdx index 7d29786..d9eb720 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -65,18 +65,20 @@ The `kagi search` command provides multiple pathways depending on your authentic - **Base Search**: Standard Kagi search with high-quality results - **Snap-Prefixed Search**: Reuse your Kagi snap shortcuts from the CLI with `--snap` -- **API Token Path**: Uses the documented Search API when you have API access +- **API Key Path**: Uses the current documented Search API when you have API access - **Session Token Path**: Uses the subscriber web product for enhanced capabilities - **Lens Search**: Scope searches to your personal Kagi lenses (requires session token) - **Automatic Fallback**: Intelligently falls back from API to session path when appropriate ### Authentication Flexibility -Two credential types serve different purposes: +Three credential types serve different purposes: - **KAGI_SESSION_TOKEN**: Unlocks subscriber features including lens search, Assistant, subscriber Summarizer, and Translate text mode. Obtained from your Kagi Session Link URL. -- **KAGI_API_TOKEN**: Enables paid public API commands including Extract, FastGPT, public Summarizer, and enrichment APIs. Requires API credit. +- **KAGI_API_KEY**: Enables current `/api/v1` public API commands including Search and Extract. Requires API access and credit. + +- **KAGI_API_TOKEN**: Enables legacy `/api/v0` public API commands including FastGPT, public Summarizer, and enrichment APIs. Requires API credit. ### Content Processing diff --git a/docs/reference/auth-matrix.mdx b/docs/reference/auth-matrix.mdx index 6c31cd3..f6e337c 100644 --- a/docs/reference/auth-matrix.mdx +++ b/docs/reference/auth-matrix.mdx @@ -18,9 +18,9 @@ This reference provides a complete mapping of which commands require which authe | `auth status` | None | None | Reads config only | | `auth check` | Primary credential | None | Tests selected token | | `auth set` | None | None | Saves credentials | -| `summarize` | `KAGI_API_TOKEN` | None | Paid public API | +| `summarize` | `KAGI_API_TOKEN` | None | Legacy `/api/v0` public API | | `summarize --subscriber` | `KAGI_SESSION_TOKEN` | None | Subscriber web product | -| `extract` | `KAGI_API_TOKEN` | None | Paid public API | +| `extract` | `KAGI_API_KEY` | Session-derived key when API portal allows it | Current `/api/v1` Extract API | | `news` | None | None | Public endpoint | | `quick` | `KAGI_SESSION_TOKEN` | None | Quick Answer web product | | `ask-page` | `KAGI_SESSION_TOKEN` | None | Subscriber feature | @@ -30,9 +30,9 @@ This reference provides a complete mapping of which commands require which authe | `bang custom` | `KAGI_SESSION_TOKEN` | None | Custom bang settings | | `redirect` | `KAGI_SESSION_TOKEN` | None | Redirect rule settings | | `translate` | `KAGI_SESSION_TOKEN` | None | Text mode only; bootstraps `translate_session` over HTTP | -| `fastgpt` | `KAGI_API_TOKEN` | None | Paid public API | -| `enrich web` | `KAGI_API_TOKEN` | None | Paid public API | -| `enrich news` | `KAGI_API_TOKEN` | None | Paid public API | +| `fastgpt` | `KAGI_API_TOKEN` | None | Legacy `/api/v0` public API | +| `enrich web` | `KAGI_API_TOKEN` | None | Legacy `/api/v0` public API | +| `enrich news` | `KAGI_API_TOKEN` | None | Legacy `/api/v0` public API | | `smallweb` | None | None | Public feed | ## Detailed Breakdown @@ -48,12 +48,12 @@ flowchart TD 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?} + CheckSession -->|No| CheckAPI1{KAGI_API_KEY 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?} + Pref -->|Yes| CheckAPI2{KAGI_API_KEY configured?} CheckAPI2 -->|Yes| TryAPI2[Try Search API] TryAPI2 -->|Success| ReturnResults3[Return results ✓] TryAPI2 -->|Auth error| CheckSession2{KAGI_SESSION_TOKEN configured?} @@ -133,7 +133,7 @@ flowchart TD | Mode | Token | Notes | |------|-------|-------| -| Public API (default) | `KAGI_API_TOKEN` | Uses Universal Summarizer API | +| Public API (default) | `KAGI_API_TOKEN` | Uses legacy Universal Summarizer API | | Subscriber (`--subscriber`) | `KAGI_SESSION_TOKEN` | Uses web product summarizer | **Important:** These are mutually exclusive. You cannot use `--subscriber` with API-only options like `--engine`. @@ -147,8 +147,8 @@ flowchart TD | `assistant` | `KAGI_SESSION_TOKEN` | Conversational AI with threads | | `assistant custom` | `KAGI_SESSION_TOKEN` | Create and manage saved assistants | | `translate` | `KAGI_SESSION_TOKEN` | Kagi Translate text mode | -| `fastgpt` | `KAGI_API_TOKEN` | Quick factual answers | -| `extract` | `KAGI_API_TOKEN` | Full-page markdown extraction | +| `fastgpt` | `KAGI_API_TOKEN` | Quick factual answers through legacy `/api/v0` | +| `extract` | `KAGI_API_KEY` | Full-page markdown extraction through current `/api/v1` | #### Settings Commands @@ -162,7 +162,7 @@ flowchart TD #### Enrichment -Both `enrich web` and `enrich news` require `KAGI_API_TOKEN`: +Both `enrich web` and `enrich news` require legacy `KAGI_API_TOKEN`: - **Teclis** (web) - Enhanced web search - **TinyGem** (news) - Enhanced news search @@ -195,7 +195,14 @@ Requires `KAGI_SESSION_TOKEN`: - ✅ Subscriber Summarizer (`summarize --subscriber`) - ✅ Base search (fallback) -### API Token Features +### API Key Features + +Requires `KAGI_API_KEY`: + +- ✅ Current Search API (`search` when `[auth.preferred_auth] = "api"`) +- ✅ Extract API (`extract`) + +### Legacy API Token Features Requires `KAGI_API_TOKEN`: @@ -203,7 +210,6 @@ Requires `KAGI_API_TOKEN`: - ✅ Public Summarizer (`summarize`) - ✅ Web Enrichment (`enrich web`) - ✅ News Enrichment (`enrich news`) -- ✅ Base search when `[auth.preferred_auth] = "api"` ### No Token Required @@ -218,7 +224,7 @@ Works without authentication: ### Resolution Order ``` -1. Environment Variables (KAGI_API_TOKEN, KAGI_SESSION_TOKEN) +1. Environment Variables (KAGI_API_KEY, KAGI_API_TOKEN, KAGI_SESSION_TOKEN) ↓ (if not set) 2. Configuration File (`./.kagi.toml`) ↓ (if not set) @@ -227,11 +233,12 @@ Works without authentication: ### Example Scenarios -**Scenario 1: Both tokens in file** +**Scenario 1: Multiple credentials in file** ```toml # ./.kagi.toml [auth] api_token = "api123" +api_key = "key123" session_token = "session456" ``` - `search`: Uses the configured base-search preference (session by default) @@ -242,12 +249,12 @@ session_token = "session456" **Scenario 2: Mixed sources** ```bash -export KAGI_API_TOKEN="api789" +export KAGI_API_KEY="key789" # ./.kagi.toml has session_token only ``` -- `search`: Uses env API token (takes precedence) +- `search`: Uses env API key when `[auth.preferred_auth] = "api"` - `summarize --subscriber`: Uses file session token -- `fastgpt`: Uses env API token +- `fastgpt`: Requires `KAGI_API_TOKEN` or `[auth].api_token` **Scenario 3: Environment overrides file** ```bash @@ -288,7 +295,7 @@ kagi auth set --session-token 'https://kagi.com/search?token=...' - ❌ `kagi summarize --url ...` (without --subscriber) - requires API token - ❌ `kagi enrich web` - requires API token -### API Token Only +### Legacy API Token Only **Setup:** ```bash @@ -296,7 +303,6 @@ kagi auth set --api-token 'your_api_token' ``` **Working commands:** -- ✅ `kagi search "query"` (uses API path) - ✅ `kagi summarize --url ...` - ✅ `kagi fastgpt "query"` - ✅ `kagi enrich web "query"` @@ -304,6 +310,7 @@ kagi auth set --api-token 'your_api_token' - ✅ `kagi smallweb` **Non-working:** +- ❌ `kagi search "query"` with `[auth.preferred_auth] = "api"` - requires API key or session token - ❌ `kagi search --lens 2` - requires session token - ❌ `kagi quick` - requires session token - ❌ `kagi search --region us "query"` - requires session token @@ -316,19 +323,20 @@ kagi auth set --api-token 'your_api_token' - ❌ `kagi redirect list` - requires session token - ❌ `kagi summarize --subscriber` - requires session token -### Both Tokens +### Session Token, API Key, and Legacy API Token **Setup:** ```bash -kagi auth set --session-token '...' --api-token '...' +kagi auth set --session-token '...' --api-key '...' --api-token '...' ``` **All commands work:** - ✅ Everything listed above **Smart behavior:** -- `search`: Uses session (default), or API if `[auth.preferred_auth = "api"]` is set -- `summarize` without `--subscriber`: Uses API +- `search`: Uses session (default), or API key if `[auth.preferred_auth = "api"]` is set +- `extract`: Uses API key directly, or session only when Kagi's API portal allows Extract access +- `summarize` without `--subscriber`: Uses legacy API token - `summarize --subscriber`: Uses session - `quick`: Uses session - `ask-page`: Uses session @@ -337,7 +345,7 @@ kagi auth set --session-token '...' --api-token '...' - `lens`: Uses session - `bang custom`: Uses session - `redirect`: Uses session -- `fastgpt`: Uses API +- `fastgpt`: Uses legacy API token ## Troubleshooting Matrix @@ -345,10 +353,11 @@ kagi auth set --session-token '...' --api-token '...' |---------|-------|----------| | "missing credentials" | `kagi auth status` | Set appropriate token | | "requires KAGI_SESSION_TOKEN" | Token type | Use `--subscriber` or set session token | -| "requires KAGI_API_TOKEN" | Token type | Remove `--subscriber` or set API token | +| "requires KAGI_API_KEY" | Token type | Set API key for current Search API or Extract | +| "requires KAGI_API_TOKEN" | Token type | Remove `--subscriber` or set legacy API token | | "auth check failed" | Token validity | Regenerate token in Kagi settings | | `search --lens` fails | Session token | Verify session token configured | -| `fastgpt` fails | API token + credit | Check API credit balance | +| `fastgpt` fails | Legacy API token + credit | Check API credit balance | ## Security Considerations @@ -363,7 +372,8 @@ kagi auth set --session-token '...' --api-token '...' ### Scope of Access - **Session Token:** Full subscriber access (search, assistant, summarizer) -- **API Token:** API-only access (fastgpt, enrich, public summarizer) +- **API Key:** Current `/api/v1` API-only access (search, extract) +- **Legacy API Token:** Older `/api/v0` API-only access (fastgpt, enrich, public summarizer) **Principle:** Use least-privilege tokens for specific workflows. diff --git a/docs/reference/error-reference.mdx b/docs/reference/error-reference.mdx index 8519180..11af306 100644 --- a/docs/reference/error-reference.mdx +++ b/docs/reference/error-reference.mdx @@ -37,7 +37,7 @@ Error: Network error: request to https://kagi.com/api/v0/search timed out after **Message:** ``` -Config error: missing credentials: set KAGI_API_TOKEN or KAGI_SESSION_TOKEN (env), or add [auth] api_token/session_token to .kagi.toml +Config error: missing credentials: set KAGI_API_KEY or KAGI_SESSION_TOKEN (env), or add [auth] api_key/session_token to .kagi.toml ``` **Meaning:** No authentication token is configured. @@ -61,7 +61,7 @@ kagi auth check Config error: this command requires KAGI_SESSION_TOKEN (env or .kagi.toml [auth.session_token]) ``` -**Meaning:** Command needs session token, but you may have only API token configured. +**Meaning:** Command needs a session token, but you may have only an API key or legacy API token configured. **Solution:** ```bash diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index 0ca0636..3c29bdb 100644 --- a/docs/reference/output-contract.mdx +++ b/docs/reference/output-contract.mdx @@ -431,7 +431,7 @@ Rust 1.86 added... Errors are plain text on stderr. Typical examples: ```text -Config error: missing credentials: set KAGI_API_TOKEN or KAGI_SESSION_TOKEN (env), or add [auth] api_token/session_token to .kagi.toml +Config error: missing credentials: set KAGI_API_KEY or KAGI_SESSION_TOKEN (env), or add [auth] api_key/session_token to .kagi.toml Auth error: invalid or expired Kagi session token Network error: request to Kagi timed out ``` diff --git a/src/api.rs b/src/api.rs index 6d6389b..a139b2c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,7 +6,7 @@ use std::time::Duration; use reqwest::multipart; use reqwest::{Client, StatusCode, Url, header}; -use scraper::Html; +use scraper::{Html, Selector}; use serde::Deserialize; #[cfg(test)] use serde::Serialize; @@ -19,6 +19,7 @@ use tracing::debug; use crate::cli::{NewsFilterMode, NewsFilterScope}; use crate::error::KagiError; use crate::http::{self, map_transport_error}; +use crate::local; use crate::parser::{ parse_assistant_profile_form, parse_assistant_profile_list, parse_assistant_thread_list, parse_custom_bang_form, parse_custom_bang_list, parse_lens_form, parse_lens_list, @@ -50,6 +51,7 @@ use crate::types::{ const KAGI_SUMMARIZE_PATH: &str = "/api/v0/summarize"; const KAGI_EXTRACT_PATH: &str = "/api/v1/extract"; +const KAGI_API_PORTAL_PATH: &str = "/api"; const KAGI_SUBSCRIBER_SUMMARIZE_PATH: &str = "/mother/summary_labs"; const KAGI_NEWS_LATEST_PATH: &str = "/api/batches/latest"; const KAGI_NEWS_CATEGORIES_METADATA_PATH: &str = "/api/categories/metadata"; @@ -175,7 +177,7 @@ pub async fn execute_extract(url: &str, token: &str) -> Result Result Result { + if token.trim().is_empty() { + return Err(KagiError::Auth( + "missing Kagi session token (expected KAGI_SESSION_TOKEN)".to_string(), + )); + } + + if let Some(api_token) = local::session_api_token_get(token)? { + match execute_extract(url, &api_token).await { + Ok(markdown) => return Ok(markdown), + Err(error @ KagiError::Auth(_)) => { + local::session_api_token_remove(token)?; + debug!(error = %error, "cached session-derived Kagi API token was rejected"); + } + Err(error) => return Err(error), + } + } + + let api_token = resolve_api_token_from_session(token).await?; + local::session_api_token_put(token, &api_token)?; + execute_extract(url, &api_token).await.map_err(|error| { + if matches!(error, KagiError::Auth(_)) { + let _ = local::session_api_token_remove(token); + return KagiError::Auth(format!( + "session-derived Kagi API token was minted, but Extract rejected it. The account may not have Extract API access enabled: {error}" + )); + } + error + }) +} + +async fn resolve_api_token_from_session(token: &str) -> Result { + if token.trim().is_empty() { + return Err(KagiError::Auth( + "missing Kagi session token (expected KAGI_SESSION_TOKEN)".to_string(), + )); + } + + match fetch_api_portal_token(token, false).await? { + Some(api_token) => Ok(api_token), + None => { + verify_new_api_portal_allows_extract(token).await?; + fetch_api_portal_token(token, true).await?.ok_or_else(|| { + KagiError::Parse("Kagi API portal did not return a generated API token".to_string()) + }) + } + } +} + +async fn verify_new_api_portal_allows_extract(token: &str) -> Result<(), KagiError> { + let client = build_client()?; + let response = client + .get(http::kagi_url(KAGI_API_PORTAL_PATH)) + .header(header::COOKIE, format!("kagi_session={token}")) + .header(header::ACCEPT, "text/html,application/xhtml+xml") + .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 Kagi API portal response: {error}")) + })?; + if body.contains("API access is restricted for family members") { + return Err(KagiError::Auth( + "Kagi API access is restricted for this session because it belongs to a family member account; Extract requires an API key from the family administrator or an account with API access" + .to_string(), + )); + } + Ok(()) + } + status @ (StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) => { + let body = http::read_error_body(response, "Kagi API portal").await; + Err(KagiError::Auth(format!( + "invalid or expired Kagi session token for API portal: HTTP {status}{}", + format_client_error_suffix(&body) + ))) + } + status => { + let body = http::read_error_body(response, "Kagi API portal").await; + Err(KagiError::Network(format!( + "unexpected Kagi API portal response status: HTTP {status}{}", + format_client_error_suffix(&body) + ))) + } + } +} + +async fn fetch_api_portal_token(token: &str, generate: bool) -> Result, KagiError> { + let client = build_client()?; + let path = if generate { + "/settings/api?generate=1" + } else { + "/settings/api" + }; + let response = client + .get(http::kagi_url(path)) + .header(header::COOKIE, format!("kagi_session={token}")) + .header(header::ACCEPT, "text/html,application/xhtml+xml") + .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 Kagi API portal response: {error}")) + })?; + parse_api_portal_token(&body) + } + status @ (StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) => { + let body = http::read_error_body(response, "Kagi API portal").await; + Err(KagiError::Auth(format!( + "invalid or expired Kagi session token for API portal: HTTP {status}{}", + format_client_error_suffix(&body) + ))) + } + status => { + let body = http::read_error_body(response, "Kagi API portal").await; + Err(KagiError::Network(format!( + "unexpected Kagi API portal response status: HTTP {status}{}", + format_client_error_suffix(&body) + ))) + } + } +} + +fn parse_api_portal_token(body: &str) -> Result, KagiError> { + let document = Html::parse_document(body); + let selector = + Selector::parse("input.copyToClipText, input#team_invite_link").map_err(|error| { + KagiError::Parse(format!("failed to build API token selector: {error}")) + })?; + + if let Some(token) = document + .select(&selector) + .filter_map(|element| element.value().attr("value")) + .map(str::trim) + .find(|value| value.len() >= 32 && value.chars().all(is_api_token_char)) + .map(str::to_string) + { + return Ok(Some(token)); + } + + if body.contains("API access is restricted for family members") { + return Err(KagiError::Auth( + "Kagi API portal rejected this session because API access is restricted for family members" + .to_string(), + )); + } + + Ok(None) +} + +const fn is_api_token_char(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '+' | '/' | '=') +} + /// Summarizes a URL or text using the subscriber web Summarizer with session-token auth. /// /// # Arguments diff --git a/src/auth.rs b/src/auth.rs index 99b6f4b..c0d2b8d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,6 @@ //! Authentication and session management for the Kagi API. //! -//! Handles loading API tokens from environment variables, config files, +//! Handles loading API keys, legacy API tokens, and session tokens from environment variables, config files, //! and the interactive authentication wizard. Provides session persistence //! via the filesystem. @@ -16,12 +16,14 @@ use serde::Deserialize; use crate::error::KagiError; const DEFAULT_CONFIG_PATH: &str = ".kagi.toml"; +pub const API_KEY_ENV: &str = "KAGI_API_KEY"; pub const API_TOKEN_ENV: &str = "KAGI_API_TOKEN"; pub const SESSION_TOKEN_ENV: &str = "KAGI_SESSION_TOKEN"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] /// The type of authentication credential. pub enum CredentialKind { + ApiKey, ApiToken, SessionToken, } @@ -30,9 +32,10 @@ impl CredentialKind { /// Returns the string representation of this credential kind. /// /// # Returns - /// `"api-token"` or `"session-token"`. + /// `"api-key"`, `"api-token"`, or `"session-token"`. pub const fn as_str(self) -> &'static str { match self { + Self::ApiKey => "api-key", Self::ApiToken => "api-token", Self::SessionToken => "session-token", } @@ -125,6 +128,7 @@ pub enum SearchAuthRequirement { #[derive(Debug, Clone)] /// All available credentials and preferences loaded from config and environment. pub struct CredentialInventory { + pub api_key: Option, pub api_token: Option, pub session_token: Option, pub search_preference: SearchAuthPreference, @@ -176,21 +180,21 @@ impl CredentialInventory { if let Some(session_token) = self.session_token.clone() { return Ok(SearchCredentials { primary: session_token, - fallback_session: self.api_token.clone(), + fallback_session: self.api_key.clone(), }); } - if let Some(api_token) = self.api_token.clone() { + if let Some(api_key) = self.api_key.clone() { return Ok(SearchCredentials { - primary: api_token, + primary: api_key, fallback_session: None, }); } } SearchAuthPreference::Api => { - if let Some(api_token) = self.api_token.clone() { + if let Some(api_key) = self.api_key.clone() { return Ok(SearchCredentials { - primary: api_token, + primary: api_key, fallback_session: self.session_token.clone(), }); } @@ -205,7 +209,7 @@ impl CredentialInventory { } Err(KagiError::Config( - "missing credentials: set KAGI_API_TOKEN or KAGI_SESSION_TOKEN (env), or add [auth] api_token/session_token to .kagi.toml".to_string(), + "missing credentials: set KAGI_API_KEY or KAGI_SESSION_TOKEN (env), or add [auth] api_key/session_token to .kagi.toml".to_string(), )) } @@ -215,10 +219,16 @@ impl CredentialInventory { /// The preferred credential, or `None` if no credentials are configured. pub fn preferred_for_status(&self) -> Option<&Credential> { match self.search_preference { - SearchAuthPreference::Session => { - self.session_token.as_ref().or(self.api_token.as_ref()) - } - SearchAuthPreference::Api => self.api_token.as_ref().or(self.session_token.as_ref()), + SearchAuthPreference::Session => self + .session_token + .as_ref() + .or(self.api_key.as_ref()) + .or(self.api_token.as_ref()), + SearchAuthPreference::Api => self + .api_key + .as_ref() + .or(self.session_token.as_ref()) + .or(self.api_token.as_ref()), } } } @@ -231,6 +241,7 @@ struct ConfigFile { #[derive(Debug, Default, Deserialize, serde::Serialize)] struct AuthConfig { + api_key: Option, api_token: Option, session_token: Option, preferred_auth: Option, @@ -245,6 +256,7 @@ struct ProfileConfig { /// Snapshot of the current authentication configuration for display purposes. pub struct ConfigAuthSnapshot { pub config_path: PathBuf, + pub api_key: Option, pub api_token: Option, pub session_token: Option, pub search_preference: SearchAuthPreference, @@ -253,7 +265,7 @@ pub struct ConfigAuthSnapshot { /// Loads the credential inventory from the default config path and environment variables. /// /// # Returns -/// A `CredentialInventory` with resolved API token, session token, and preferences. +/// A `CredentialInventory` with resolved API key, legacy API token, session token, and preferences. /// /// # Errors /// Returns `KagiError::Config` if the config file cannot be read or parsed, @@ -286,7 +298,12 @@ fn load_credential_inventory_from_path( .transpose()? .unwrap_or(SearchAuthPreference::Session); - let env_api = read_env_credential(API_TOKEN_ENV).map(|value| Credential { + let env_api_key = read_env_credential(API_KEY_ENV).map(|value| Credential { + kind: CredentialKind::ApiKey, + source: CredentialSource::Env, + value, + }); + let env_api_token = read_env_credential(API_TOKEN_ENV).map(|value| Credential { kind: CredentialKind::ApiToken, source: CredentialSource::Env, value, @@ -295,7 +312,17 @@ fn load_credential_inventory_from_path( .map(|value| build_session_credential(&value, CredentialSource::Env)) .transpose()?; - let config_api = auth_config + let config_api_key = auth_config + .and_then(|auth| auth.api_key.as_ref()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| Credential { + kind: CredentialKind::ApiKey, + source: CredentialSource::Config, + value, + }); + + let config_api_token = auth_config .and_then(|auth| auth.api_token.as_ref()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -313,7 +340,8 @@ fn load_credential_inventory_from_path( .transpose()?; Ok(CredentialInventory { - api_token: env_api.or(config_api), + api_key: env_api_key.or(config_api_key), + api_token: env_api_token.or(config_api_token), session_token: env_session.or(config_session), search_preference, config_path: config_path.to_path_buf(), @@ -340,11 +368,12 @@ pub fn format_status(inventory: &CredentialInventory) -> String { "selected: none".to_string() }; - let api_line = format_status_line("api token", inventory.api_token.as_ref()); + let api_key_line = format_status_line("api key", inventory.api_key.as_ref()); + let api_token_line = format_status_line("legacy api token", inventory.api_token.as_ref()); let session_line = format_status_line("session token", inventory.session_token.as_ref()); format!( - "{selected_line}\nprofile: {}\npreferred auth for base search: {}\n{api_line}\n{session_line}\nconfig path: {}\nprecedence: env > selected profile config > default config; base search defaults to session unless preferred_auth = \"api\"; lens search requires session token", + "{selected_line}\nprofile: {}\npreferred auth for base search: {}\n{api_key_line}\n{api_token_line}\n{session_line}\nconfig path: {}\nprecedence: env > selected profile config > default config; base search defaults to session unless preferred_auth = \"api\"; lens search requires session token", inventory.profile.as_deref().unwrap_or("default"), inventory.search_preference.as_str(), inventory.config_path.display(), @@ -374,6 +403,14 @@ fn load_config_auth_snapshot_from_path( .transpose()? .unwrap_or(SearchAuthPreference::Session); + let api_key = config + .auth + .as_ref() + .and_then(|auth| auth.api_key.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let api_token = config .auth .as_ref() @@ -391,6 +428,7 @@ fn load_config_auth_snapshot_from_path( Ok(ConfigAuthSnapshot { config_path: config_path.to_path_buf(), + api_key, api_token, session_token, search_preference, @@ -456,7 +494,26 @@ fn build_session_credential( }) } -/// Normalizes and validates an API token string. +/// Normalizes and validates an API key string. +/// +/// # Arguments +/// * `input` - The raw API key input. +/// +/// # Returns +/// The trimmed API key string. +/// +/// # Errors +/// Returns `KagiError::Config` if the key is empty after trimming. +pub fn normalize_api_key(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(KagiError::Config("api key cannot be empty".to_string())); + } + + Ok(trimmed.to_string()) +} + +/// Normalizes and validates a legacy API token string. /// /// # Arguments /// * `input` - The raw API token input. @@ -478,7 +535,8 @@ pub fn normalize_api_token(input: &str) -> Result { /// Saves API and/or session credentials to the selected profile or default config block. /// /// # Arguments -/// * `api_token` - Optional API token to save. +/// * `api_key` - Optional API key to save. +/// * `api_token` - Optional legacy API token to save. /// * `session_input` - Optional session token or session link URL to save. /// /// # Returns @@ -488,16 +546,18 @@ pub fn normalize_api_token(input: &str) -> Result { /// Returns `KagiError::Config` if neither credential is provided, or on I/O or serialization errors. pub fn save_credentials_for_profile( profile: Option<&str>, + api_key: Option<&str>, api_token: Option<&str>, session_input: Option<&str>, ) -> Result { - save_credentials_with_preference_for_profile(profile, api_token, session_input, None) + save_credentials_with_preference_for_profile(profile, api_key, api_token, session_input, None) } /// Saves credentials with an optional search auth preference to the default config file. /// /// # Arguments -/// * `api_token` - Optional API token to save. +/// * `api_key` - Optional API key to save. +/// * `api_token` - Optional legacy API token to save. /// * `session_input` - Optional session token or session link URL to save. /// * `preferred_auth` - Optional search auth preference to set. /// @@ -507,15 +567,23 @@ pub fn save_credentials_for_profile( /// # Errors /// Returns `KagiError::Config` if neither credential is provided, or on I/O or serialization errors. pub fn save_credentials_with_preference( + api_key: Option<&str>, api_token: Option<&str>, session_input: Option<&str>, preferred_auth: Option, ) -> Result { - save_credentials_with_preference_for_profile(None, api_token, session_input, preferred_auth) + save_credentials_with_preference_for_profile( + None, + api_key, + api_token, + session_input, + preferred_auth, + ) } fn save_credentials_with_preference_for_profile( profile: Option<&str>, + api_key: Option<&str>, api_token: Option<&str>, session_input: Option<&str>, preferred_auth: Option, @@ -523,6 +591,7 @@ fn save_credentials_with_preference_for_profile( save_credentials_with_preference_to_path( Path::new(DEFAULT_CONFIG_PATH), profile, + api_key, api_token, session_input, preferred_auth, @@ -532,13 +601,15 @@ fn save_credentials_with_preference_for_profile( fn save_credentials_with_preference_to_path( config_path: &Path, profile: Option<&str>, + api_key: Option<&str>, api_token: Option<&str>, session_input: Option<&str>, preferred_auth: Option, ) -> Result { - if api_token.is_none() && session_input.is_none() { + if api_key.is_none() && api_token.is_none() && session_input.is_none() { return Err(KagiError::Config( - "nothing to save: provide --api-token, --session-token, or both".to_string(), + "nothing to save: provide --api-key, --api-token, --session-token, or a combination" + .to_string(), )); } @@ -556,6 +627,10 @@ fn save_credentials_with_preference_to_path( config.auth.get_or_insert_with(AuthConfig::default) }; + if let Some(api_key) = api_key { + auth.api_key = Some(normalize_api_key(api_key)?); + } + if let Some(api_token) = api_token { auth.api_token = Some(normalize_api_token(api_token)?); } @@ -744,16 +819,34 @@ mod tests { let path = temp_config_file(); fs::write( path.path(), - "[auth]\napi_token = \"config-api\"\nsession_token = \"config-session\"\n", + "[auth]\napi_key = \"config-key\"\napi_token = \"config-token\"\nsession_token = \"config-session\"\n", ) .expect("write config"); - let _api_env = set_env_var(API_TOKEN_ENV, "env-api"); + let _api_key_env = set_env_var(API_KEY_ENV, "env-key"); + let _api_token_env = set_env_var(API_TOKEN_ENV, "env-token"); let _session_env = set_env_var(SESSION_TOKEN_ENV, "env-session"); let config = read_config_file(path.path()).expect("config parses"); let inventory = CredentialInventory { + api_key: read_env_credential(API_KEY_ENV) + .map(|value| Credential { + kind: CredentialKind::ApiKey, + source: CredentialSource::Env, + value, + }) + .or_else(|| { + config + .auth + .as_ref() + .and_then(|auth| auth.api_key.as_ref()) + .map(|value| Credential { + kind: CredentialKind::ApiKey, + source: CredentialSource::Config, + value: value.clone(), + }) + }), api_token: read_env_credential(API_TOKEN_ENV) .map(|value| Credential { kind: CredentialKind::ApiToken, @@ -793,6 +886,7 @@ mod tests { profile: None, }; + assert_eq!(inventory.api_key.unwrap().source, CredentialSource::Env); assert_eq!(inventory.api_token.unwrap().source, CredentialSource::Env); assert_eq!( inventory.session_token.unwrap().source, @@ -806,14 +900,21 @@ mod tests { assert!(error.to_string().contains("api token cannot be empty")); } + #[test] + fn rejects_empty_api_key_input() { + let error = normalize_api_key(" ").expect_err("empty api key should fail"); + assert!(error.to_string().contains("api key cannot be empty")); + } + #[test] fn requires_session_for_lens_search() { let inventory = CredentialInventory { - api_token: Some(Credential { - kind: CredentialKind::ApiToken, + api_key: Some(Credential { + kind: CredentialKind::ApiKey, source: CredentialSource::Env, - value: "api".to_string(), + value: "key".to_string(), }), + api_token: None, session_token: None, search_preference: SearchAuthPreference::Session, config_path: PathBuf::from(DEFAULT_CONFIG_PATH), @@ -830,11 +931,12 @@ mod tests { #[test] fn requires_session_for_filtered_search() { let inventory = CredentialInventory { - api_token: Some(Credential { - kind: CredentialKind::ApiToken, + api_key: Some(Credential { + kind: CredentialKind::ApiKey, source: CredentialSource::Env, - value: "api".to_string(), + value: "key".to_string(), }), + api_token: None, session_token: None, search_preference: SearchAuthPreference::Session, config_path: PathBuf::from(DEFAULT_CONFIG_PATH), @@ -849,13 +951,14 @@ mod tests { } #[test] - fn base_search_keeps_api_token_as_fallback_when_session_is_preferred() { + fn base_search_keeps_api_key_as_fallback_when_session_is_preferred() { let inventory = CredentialInventory { - api_token: Some(Credential { - kind: CredentialKind::ApiToken, + api_key: Some(Credential { + kind: CredentialKind::ApiKey, source: CredentialSource::Env, - value: "api".to_string(), + value: "key".to_string(), }), + api_token: None, session_token: Some(Credential { kind: CredentialKind::SessionToken, source: CredentialSource::Env, @@ -875,18 +978,19 @@ mod tests { .fallback_session .expect("api fallback exists") .kind, - CredentialKind::ApiToken + CredentialKind::ApiKey ); } #[test] fn prefers_session_for_base_search_by_default() { let inventory = CredentialInventory { - api_token: Some(Credential { - kind: CredentialKind::ApiToken, + api_key: Some(Credential { + kind: CredentialKind::ApiKey, source: CredentialSource::Env, - value: "api".to_string(), + value: "key".to_string(), }), + api_token: None, session_token: Some(Credential { kind: CredentialKind::SessionToken, source: CredentialSource::Env, @@ -906,11 +1010,12 @@ mod tests { #[test] fn prefers_api_for_base_search_when_configured() { let inventory = CredentialInventory { - api_token: Some(Credential { - kind: CredentialKind::ApiToken, + api_key: Some(Credential { + kind: CredentialKind::ApiKey, source: CredentialSource::Env, - value: "api".to_string(), + value: "key".to_string(), }), + api_token: None, session_token: Some(Credential { kind: CredentialKind::SessionToken, source: CredentialSource::Env, @@ -924,7 +1029,29 @@ mod tests { let credentials = inventory .resolve_for_search(SearchAuthRequirement::Base) .expect("base search resolves credential"); - assert_eq!(credentials.primary.kind, CredentialKind::ApiToken); + assert_eq!(credentials.primary.kind, CredentialKind::ApiKey); + } + + #[test] + fn legacy_api_token_does_not_satisfy_base_search() { + let inventory = CredentialInventory { + api_key: None, + api_token: Some(Credential { + kind: CredentialKind::ApiToken, + source: CredentialSource::Env, + value: "legacy-token".to_string(), + }), + session_token: None, + search_preference: SearchAuthPreference::Api, + config_path: PathBuf::from(DEFAULT_CONFIG_PATH), + profile: None, + }; + + let error = inventory + .resolve_for_search(SearchAuthRequirement::Base) + .expect_err("legacy token should not satisfy base search"); + + assert!(error.to_string().contains("KAGI_API_KEY")); } #[test] @@ -948,10 +1075,15 @@ mod tests { #[test] fn status_output_redacts_values() { let inventory = CredentialInventory { + api_key: Some(Credential { + kind: CredentialKind::ApiKey, + source: CredentialSource::Env, + value: "secret-key".to_string(), + }), api_token: Some(Credential { kind: CredentialKind::ApiToken, source: CredentialSource::Env, - value: "secret-api".to_string(), + value: "secret-token".to_string(), }), session_token: None, search_preference: SearchAuthPreference::Session, @@ -960,9 +1092,12 @@ mod tests { }; let status = format_status(&inventory); - assert!(status.contains("selected: api-token (env)")); + assert!(status.contains("selected: api-key (env)")); + assert!(status.contains("api key: configured via env")); + assert!(status.contains("legacy api token: configured via env")); assert!(status.contains("preferred auth for base search: session")); - assert!(!status.contains("secret-api")); + assert!(!status.contains("secret-key")); + assert!(!status.contains("secret-token")); } #[test] @@ -1055,7 +1190,7 @@ mod tests { let path = temp_config_file(); fs::write( path.path(), - "[auth]\napi_token = \"existing-api\"\nsession_token = \"existing-session\"\npreferred_auth = \"api\"\n", + "[auth]\napi_key = \"existing-key\"\napi_token = \"existing-token\"\nsession_token = \"existing-session\"\npreferred_auth = \"api\"\n", ) .expect("write config"); @@ -1063,6 +1198,7 @@ mod tests { path.path(), None, None, + None, Some("https://kagi.com/search?token=new-session"), None, ) @@ -1070,7 +1206,8 @@ mod tests { let snapshot = load_config_auth_snapshot_from_path(path.path()).expect("config snapshot should load"); - assert_eq!(snapshot.api_token.as_deref(), Some("existing-api")); + assert_eq!(snapshot.api_key.as_deref(), Some("existing-key")); + assert_eq!(snapshot.api_token.as_deref(), Some("existing-token")); assert_eq!(snapshot.session_token.as_deref(), Some("new-session")); assert_eq!(snapshot.search_preference, SearchAuthPreference::Api); } @@ -1082,7 +1219,8 @@ mod tests { let inventory = save_credentials_with_preference_to_path( path.path(), None, - Some("new-api"), + Some("new-key"), + Some("new-token"), Some("https://kagi.com/search?token=new-session"), Some(SearchAuthPreference::Api), ) diff --git a/src/auth_wizard.rs b/src/auth_wizard.rs index 0199b60..cd3d856 100644 --- a/src/auth_wizard.rs +++ b/src/auth_wizard.rs @@ -6,13 +6,16 @@ use std::io::Write; use cliclack::{Theme, ThemeState, log}; use console::{Style, Term, style}; +use crate::api; use crate::auth::{ - API_TOKEN_ENV, ConfigAuthSnapshot, Credential, CredentialKind, CredentialSource, + API_KEY_ENV, API_TOKEN_ENV, ConfigAuthSnapshot, Credential, CredentialKind, CredentialSource, SESSION_TOKEN_ENV, SearchAuthPreference, load_config_auth_snapshot, load_credential_inventory, - normalize_api_token, normalize_session_token, save_credentials_with_preference, + normalize_api_key, normalize_api_token, normalize_session_token, + save_credentials_with_preference, }; use crate::error::KagiError; use crate::search; +use crate::types::FastGptRequest; const VALIDATION_QUERY: &str = "rust lang"; const GOLD: u8 = 220; @@ -126,10 +129,15 @@ pub async fn run_auth_wizard() -> Result<(), KagiError> { "Session Link", "Search, Quick Answer, Assistant, Translate, subscriber Summarizer", ) + .item( + CredentialKind::ApiKey, + "API Key", + "Current Search API and Extract API", + ) .item( CredentialKind::ApiToken, - "API Token", - "FastGPT, enrich, public Summarizer, and API-first base search", + "Legacy API Token", + "Legacy FastGPT, enrich, and public Summarizer APIs", ) .interact(), )? @@ -194,7 +202,7 @@ pub async fn run_auth_wizard() -> Result<(), KagiError> { ))?; let Some(save_anyway) = prompt_result( cliclack::confirm(format!("Save this {} anyway?", kind_display(kind))) - .initial_value(kind == CredentialKind::ApiToken) + .initial_value(kind != CredentialKind::SessionToken) .interact(), )? else { @@ -213,7 +221,7 @@ pub async fn run_auth_wizard() -> Result<(), KagiError> { let preferred_auth = if should_prompt_preference(&config_snapshot, kind) { let Some(use_selected_method) = prompt_result( cliclack::confirm(format!( - "Both auth methods are configured. Make {} the preferred path for base search?", + "Both base-search auth methods are configured. Make {} the preferred path for base search?", kind_display(kind) )) .initial_value(true) @@ -224,7 +232,7 @@ pub async fn run_auth_wizard() -> Result<(), KagiError> { }; if use_selected_method { - Some(preference_for_kind(kind)) + preference_for_kind(kind) } else { None } @@ -233,11 +241,14 @@ pub async fn run_auth_wizard() -> Result<(), KagiError> { }; let saved_inventory = match kind { + CredentialKind::ApiKey => { + save_credentials_with_preference(Some(&credential.value), None, None, preferred_auth)? + } CredentialKind::ApiToken => { - save_credentials_with_preference(Some(&credential.value), None, preferred_auth)? + save_credentials_with_preference(None, Some(&credential.value), None, preferred_auth)? } CredentialKind::SessionToken => { - save_credentials_with_preference(None, Some(&credential.value), preferred_auth)? + save_credentials_with_preference(None, None, Some(&credential.value), preferred_auth)? } }; @@ -304,7 +315,7 @@ fn auth_ascii_width() -> u16 { .min(u16::MAX as usize) as u16 } -/// Validates a credential by executing a test search against the Kagi API. +/// Validates a credential against the Kagi surface that uses that credential type. /// /// # Arguments /// * `credential` - The credential to validate. @@ -317,9 +328,12 @@ fn auth_ascii_width() -> u16 { pub async fn validate_credential(credential: &Credential) -> Result<(), KagiError> { let request = search::SearchRequest::new(VALIDATION_QUERY.to_string()); match credential.kind { - CredentialKind::ApiToken => { + CredentialKind::ApiKey => { search::execute_api_search(&request, &credential.value).await?; } + CredentialKind::ApiToken => { + validate_legacy_api_token(&credential.value).await?; + } CredentialKind::SessionToken => { search::execute_search(&request, &credential.value).await?; } @@ -328,6 +342,17 @@ pub async fn validate_credential(credential: &Credential) -> Result<(), KagiErro Ok(()) } +async fn validate_legacy_api_token(token: &str) -> Result<(), KagiError> { + let request = FastGptRequest { + query: "2+2".to_string(), + cache: Some(true), + web_search: Some(false), + }; + + api::execute_fastgpt(&request, token).await?; + Ok(()) +} + fn prompt_result(result: io::Result) -> Result, KagiError> { match result { Ok(value) => Ok(Some(value)), @@ -349,6 +374,7 @@ fn wizard_io(result: io::Result) -> Result { fn build_candidate_credential(kind: CredentialKind, input: &str) -> Result { let value = match kind { + CredentialKind::ApiKey => normalize_api_key(input)?, CredentialKind::ApiToken => normalize_api_token(input)?, CredentialKind::SessionToken => normalize_session_token(input)?, }; @@ -362,6 +388,7 @@ fn build_candidate_credential(kind: CredentialKind, input: &str) -> Result &'static str { match kind { + CredentialKind::ApiKey => "API key", CredentialKind::ApiToken => "API token", CredentialKind::SessionToken => "Session Link", } @@ -398,14 +425,15 @@ fn format_inventory_summary(inventory: &crate::auth::CredentialInventory) -> Str ), ), wizard_status_line("Base Search", inventory.search_preference.as_str()), - wizard_status_line( - "Session Link", - &inventory_value_line(inventory.session_token.as_ref()), - ), + wizard_status_line("API Key", &inventory_value_line(inventory.api_key.as_ref())), wizard_status_line( "API Token", &inventory_value_line(inventory.api_token.as_ref()), ), + wizard_status_line( + "Session Link", + &inventory_value_line(inventory.session_token.as_ref()), + ), wizard_status_line("Config File", &inventory.config_path.display().to_string()), ] .join("\n") @@ -427,20 +455,22 @@ fn format_saved_summary(inventory: &crate::auth::CredentialInventory) -> String ), ), wizard_status_line("Base Search", inventory.search_preference.as_str()), - wizard_status_line( - "Session Link", - &inventory_value_line(inventory.session_token.as_ref()), - ), + wizard_status_line("API Key", &inventory_value_line(inventory.api_key.as_ref())), wizard_status_line( "API Token", &inventory_value_line(inventory.api_token.as_ref()), ), + wizard_status_line( + "Session Link", + &inventory_value_line(inventory.session_token.as_ref()), + ), ] .join("\n") } const fn method_title(kind: CredentialKind) -> &'static str { match kind { + CredentialKind::ApiKey => "API Key Setup", CredentialKind::ApiToken => "API Token Setup", CredentialKind::SessionToken => "Session Link Setup", } @@ -448,6 +478,7 @@ const fn method_title(kind: CredentialKind) -> &'static str { const fn method_prompt(kind: CredentialKind) -> &'static str { match kind { + CredentialKind::ApiKey => "Paste your API key", CredentialKind::ApiToken => "Paste your API token", CredentialKind::SessionToken => "Paste your Session Link or raw session token", } @@ -455,9 +486,15 @@ const fn method_prompt(kind: CredentialKind) -> &'static str { fn method_instructions(kind: CredentialKind) -> String { match kind { + CredentialKind::ApiKey => [ + "Open: https://kagi.com/api/keys", + "Then copy your API key and paste it here.", + "", + ] + .join("\n"), CredentialKind::ApiToken => [ "Open: https://kagi.com/settings/api", - "Then copy your API token and paste it here.", + "Then copy your legacy API token and paste it here.", "", ] .join("\n"), @@ -474,9 +511,26 @@ fn validation_warning(kind: CredentialKind, error: &KagiError) -> String { let mut message = format!("Validation Error:\n{error}"); match kind { + CredentialKind::ApiKey => { + message.push_str( + "\n\nAPI key validation uses Kagi's current Search API path. Verify the key at https://kagi.com/api/keys and confirm your account has API access enabled.", + ); + if error.to_string().contains("401") { + message.push_str( + "\nKagi rejected the key as unauthorized. Generate a fresh API key and paste the complete value.", + ); + } else if error.to_string().contains("403") { + message.push_str( + "\nKagi returned forbidden. The key may be valid, but this account may not have Search API access.", + ); + } + message.push_str( + "\nYou may still save the key and test Extract directly if that endpoint is available to your account.", + ); + } CredentialKind::ApiToken => { message.push_str( - "\n\nAPI token validation uses Kagi's Search API path. Verify the token at https://kagi.com/settings/api and confirm your account has API access enabled.", + "\n\nLegacy API token validation uses Kagi's legacy FastGPT API path. Verify the token at https://kagi.com/settings/api if you still use legacy /api/v0 endpoints.", ); if error.to_string().contains("401") { message.push_str( @@ -484,11 +538,11 @@ fn validation_warning(kind: CredentialKind, error: &KagiError) -> String { ); } else if error.to_string().contains("403") { message.push_str( - "\nKagi returned forbidden. The token may be valid, but this account may not have Search API access.", + "\nKagi returned forbidden. The token may be valid, but this account may not have FastGPT API access.", ); } message.push_str( - "\nYou may still save the token and test FastGPT or enrich directly if those API endpoints are available to your account.", + "\nYou may still save the token and test summarize, fastgpt, or enrich directly if those legacy endpoints are available to your account.", ); } CredentialKind::SessionToken => { @@ -513,16 +567,22 @@ fn env_override_notice(kind: CredentialKind) -> Option { const fn has_config_credential(snapshot: &ConfigAuthSnapshot, kind: CredentialKind) -> bool { match kind { + CredentialKind::ApiKey => snapshot.api_key.is_some(), CredentialKind::ApiToken => snapshot.api_token.is_some(), CredentialKind::SessionToken => snapshot.session_token.is_some(), } } fn should_prompt_preference(snapshot: &ConfigAuthSnapshot, kind: CredentialKind) -> bool { + if kind == CredentialKind::ApiToken { + return false; + } + should_prompt_preference_with_other_method( snapshot, kind, - other_method_configured(snapshot, kind) || env_credential_present(other_kind(kind)), + other_method_configured(snapshot, kind) + || search_other_kind(kind).is_some_and(env_credential_present), ) } @@ -531,29 +591,34 @@ fn should_prompt_preference_with_other_method( kind: CredentialKind, other_method_configured: bool, ) -> bool { - other_method_configured && snapshot.search_preference != preference_for_kind(kind) + preference_for_kind(kind).is_some_and(|preference| { + other_method_configured && snapshot.search_preference != preference + }) } -const fn preference_for_kind(kind: CredentialKind) -> SearchAuthPreference { +const fn preference_for_kind(kind: CredentialKind) -> Option { match kind { - CredentialKind::ApiToken => SearchAuthPreference::Api, - CredentialKind::SessionToken => SearchAuthPreference::Session, + CredentialKind::ApiKey => Some(SearchAuthPreference::Api), + CredentialKind::ApiToken => None, + CredentialKind::SessionToken => Some(SearchAuthPreference::Session), } } -const fn other_kind(kind: CredentialKind) -> CredentialKind { +const fn search_other_kind(kind: CredentialKind) -> Option { match kind { - CredentialKind::ApiToken => CredentialKind::SessionToken, - CredentialKind::SessionToken => CredentialKind::ApiToken, + CredentialKind::ApiKey => Some(CredentialKind::SessionToken), + CredentialKind::ApiToken => None, + CredentialKind::SessionToken => Some(CredentialKind::ApiKey), } } fn other_method_configured(snapshot: &ConfigAuthSnapshot, kind: CredentialKind) -> bool { - has_config_credential(snapshot, other_kind(kind)) + search_other_kind(kind).is_some_and(|other_kind| has_config_credential(snapshot, other_kind)) } const fn env_var_name(kind: CredentialKind) -> &'static str { match kind { + CredentialKind::ApiKey => API_KEY_ENV, CredentialKind::ApiToken => API_TOKEN_ENV, CredentialKind::SessionToken => SESSION_TOKEN_ENV, } @@ -575,8 +640,13 @@ fn env_override_message(env_var: &str) -> String { fn next_steps(kind: CredentialKind) -> String { match kind { - CredentialKind::ApiToken => [ + CredentialKind::ApiKey => [ "kagi auth check", + "kagi search --format pretty \"rust programming language\"", + "kagi extract \"https://example.com/article\"", + ] + .join("\n"), + CredentialKind::ApiToken => [ "kagi fastgpt \"what changed in rust 1.86?\"", "kagi enrich web \"local-first software\"", ] @@ -595,12 +665,14 @@ mod tests { use super::*; fn snapshot( + api_key: Option<&str>, api_token: Option<&str>, session_token: Option<&str>, search_preference: SearchAuthPreference, ) -> ConfigAuthSnapshot { ConfigAuthSnapshot { config_path: ".kagi.toml".into(), + api_key: api_key.map(str::to_string), api_token: api_token.map(str::to_string), session_token: session_token.map(str::to_string), search_preference, @@ -609,17 +681,22 @@ mod tests { #[test] fn prompts_for_preference_when_both_methods_exist_and_choice_changes_it() { - let config = snapshot(Some("api"), None, SearchAuthPreference::Session); + let config = snapshot(Some("key"), None, None, SearchAuthPreference::Session); assert!(!should_prompt_preference_with_other_method( &config, - CredentialKind::ApiToken, + CredentialKind::ApiKey, false )); - let config = snapshot(Some("api"), Some("session"), SearchAuthPreference::Session); + let config = snapshot( + Some("key"), + None, + Some("session"), + SearchAuthPreference::Session, + ); assert!(should_prompt_preference_with_other_method( &config, - CredentialKind::ApiToken, + CredentialKind::ApiKey, true )); assert!(!should_prompt_preference_with_other_method( @@ -631,10 +708,10 @@ mod tests { #[test] fn prompts_for_preference_when_other_method_exists_via_environment() { - let config = snapshot(None, None, SearchAuthPreference::Session); + let config = snapshot(None, None, None, SearchAuthPreference::Session); assert!(should_prompt_preference_with_other_method( &config, - CredentialKind::ApiToken, + CredentialKind::ApiKey, true )); assert!(!should_prompt_preference_with_other_method( @@ -652,19 +729,37 @@ mod tests { } #[test] - fn builds_api_instructions_with_official_settings_page() { + fn builds_api_key_instructions_with_official_keys_page() { + let instructions = method_instructions(CredentialKind::ApiKey); + assert!(instructions.contains("https://kagi.com/api/keys")); + assert!(instructions.contains("API key")); + } + + #[test] + fn builds_legacy_api_token_instructions_with_legacy_settings_page() { let instructions = method_instructions(CredentialKind::ApiToken); assert!(instructions.contains("https://kagi.com/settings/api")); - assert!(instructions.contains("API token")); + assert!(instructions.contains("legacy API token")); } #[test] - fn api_validation_warning_mentions_search_api_behavior() { + fn api_key_validation_warning_mentions_search_api_behavior() { let warning = validation_warning( - CredentialKind::ApiToken, + CredentialKind::ApiKey, &KagiError::Auth("403 Forbidden".to_string()), ); assert!(warning.contains("Search API")); + assert!(warning.contains("https://kagi.com/api/keys")); + assert!(warning.contains("forbidden")); + } + + #[test] + fn legacy_api_token_validation_warning_mentions_fastgpt_behavior() { + let warning = validation_warning( + CredentialKind::ApiToken, + &KagiError::Auth("403 Forbidden".to_string()), + ); + assert!(warning.contains("FastGPT API")); assert!(warning.contains("https://kagi.com/settings/api")); assert!(warning.contains("forbidden")); } diff --git a/src/cli.rs b/src/cli.rs index e8d7e1c..2e81f45 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -532,7 +532,11 @@ pub enum AuthSubcommand { #[derive(Debug, Args)] /// Arguments for `auth set` (store a credential). pub struct AuthSetArgs { - /// Kagi API token to save into .kagi.toml + /// Kagi API key for current /api/v1 endpoints to save into .kagi.toml + #[arg(long, value_name = "KEY")] + pub api_key: Option, + + /// Legacy Kagi API token for /api/v0 endpoints to save into .kagi.toml #[arg(long, value_name = "TOKEN")] pub api_token: Option, diff --git a/src/local.rs b/src/local.rs index a1c0dd6..c094580 100644 --- a/src/local.rs +++ b/src/local.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; use std::collections::hash_map::DefaultHasher; use std::env; use std::fs; +use std::fs::OpenOptions; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -28,6 +29,13 @@ pub struct CacheEnvelope { pub value: Value, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct SessionApiTokenCache { + session_key: String, + api_token: String, + created_at: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct HistoryEntry { pub timestamp: u64, @@ -127,6 +135,58 @@ pub fn cache_put(key: &str, ttl_seconds: u64, value: &Value) -> Result<(), KagiE write_json(&path, &envelope) } +pub fn session_api_token_get(session_token: &str) -> Result, KagiError> { + let session_key = session_api_token_key(session_token); + let path = session_api_token_path(&session_key); + if !path.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(&path).map_err(|error| { + KagiError::Config(format!( + "failed to read cached Kagi API token {}: {error}", + path.display() + )) + })?; + let cache: SessionApiTokenCache = serde_json::from_str(&raw).map_err(|error| { + KagiError::Parse(format!( + "failed to parse cached Kagi API token {}: {error}", + path.display() + )) + })?; + + if cache.session_key != session_key || cache.api_token.trim().is_empty() { + let _ = fs::remove_file(path); + return Ok(None); + } + + Ok(Some(cache.api_token)) +} + +pub fn session_api_token_put(session_token: &str, api_token: &str) -> Result<(), KagiError> { + let session_key = session_api_token_key(session_token); + let path = session_api_token_path(&session_key); + ensure_parent_dir(&path)?; + let cache = SessionApiTokenCache { + session_key, + api_token: api_token.to_string(), + created_at: now_unix_seconds()?, + }; + write_json_private(&path, &cache) +} + +pub fn session_api_token_remove(session_token: &str) -> Result<(), KagiError> { + let path = session_api_token_path(&session_api_token_key(session_token)); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(KagiError::Config(format!( + "failed to remove cached Kagi API token {}: {error}", + path.display() + ))), + } +} + pub fn append_history(entry: &HistoryEntry) -> Result<(), KagiError> { let path = cache_root().join("history.jsonl"); ensure_parent_dir(&path)?; @@ -251,6 +311,16 @@ fn cache_response_path(key: &str) -> PathBuf { cache_root().join("responses").join(format!("{key}.json")) } +fn session_api_token_key(session_token: &str) -> String { + cache_key(&["session-api-token", session_token.trim()]) +} + +fn session_api_token_path(session_key: &str) -> PathBuf { + cache_root() + .join("auth") + .join(format!("session-api-token-{session_key}.json")) +} + fn site_preferences_path() -> PathBuf { cache_root().join("site-preferences.json") } @@ -261,6 +331,24 @@ fn write_json(path: &Path, value: &T) -> Result<(), KagiError> { .map_err(|error| KagiError::Config(format!("failed to write {}: {error}", path.display()))) } +fn write_json_private(path: &Path, value: &T) -> Result<(), KagiError> { + let raw = serde_json::to_string_pretty(value)?; + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .and_then(|mut file| { + use std::io::Write; + file.write_all(raw.as_bytes()) + }) + .map_err(|error| KagiError::Config(format!("failed to write {}: {error}", path.display()))) +} + fn ensure_parent_dir(path: &Path) -> Result<(), KagiError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { @@ -289,6 +377,27 @@ mod tests { unsafe { env::remove_var(CACHE_DIR_ENV) }; } + #[test] + fn session_api_token_cache_round_trips_values() { + let _guard = lock_env(); + let tempdir = TempDir::new().expect("tempdir"); + unsafe { env::set_var(CACHE_DIR_ENV, tempdir.path()) }; + + session_api_token_put("session-token", "api-token").expect("cache put"); + let value = session_api_token_get("session-token") + .expect("cache get") + .expect("cached token"); + + assert_eq!(value, "api-token"); + session_api_token_remove("session-token").expect("cache remove"); + assert!( + session_api_token_get("session-token") + .expect("cache get") + .is_none() + ); + unsafe { env::remove_var(CACHE_DIR_ENV) }; + } + #[test] fn normalizes_domains() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index e575b08..af16a08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,8 +27,8 @@ use crate::api::{ execute_lens_set_enabled, execute_lens_update, execute_news, execute_news_categories, execute_news_chaos, execute_news_filter_presets, execute_redirect_create, execute_redirect_delete, execute_redirect_get, execute_redirect_list, - execute_redirect_set_enabled, execute_redirect_update, execute_smallweb, - execute_subscriber_summarize, execute_summarize, execute_translate, + execute_redirect_set_enabled, execute_redirect_update, execute_session_extract, + execute_smallweb, execute_subscriber_summarize, execute_summarize, execute_translate, }; use crate::auth::{ Credential, CredentialKind, SearchAuthRequirement, SearchCredentials, format_status, @@ -253,8 +253,8 @@ async fn run() -> Result<(), KagiError> { } } Commands::Extract(args) => { - let token = resolve_api_token(profile.as_deref())?; - let markdown = execute_extract(&args.url, &token).await?; + let markdown = + execute_extract_with_available_auth(&args.url, profile.as_deref()).await?; println!("{markdown}"); Ok(()) } @@ -792,6 +792,7 @@ fn run_auth_status(profile: Option<&str>) -> Result<(), KagiError> { fn run_auth_set(args: AuthSetArgs, profile: Option<&str>) -> Result<(), KagiError> { let inventory = save_credentials_for_profile( profile, + args.api_key.as_deref(), args.api_token.as_deref(), args.session_token.as_deref(), )?; @@ -802,11 +803,16 @@ fn run_auth_set(args: AuthSetArgs, profile: Option<&str>) -> Result<(), KagiErro async fn run_auth_check(profile: Option<&str>) -> Result<(), KagiError> { let inventory = load_credential_inventory_for_profile(profile)?; - let credentials = inventory.resolve_for_search(SearchAuthRequirement::Base)?; + let credential = inventory.preferred_for_status().cloned().ok_or_else(|| { + KagiError::Config( + "missing credentials: set KAGI_API_KEY, KAGI_API_TOKEN, or KAGI_SESSION_TOKEN (env), or add [auth] api_key/api_token/session_token to .kagi.toml" + .to_string(), + ) + })?; - let selected_kind = credentials.primary.kind; - let selected_source = credentials.primary.source; - validate_credential(&credentials.primary).await?; + let selected_kind = credential.kind; + let selected_source = credential.source; + validate_credential(&credential).await?; println!( "auth check passed: {} ({})", @@ -823,7 +829,7 @@ async fn execute_search_request( match execute_primary_search_request(request, &credentials.primary).await { Ok(response) => Ok(response), Err(api_error) - if credentials.primary.kind == CredentialKind::ApiToken + if credentials.primary.kind == CredentialKind::ApiKey && should_fallback_to_session(&api_error) => { let fallback = credentials.fallback_session.ok_or(api_error)?; @@ -838,7 +844,11 @@ async fn execute_primary_search_request( credential: &Credential, ) -> Result { match credential.kind { - CredentialKind::ApiToken => search::execute_api_search(request, &credential.value).await, + CredentialKind::ApiKey => search::execute_api_search(request, &credential.value).await, + CredentialKind::ApiToken => Err(KagiError::Config( + "base search requires KAGI_API_KEY for API-first mode; legacy KAGI_API_TOKEN only works with /api/v0 commands" + .to_string(), + )), CredentialKind::SessionToken => search::execute_search(request, &credential.value).await, } } @@ -873,6 +883,24 @@ fn resolve_session_token(profile: Option<&str>) -> Result { }) } +async fn execute_extract_with_available_auth( + url: &str, + profile: Option<&str>, +) -> Result { + let inventory = load_credential_inventory_for_profile(profile)?; + if let Some(key) = inventory.api_key { + return execute_extract(url, &key.value).await; + } + if let Some(token) = inventory.session_token { + return execute_session_extract(url, &token.value).await; + } + + Err(KagiError::Config( + "extract requires KAGI_API_KEY or KAGI_SESSION_TOKEN (env or .kagi.toml [auth])" + .to_string(), + )) +} + fn build_translate_request(args: TranslateArgs) -> Result { let text = match args.text { Some(text) => text, @@ -2144,9 +2172,8 @@ async fn run_mcp_tool_call(request: &Value, profile: Option<&str>) -> Result { - let token = resolve_api_token(profile)?; let url = arguments.get("url").and_then(Value::as_str).unwrap_or(""); - execute_extract(url, &token).await? + execute_extract_with_available_auth(url, profile).await? } "kagi_quick" => { let token = resolve_session_token(profile)?; diff --git a/src/search.rs b/src/search.rs index b4ff8ec..d5e19e4 100644 --- a/src/search.rs +++ b/src/search.rs @@ -15,7 +15,7 @@ use crate::types::{NewsSearchResponse, SearchResponse, SearchResult}; const KAGI_SEARCH_PATH: &str = "/html/search"; const KAGI_NEWS_SEARCH_PATH: &str = "/news"; -const KAGI_API_SEARCH_PATH: &str = "/api/v0/search"; +const KAGI_API_SEARCH_PATH: &str = "/api/v1/search"; const DEBUG_BODY_PREVIEW_LIMIT: usize = 256; const UNAUTHENTICATED_MARKERS: [&str; 3] = [ "Kagi Search - A Premium Search Engine", @@ -346,9 +346,12 @@ pub async fn execute_api_search( let client = build_client()?; let response = client - .get(http::kagi_url(KAGI_API_SEARCH_PATH)) - .query(&[("q", request.query.trim())]) - .header(header::AUTHORIZATION, format!("Bot {token}")) + .post(http::kagi_url(KAGI_API_SEARCH_PATH)) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .json(&ApiSearchRequest { + query: request.query.trim(), + }) .send() .await .map_err(map_transport_error)?; @@ -368,7 +371,7 @@ pub async fn execute_api_search( KagiError::Parse(format!("failed to parse Kagi API response: {error}")) })?; Ok(SearchResponse { - data: api_response.data, + data: api_response.into_search_results(), }) } status if status.is_client_error() => { @@ -705,9 +708,54 @@ fn build_client() -> Result { http::client_20s() } +#[derive(Debug, Serialize)] +struct ApiSearchRequest<'a> { + query: &'a str, +} + #[derive(Debug, Deserialize)] struct ApiSearchResponse { - data: Vec, + data: Option, +} + +impl ApiSearchResponse { + fn into_search_results(self) -> Vec { + self.data + .and_then(|data| data.search) + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(index, result)| result.into_search_result(index as u32 + 1)) + .collect() + } +} + +#[derive(Debug, Deserialize)] +struct ApiSearchData { + search: Option>, +} + +#[derive(Debug, Deserialize)] +struct ApiSearchResult { + url: String, + title: String, + #[serde(default)] + snippet: Option, + #[serde(default)] + time: Option, +} + +impl ApiSearchResult { + fn into_search_result(self, rank: u32) -> SearchResult { + SearchResult { + t: 0, + rank: Some(rank), + url: self.url, + title: self.title, + snippet: self.snippet.unwrap_or_default(), + published: self.time, + } + } } #[derive(Debug, Deserialize)] @@ -915,20 +963,22 @@ mod tests { #[test] fn parses_api_response_shape_into_search_response() { let raw = r#"{ - "meta": { "id": "abc", "node": "us", "ms": 10 }, - "data": [ - { - "t": 0, - "url": "https://example.com", - "title": "Example", - "snippet": "Example snippet" - } - ] + "meta": { "trace": "abc", "node": "us", "ms": 10 }, + "data": { + "search": [ + { + "url": "https://example.com", + "title": "Example", + "snippet": "Example snippet" + } + ] + } }"#; let parsed: ApiSearchResponse = serde_json::from_str(raw).expect("api response parses"); - assert_eq!(parsed.data.len(), 1); - assert_eq!(parsed.data[0].title, "Example"); + let results = parsed.into_search_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Example"); } #[test] diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index cec8e2f..06d6ac4 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -1,3 +1,4 @@ +use std::fs; use std::io::Write; use std::path::Path; use std::process::{Command, Output, Stdio}; @@ -7,6 +8,7 @@ use httpmock::MockServer; use serde_json::{Value, json}; use tempfile::TempDir; +const API_KEY: &str = "test-api-key"; const API_TOKEN: &str = "test-api-token"; fn run_kagi(args: &[&str], envs: &[(&str, &str)], cwd: &Path) -> Output { @@ -14,6 +16,7 @@ fn run_kagi(args: &[&str], envs: &[(&str, &str)], cwd: &Path) -> Output { command.args(args).current_dir(cwd); for key in [ + "KAGI_API_KEY", "KAGI_API_TOKEN", "KAGI_SESSION_TOKEN", "KAGI_BASE_URL", @@ -41,6 +44,7 @@ fn run_kagi_with_stdin(args: &[&str], stdin: &str, envs: &[(&str, &str)], cwd: & .stderr(Stdio::piped()); for key in [ + "KAGI_API_KEY", "KAGI_API_TOKEN", "KAGI_SESSION_TOKEN", "KAGI_BASE_URL", @@ -77,6 +81,7 @@ fn assert_success(output: &Output) { fn test_env(server: &MockServer) -> Vec<(&'static str, String)> { vec![ + ("KAGI_API_KEY", API_KEY.to_string()), ("KAGI_API_TOKEN", API_TOKEN.to_string()), ("KAGI_BASE_URL", server.base_url()), ("KAGI_NEWS_BASE_URL", server.base_url()), @@ -108,14 +113,15 @@ fn api_meta() -> Value { fn search_payload(title: &str, url: &str, snippet: &str) -> Value { json!({ "meta": api_meta(), - "data": [ - { - "t": 0, - "url": url, - "title": title, - "snippet": snippet - } - ] + "data": { + "search": [ + { + "url": url, + "title": title, + "snippet": snippet + } + ] + } }) } @@ -188,10 +194,10 @@ fn news_stories() -> Value { fn search_command_returns_json_from_mock_api() { let server = MockServer::start(); let _search = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust programming") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust programming" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -218,10 +224,10 @@ fn search_command_returns_json_from_mock_api() { fn search_command_returns_toon_from_mock_api() { let server = MockServer::start(); let _search = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust programming") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust programming" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -250,10 +256,10 @@ fn search_command_returns_toon_from_mock_api() { fn search_command_pretty_format_prints_ranked_results() { let server = MockServer::start(); let _search = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust programming") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust programming" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -288,19 +294,21 @@ fn search_command_pretty_format_prints_ranked_results() { fn search_command_limit_truncates_results() { let server = MockServer::start(); let _search = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(json!({ "meta": api_meta(), - "data": [ - { "t": 0, "url": "https://example.com/a", "title": "A", "snippet": "first" }, - { "t": 0, "url": "https://example.com/b", "title": "B", "snippet": "second" }, - { "t": 0, "url": "https://example.com/c", "title": "C", "snippet": "third" } - ] + "data": { + "search": [ + { "url": "https://example.com/a", "title": "A", "snippet": "first" }, + { "url": "https://example.com/b", "title": "B", "snippet": "second" }, + { "url": "https://example.com/c", "title": "C", "snippet": "third" } + ] + } })); }); @@ -324,10 +332,10 @@ fn search_command_limit_truncates_results() { fn batch_command_returns_queries_and_results() { let server = MockServer::start(); let _rust = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -337,10 +345,10 @@ fn batch_command_returns_queries_and_results() { )); }); let _zig = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "zig") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "zig" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -379,10 +387,10 @@ fn batch_command_returns_queries_and_results() { fn batch_command_reports_partial_failures_in_json_mode() { let server = MockServer::start(); let _ok = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -392,10 +400,10 @@ fn batch_command_reports_partial_failures_in_json_mode() { )); }); let _fail = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "broken") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "broken" })) + .header("authorization", "Bearer test-api-key"); then.status(403) .header("content-type", "application/json") .json_body(json!({ @@ -433,10 +441,10 @@ fn batch_command_reports_partial_failures_in_json_mode() { fn auth_check_validates_credentials_without_live_network() { let server = MockServer::start(); let _search = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust lang") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust lang" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -450,11 +458,96 @@ fn auth_check_validates_credentials_without_live_network() { let env = test_env(&server); let output = run_kagi(&["auth", "check"], &env_refs(&env), tempdir.path()); + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("auth check passed: api-key (env)")); +} + +#[test] +fn auth_check_uses_current_search_api_for_api_keys() { + let server = MockServer::start(); + let _search = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust lang" })) + .header("authorization", "Bearer test-api-key"); + then.status(200) + .header("content-type", "application/json") + .json_body(search_payload( + "Rust", + "https://www.rust-lang.org", + "Rust homepage.", + )); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let env = test_env(&server); + let output = run_kagi(&["auth", "check"], &env_refs(&env), tempdir.path()); + + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("auth check passed: api-key (env)")); + assert_eq!(_search.calls(), 1, "auth check should call v1 Search API"); +} + +#[test] +fn auth_check_validates_legacy_api_token_with_fastgpt() { + let server = MockServer::start(); + let _fastgpt = server.mock(|when, then| { + when.method(POST) + .path("/api/v0/fastgpt") + .json_body(json!({ + "query": "2+2", + "cache": true, + "web_search": false + })) + .header("authorization", "Bot test-api-token"); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "meta": api_meta(), + "data": { + "output": "4", + "tokens": 4, + "references": [] + } + })); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let env = vec![ + ("KAGI_API_TOKEN", API_TOKEN.to_string()), + ("KAGI_BASE_URL", server.base_url()), + ]; + let output = run_kagi(&["auth", "check"], &env_refs(&env), tempdir.path()); + assert_success(&output); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("auth check passed: api-token (env)")); } +#[test] +fn auth_set_saves_api_key_and_legacy_api_token_separately() { + let tempdir = TempDir::new().expect("tempdir"); + let output = run_kagi( + &[ + "auth", + "set", + "--api-key", + "current-key", + "--api-token", + "legacy-token", + ], + &[], + tempdir.path(), + ); + + assert_success(&output); + let raw = fs::read_to_string(tempdir.path().join(".kagi.toml")).expect("config should exist"); + assert!(raw.contains("api_key = \"current-key\"")); + assert!(raw.contains("api_token = \"legacy-token\"")); +} + #[test] fn summarize_url_command_prints_structured_json() { let server = MockServer::start(); @@ -492,7 +585,7 @@ fn extract_command_prints_markdown_from_mock_api() { let _extract = server.mock(|when, then| { when.method(POST) .path("/api/v1/extract") - .header("authorization", "Bot test-api-token") + .header("authorization", "Bearer test-api-key") .json_body(json!({ "pages": [ { @@ -534,7 +627,7 @@ fn extract_command_prints_markdown_from_mock_api() { #[test] fn extract_command_rejects_non_https_urls() { let tempdir = TempDir::new().expect("tempdir"); - let env = [("KAGI_API_TOKEN", API_TOKEN)]; + let env = [("KAGI_API_KEY", API_KEY)]; let output = run_kagi(&["extract", "http://example.com"], &env, tempdir.path()); assert!( @@ -548,6 +641,187 @@ fn extract_command_rejects_non_https_urls() { ); } +#[test] +fn extract_command_uses_session_api_token_from_portal() { + let server = MockServer::start(); + let _portal = server.mock(|when, then| { + when.method(GET) + .path("/settings/api") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(r#""#); + }); + let _extract = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/extract") + .header( + "authorization", + "Bearer session-derived-api-token-1234567890abcdef", + ) + .json_body(json!({ + "pages": [ + { + "url": "https://example.com/article" + } + ], + "format": "json" + })); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "meta": { + "trace": "trace-1", + "node": "test", + "ms": 12 + }, + "data": [ + { + "url": "https://example.com/article", + "markdown": "# Article\n\nExtracted via session-derived API token." + } + ] + })); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let cache_dir = tempdir.path().join("cache"); + let mut env = session_env(&server); + env.push(("KAGI_CACHE_DIR", cache_dir.to_string_lossy().to_string())); + let output = run_kagi( + &["extract", "https://example.com/article"], + &env_refs(&env), + tempdir.path(), + ); + + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "# Article\n\nExtracted via session-derived API token.\n" + ); +} + +#[test] +fn extract_command_generates_api_token_when_portal_has_none() { + let server = MockServer::start(); + let _new_portal = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(r#"

Kagi API

Extraction"#); + }); + let _generate = server.mock(|when, then| { + when.method(GET) + .path("/settings/api") + .query_param("generate", "1") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(r#""#); + }); + let _portal = server.mock(|when, then| { + when.method(GET) + .path("/settings/api") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(r#"Generate API key"#); + }); + let _extract = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/extract") + .header( + "authorization", + "Bearer generated-api-token-1234567890abcdef", + ) + .json_body(json!({ + "pages": [ + { + "url": "https://example.com/article" + } + ], + "format": "json" + })); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "meta": { + "trace": "trace-1", + "node": "test", + "ms": 12 + }, + "data": [ + { + "url": "https://example.com/article", + "markdown": "# Article\n\nExtracted after API token generation." + } + ] + })); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let cache_dir = tempdir.path().join("cache"); + let mut env = session_env(&server); + env.push(("KAGI_CACHE_DIR", cache_dir.to_string_lossy().to_string())); + let output = run_kagi( + &["extract", "https://example.com/article"], + &env_refs(&env), + tempdir.path(), + ); + + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "# Article\n\nExtracted after API token generation.\n" + ); +} + +#[test] +fn extract_command_rejects_family_restricted_session_before_generating_api_token() { + let server = MockServer::start(); + let _settings_portal = server.mock(|when, then| { + when.method(GET) + .path("/settings/api") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(r#"Generate API key"#); + }); + let _new_portal = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("cookie", "kagi_session=test-session"); + then.status(200).header("content-type", "text/html").body( + r#"

API access is restricted for family members

+

Request an API key from your family administrator.

"#, + ); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let cache_dir = tempdir.path().join("cache"); + let mut env = session_env(&server); + env.push(("KAGI_CACHE_DIR", cache_dir.to_string_lossy().to_string())); + let output = run_kagi( + &["extract", "https://example.com/article"], + &env_refs(&env), + tempdir.path(), + ); + + assert!( + !output.status.success(), + "expected family-restricted session to fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("API access is restricted for this session"), + "expected family restriction in stderr: {stderr}" + ); +} + fn news_search_html_fixture() -> &'static str { r#"
@@ -818,10 +1092,10 @@ fn assistant_thread_list_paginates_with_cursor_id() { fn batch_command_reads_queries_from_stdin() { let server = MockServer::start(); let _rust = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -831,10 +1105,10 @@ fn batch_command_reads_queries_from_stdin() { )); }); let _zig = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "zig") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "zig" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -862,10 +1136,10 @@ fn batch_command_reads_queries_from_stdin() { fn search_template_renders_result_fields() { let server = MockServer::start(); let _search = server.mock(|when, then| { - when.method(GET) - .path("/api/v0/search") - .query_param("q", "rust") - .header("authorization", "Bot test-api-token"); + when.method(POST) + .path("/api/v1/search") + .json_body(json!({ "query": "rust" })) + .header("authorization", "Bearer test-api-key"); then.status(200) .header("content-type", "application/json") .json_body(search_payload( @@ -960,7 +1234,7 @@ fn mcp_extract_tool_call_returns_markdown() { let _extract = server.mock(|when, then| { when.method(POST) .path("/api/v1/extract") - .header("authorization", "Bot test-api-token") + .header("authorization", "Bearer test-api-key") .json_body(json!({ "pages": [ { @@ -1003,6 +1277,68 @@ fn mcp_extract_tool_call_returns_markdown() { ); } +#[test] +fn mcp_extract_tool_call_uses_session_api_token_from_portal() { + let server = MockServer::start(); + let _portal = server.mock(|when, then| { + when.method(GET) + .path("/settings/api") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(r#""#); + }); + let _extract = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/extract") + .header( + "authorization", + "Bearer session-mcp-api-token-1234567890abcdef", + ) + .json_body(json!({ + "pages": [ + { + "url": "https://example.com/article" + } + ], + "format": "json" + })); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "meta": { + "trace": "trace-1", + "node": "test", + "ms": 12 + }, + "data": [ + { + "url": "https://example.com/article", + "markdown": "# Article\n\nMCP session extract." + } + ] + })); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let cache_dir = tempdir.path().join("cache"); + let mut env = session_env(&server); + env.push(("KAGI_CACHE_DIR", cache_dir.to_string_lossy().to_string())); + let output = run_kagi_with_stdin( + &["mcp"], + r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kagi_extract","arguments":{"url":"https://example.com/article"}}}"#, + &env_refs(&env), + tempdir.path(), + ); + + assert_success(&output); + let response: Value = serde_json::from_slice(&output.stdout).expect("mcp json parses"); + assert_eq!( + response["result"]["content"][0]["text"], + "# Article\n\nMCP session extract." + ); +} + #[test] fn mcp_news_tool_call_returns_stories() { let server = MockServer::start();