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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@

- `kagi assistant` and `kagi ask-page` JSON output no longer has `meta.version`, `meta.trace`, `message.reply_html`, or `message.trace_id`, because Kagi's v2 Assistant API returns markdown only. `message.id` is now the assistant turn id.
- `kagi assistant --assistant <built-in> --model <model>` is now an error, because built-in assistants always use their own model.
- `kagi search --format csv` and `kagi batch --format csv` gain a fourth column: the header is now `title,url,snippet,published`.

### Fixed

- `kagi assistant` and `kagi ask-page` work again after Kagi moved Assistant to its v2 API; prompts no longer fail with `HTTP 405 Method Not Allowed`.
- `kagi assistant --assistant` with a built-in assistant such as `Code` now selects it as a model, as the web app does.
- Assistant citation markers are rewritten to `[^1]`, `[^2]`, ... to match `references_markdown`.
- `kagi search` snippets no longer include Kagi's "Summarize" link text, and whitespace in snippets is collapsed.
- `kagi search` with a session token fills `published` with the result's date as `YYYY-MM-DDT00:00:00Z` and removes the date from the snippet. Relative dates such as "Yesterday" stay in the snippet and leave `published` as `null`.
- `pretty` and `markdown` output print `published` under the URL, `csv` adds the column, and `--template` accepts `{{published}}`, for API-key searches too.

## [0.20.1]

Expand Down
8 changes: 5 additions & 3 deletions docs/content/docs/commands/search.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Possible values:
- `compact` - minified JSON
- `pretty` - terminal-friendly output
- `markdown` - markdown list output
- `csv` - CSV table output
- `csv` - CSV table output with `title,url,snippet,published` columns

### `--no-color`

Expand Down Expand Up @@ -179,7 +179,7 @@ kagi search --no-personalized "rust web framework"

### `--template <TEMPLATE>`

Render each result with a lightweight template instead of a built-in format. Supported placeholders are `{{rank}}`, `{{title}}`, `{{url}}`, and `{{snippet}}`.
Render each result with a lightweight template instead of a built-in format. Supported placeholders are `{{rank}}`, `{{title}}`, `{{url}}`, `{{snippet}}`, and `{{published}}` (empty when the result has no date).

```bash
kagi search "rust crates" --template '{{rank}}. {{title}} - {{url}}'
Expand Down Expand Up @@ -243,12 +243,14 @@ The search response shape is unchanged:
"title": "Rust Programming Language",
"url": "https://www.rust-lang.org",
"snippet": "A language empowering everyone to build reliable and efficient software.",
"published": null
"published": "2026-10-04T00:00:00Z"
}
]
}
```

With a session token, `published` is the day shown on the result page (`YYYY-MM-DDT00:00:00Z`), or `null` when the page shows no date or a relative one such as "Yesterday", which stays in `snippet`. With `KAGI_API_KEY`, it is the timestamp Kagi's Search API returns.

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

```json
Expand Down
5 changes: 5 additions & 0 deletions docs/content/docs/reference/output-contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ This page documents the current CLI output behavior as implemented in the repo.
}
```

With a session token, `published` is the day shown on the result page (`YYYY-MM-DDT00:00:00Z`), or `null` when the page shows no date or a relative one such as "Yesterday", which stays in `snippet`. With `KAGI_API_KEY`, it is the timestamp Kagi's Search API returns.

### `kagi search --news`

The News tab vertical returns a different shape -- clusters of articles:
Expand Down Expand Up @@ -448,10 +450,13 @@ Kagi's v2 Assistant API returns markdown only, so `kagi assistant` and `kagi ask
```text
1. Result Title
https://example.com
2026-03-16

Description...
```

The date line appears when the result has a `published` value.

If the result set is empty, the CLI prints:

