Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Before `1.0.0`, breaking changes may still ship in minor releases.

## [Unreleased]

### Added

- `kagi mcp` now exposes a `kagi_news` tool that fetches Kagi News stories without authentication (accepts `category`, `limit`, and `lang`)
- `kagi search --news` searches the News tab of kagi.com and returns results grouped into story clusters (session auth required); supports `--region`, `--time` (day/week/month), `--order` (default/recency/website), and `--limit`
- `kagi mcp` exposes a `kagi_news_search` tool wrapping the News-tab vertical (accepts `query`, `region`, `freshness`, `order`, `limit`)

### Fixed

- `kagi news` no longer fails to parse live responses; `total_stories` is now an integer in the output (previously typed as a string, which never matched the API's actual integer payload)

## [0.5.1]

### Added
Expand Down
7 changes: 5 additions & 2 deletions docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export KAGI_API_TOKEN='...'

| Credential | What It Unlocks |
|------------|-----------------|
| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber` |
| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, `search --news`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber` |
| `KAGI_API_TOKEN` | summarize, fastgpt, enrich web, enrich news |
| none | news, smallweb, auth status, --help |

Expand All @@ -91,6 +91,9 @@ kagi search --format pretty "query"
# Search with lens
kagi search --lens 2 "query"

# News-tab search (returns clusters of articles, session-only)
kagi search --news "iran"

# Filtered search
kagi search --time month --region us --order recency "rust release notes"

Expand Down Expand Up @@ -436,7 +439,7 @@ kagi batch "topic1" "topic2" "topic3" --format csv > comparison.csv

## Constraints

- Session token required for: search --lens, quick, ask-page, assistant, translate, summarize --subscriber
- Session token required for: search --lens, search --news, quick, ask-page, assistant, translate, summarize --subscriber
- API token required for: summarize (public API), fastgpt, enrich
- Rate limits apply based on Kagi subscription tier
- API usage has per-query costs; session-based features included with subscription
Expand Down
2 changes: 2 additions & 0 deletions docs/commands/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ kagi mcp
- `kagi_search` - search Kagi
- `kagi_summarize` - summarize a URL or text through the public API
- `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`.

The server reads one JSON-RPC request per stdin line and writes one JSON-RPC response per stdout line. This keeps the implementation dependency-light and easy to supervise from agent runners.

Expand Down
4 changes: 2 additions & 2 deletions docs/commands/news.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ kagi news --filter-keyword trump --filter-scope title
"category_name": "Technology"
},
"stories": [],
"total_stories": "20",
"total_stories": 20,
"domains": [],
"read_count": 1234,
"content_filter": {
Expand All @@ -194,7 +194,7 @@ kagi news --filter-keyword trump --filter-scope title
| `latest_batch` | object | Metadata for the current news batch |
| `category` | object | Resolved category metadata |
| `stories` | array | Raw story payloads returned by Kagi News |
| `total_stories` | string | Total number of stories in the category |
| `total_stories` | integer | Total number of stories in the category |
| `domains` | array | Domain-level metadata from the feed |
| `read_count` | integer | Aggregate read count for the category |
| `content_filter` | object | Present when local content filters are active |
Expand Down
46 changes: 46 additions & 0 deletions docs/commands/search.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,21 @@ Cap the number of results returned. Truncation happens locally after Kagi's resp
kagi search "rust async runtime tradeoffs" --limit 5
```

### `--news`

Search the News tab of `kagi.com` (`kagi.com/news?q=...`) instead of web search. Returns articles grouped into story clusters (multiple sources covering the same story share a cluster). Forces session-token auth.

```bash
kagi search "iran" --news
kagi search "iran" --news --time day --order recency --format pretty
```

Supported flags with `--news`: `--region`, `--time` (day, week, month only — not `year`), `--order` (default, recency, website only — not `trackers`), `--limit`, `--format`, `--local-cache`, `--cache-ttl`.

Rejected flags with `--news`: `--lens`, `--snap`, `--from-date`, `--to-date`, `--verbatim`, `--personalized`, `--no-personalized`, `--follow`, `--template`. Each produces a clear error.

The JSON shape is `NewsSearchResponse`, distinct from web search. See [Output Format](#output-format) below.

## Not Supported as Runtime Flags

`safe_search` is currently an account setting, not a search-time flag in this CLI. Configure it in the Kagi web settings instead of expecting `kagi search` to override it per request.
Expand All @@ -231,6 +246,31 @@ The search response shape is unchanged:
}
```

With `--news`, the response shape is different — clusters of news articles:

```json
{
"query": "iran",
"clusters": [
{
"items": [
{
"title": "Lead headline",
"url": "https://www.example.com/article",
"source": "example.com",
"time_relative": "2 hours ago",
"snippet": "Optional snippet text.",
"paywall": false,
"image_url": "https://img.example/lead.jpg"
}
]
}
]
}
```

`time_relative` is a verbatim string like `"2 hours ago"` or `"Yesterday"`; the News page does not expose absolute timestamps. Cluster ordering reflects Kagi's page order. Single-item clusters represent ungrouped articles at the top of the page.

## Examples

### Plain base search
Expand Down Expand Up @@ -287,6 +327,12 @@ kagi search --verbatim --no-personalized "\"rust book ownership\""
kagi search --lens 2 --region us --time year "developer documentation"
```

### News-tab search

```bash
kagi search "iran" --news --time day --format pretty
```

## Processing Results

```bash
Expand Down
29 changes: 28 additions & 1 deletion docs/reference/output-contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ This page documents the current CLI output behavior as implemented in the repo.
}
```

### `kagi search --news`

The News tab vertical returns a different shape — clusters of articles:

```json
{
"query": "iran",
"clusters": [
{
"items": [
{
"title": "Headline",
"url": "https://www.example.com/article",
"source": "example.com",
"time_relative": "2 hours ago",
"snippet": "Optional snippet text.",
"paywall": false,
"image_url": "https://img.example/lead.jpg"
}
]
}
]
}
```

`time_relative` is preserved verbatim from Kagi's page (the News tab does not expose absolute timestamps). Single-item clusters represent ungrouped articles at the top of the results page.

### `kagi batch`

Default JSON output uses a structured envelope:
Expand Down Expand Up @@ -105,7 +132,7 @@ Subscriber mode:
"category_name": "Technology"
},
"stories": [],
"total_stories": "20",
"total_stories": 20,
"domains": [],
"read_count": 1234
}
Expand Down
138 changes: 137 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ pub struct SearchArgs {
/// Maximum number of search results to return
#[arg(long, value_name = "N")]
pub limit: Option<usize>,

/// Search the News tab of kagi.com (kagi.com/news?q=...) instead of web results.
/// Forces session auth. Conflicts with --lens, --snap, --from-date, --to-date,
/// --verbatim, --personalized, --no-personalized, --follow, --template, and
/// rejects --time year and --order trackers (no equivalents in the news vertical).
#[arg(long)]
pub news: bool,
}

impl SearchArgs {
Expand All @@ -353,6 +360,51 @@ impl SearchArgs {
}
Ok(())
}

/// Validates the flag combinations allowed with `--news`.
///
/// # Errors
/// Returns a config error message when an incompatible flag is combined with `--news`.
pub fn validate_news_search(&self) -> Result<(), String> {
if !self.news {
return Ok(());
}
if self.lens.is_some() {
return Err("--news cannot be combined with --lens".to_string());
}
if self.snap.is_some() {
return Err("--news cannot be combined with --snap".to_string());
}
if self.from_date.is_some() || self.to_date.is_some() {
return Err("--news cannot be combined with --from-date or --to-date".to_string());
}
if self.verbatim {
return Err("--news cannot be combined with --verbatim".to_string());
}
if self.personalized || self.no_personalized {
return Err(
"--news cannot be combined with --personalized or --no-personalized".to_string(),
);
}
if self.follow.is_some() {
return Err("--news cannot be combined with --follow".to_string());
}
if self.template.is_some() {
return Err("--news cannot be combined with --template".to_string());
}
if matches!(self.time, Some(SearchTime::Year)) {
return Err(
"--time year is not supported with --news (only day, week, or month)".to_string(),
);
}
if matches!(self.order, Some(SearchOrder::Trackers)) {
return Err(
"--order trackers is not supported with --news (use default, recency, or website)"
.to_string(),
);
}
Ok(())
}
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -1548,9 +1600,36 @@ pub struct RedirectUpdateArgs {

#[cfg(test)]
mod tests {
use super::{Cli, Commands, NewsArgs, NewsFilterMode, NewsFilterScope, SummarizeArgs};
use super::{
Cli, Commands, NewsArgs, NewsFilterMode, NewsFilterScope, OutputFormat, SearchArgs,
SearchOrder, SearchTime, SummarizeArgs,
};
use clap::Parser;

fn sample_search_args(query: &str) -> SearchArgs {
SearchArgs {
query: query.to_string(),
format: OutputFormat::Json,
no_color: false,
snap: None,
lens: None,
region: None,
time: None,
from_date: None,
to_date: None,
order: None,
verbatim: false,
personalized: false,
no_personalized: false,
template: None,
local_cache: false,
cache_ttl: None,
follow: None,
limit: None,
news: false,
}
}

fn sample_news_args() -> NewsArgs {
NewsArgs {
category: "world".to_string(),
Expand Down Expand Up @@ -1598,6 +1677,63 @@ mod tests {
assert!(args.has_filter_inputs());
}

#[test]
fn news_search_passthrough_flags_are_accepted() {
let mut args = sample_search_args("iran");
args.news = true;
args.region = Some("us".to_string());
args.time = Some(SearchTime::Day);
args.order = Some(SearchOrder::Recency);
args.limit = Some(5);
assert!(args.validate_news_search().is_ok());
}

#[test]
fn news_search_rejects_lens() {
let mut args = sample_search_args("iran");
args.news = true;
args.lens = Some("1".to_string());
let err = args
.validate_news_search()
.expect_err("--news + --lens should fail");
assert!(err.contains("--lens"));
}

#[test]
fn news_search_rejects_time_year() {
let mut args = sample_search_args("iran");
args.news = true;
args.time = Some(SearchTime::Year);
let err = args
.validate_news_search()
.expect_err("--news + --time year should fail");
assert!(err.contains("--time year"));
}

#[test]
fn news_search_rejects_order_trackers() {
let mut args = sample_search_args("iran");
args.news = true;
args.order = Some(SearchOrder::Trackers);
let err = args
.validate_news_search()
.expect_err("--news + --order trackers should fail");
assert!(err.contains("--order trackers"));
}

#[test]
fn news_search_rejects_template_and_follow() {
let mut args = sample_search_args("iran");
args.news = true;
args.template = Some("{{title}}".to_string());
assert!(args.validate_news_search().is_err());

let mut args = sample_search_args("iran");
args.news = true;
args.follow = Some(2);
assert!(args.validate_news_search().is_err());
}

#[test]
fn rejects_summarize_args_without_url_or_text() {
let args = SummarizeArgs {
Expand Down
Loading