```text
Expand Down
86 changes: 79 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2304,6 +2304,7 @@ fn format_template_response(response: &SearchResponse, template: &str) -> String
.replace("{{title}}", &result.title)
.replace("{{url}}", &result.url)
.replace("{{snippet}}", &result.snippet)
.replace("{{published}}", result.published.as_deref().unwrap_or(""))
Comment thread
Joilence marked this conversation as resolved.
})
.collect::<Vec<_>>()
.join("\n")
Expand Down Expand Up @@ -2377,6 +2378,9 @@ fn format_pretty_response(response: &SearchResponse, use_color: bool) -> String
result.url,
reset_color
);
if let Some(date) = published_date(result.published.as_deref()) {
section.push_str(&format!("\n {date}"));
}
if !result.snippet.trim().is_empty() {
section.push_str(&format!("\n\n {}", result.snippet.trim()));
}
Expand All @@ -2401,6 +2405,9 @@ fn format_markdown_response(response: &SearchResponse) -> String {
.enumerate()
.map(|(index, result)| {
let mut section = format!("## {}. [{}]({})\n\n", index + 1, result.title, result.url);
if let Some(date) = published_date(result.published.as_deref()) {
section.push_str(&format!("*{date}*\n\n"));
}
if !result.snippet.trim().is_empty() {
section.push_str(&format!("{}\n\n", result.snippet.trim()));
}
Expand Down Expand Up @@ -2475,21 +2482,34 @@ fn escape_csv_field(field: &str) -> String {

fn format_csv_response(response: &SearchResponse) -> String {
if response.data.is_empty() {
return "title,url,snippet".to_string();
return "title,url,snippet,published".to_string();
}

let mut output = String::from("title,url,snippet\n");
let mut output = String::from("title,url,snippet,published\n");

for result in &response.data {
let title = escape_csv_field(&result.title);
let url = escape_csv_field(&result.url);
let snippet = escape_csv_field(&result.snippet);
output.push_str(&format!("{title},{url},{snippet}\n"));
let published = escape_csv_field(result.published.as_deref().unwrap_or(""));
output.push_str(&format!("{title},{url},{snippet},{published}\n"));
}

output
}

/// Returns `published` for display, shortening an ISO timestamp to its date (`2026-10-04`).
fn published_date(published: Option<&str>) -> Option<&str> {
let published = published?.trim();
if published.is_empty() {
return None;
}
match published.get(..10) {
Some(date) if date.as_bytes()[4] == b'-' && date.as_bytes()[7] == b'-' => Some(date),
_ => Some(published),
}
}

fn build_news_search_request(args: &SearchArgs) -> search::NewsSearchRequest {
let freshness = args.time.as_ref().and_then(|time| match time {
SearchTime::Day => Some(search::NewsFreshness::Day),
Expand Down Expand Up @@ -5877,8 +5897,8 @@ mod tests {
RateLimiter, SearchRequestOptions, bool_flag_choice, build_search_request,
format_assistant_markdown, format_assistant_pretty, format_batch_failure_message,
format_csv_response, format_markdown_response, format_pretty_response,
is_bare_auth_invocation_from, parse_context_memory_json, print_assistant_response,
should_fallback_to_session,
format_template_response, is_bare_auth_invocation_from, parse_context_memory_json,
print_assistant_response, should_fallback_to_session,
};
use crate::cli::{AssistantOutputFormat, SearchOrder, SearchTime};
use crate::error::KagiError;
Expand Down Expand Up @@ -5959,6 +5979,58 @@ mod tests {
assert_eq!(output, "No results found.");
}

#[test]
fn shows_published_date_in_every_output_format() {
let result = |title: &str, published: Option<&str>| SearchResult {
t: 0,
rank: None,
title: title.to_string(),
url: format!("https://{}.example", title.to_lowercase()),
snippet: format!("{title} snippet"),
published: published.map(str::to_string),
};
let response = SearchResponse {
data: vec![
result("Dated", Some("2026-10-04T00:00:00Z")),
result("Undated", None),
],
related_searches: Vec::new(),
};

let pretty = format_pretty_response(&response, false);
assert!(
pretty.contains("https://dated.example\n 2026-10-04\n\n Dated snippet"),
"{pretty}"
);
assert!(
pretty.contains("https://undated.example\n\n Undated snippet"),
"{pretty}"
);

let markdown = format_markdown_response(&response);
assert!(
markdown.contains("(https://dated.example)\n\n*2026-10-04*\n\nDated snippet"),
"{markdown}"
);
assert!(
markdown.contains("(https://undated.example)\n\nUndated snippet"),
"{markdown}"
);

let csv = format_csv_response(&response);
assert_eq!(
csv,
concat!(
"title,url,snippet,published\n",
"Dated,https://dated.example,Dated snippet,2026-10-04T00:00:00Z\n",
"Undated,https://undated.example,Undated snippet,\n"
)
);

let template = format_template_response(&response, "{{title}}|{{published}}");
assert_eq!(template, "Dated|2026-10-04T00:00:00Z\nUndated|");
}

#[test]
fn omits_blank_snippets_in_pretty_output() {
let response = SearchResponse {
Expand Down Expand Up @@ -6221,7 +6293,7 @@ mod tests {

assert_eq!(
output,
"title,url,snippet\nRust Programming Language,https://www.rust-lang.org,A language empowering everyone to build reliable and efficient software.\n"
"title,url,snippet,published\nRust Programming Language,https://www.rust-lang.org,A language empowering everyone to build reliable and efficient software.,\n"
);
}

Expand All @@ -6243,7 +6315,7 @@ mod tests {

assert_eq!(
output,
"title,url,snippet\n\"Rust, \"\"The Language\"\"\",\"https://example.com/a,b\",\"line 1\nline 2\"\n"
"title,url,snippet,published\n\"Rust, \"\"The Language\"\"\",\"https://example.com/a,b\",\"line 1\nline 2\",\n"
);
}

Expand Down
Loading
Loading