diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 9ec1914..8b549e0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -49,4 +49,4 @@ jobs: - name: Publish package if: vars.NPM_PUBLISH_ENABLED == 'true' working-directory: npm - run: npm publish --access public --provenance=false + run: npm publish --access public --provenance diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d6cc7e..7ebe7f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -326,7 +326,7 @@ jobs: end test do - assert_match "Usage: kagi ", shell_output("#{bin}/kagi --help") + assert_match "Usage: kagi [OPTIONS] [COMMAND]", shell_output("#{bin}/kagi --help") end end EOF diff --git a/README.md b/README.md index 85e228b..8b29ffa 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ kagi assistant --attach ./a.jpg --attach ./b.pdf "tell me everything about this use assistant as a terminal chat client: ```bash -kagi assistant repl --model gpt-5-mini --export ./assistant-transcript.json +kagi assistant repl --model gpt-5-4-nano --export ./assistant-transcript.json ``` ask assistant about a page directly: @@ -349,7 +349,7 @@ manage custom assistants: ```bash kagi assistant custom list kagi assistant custom get "Release Notes" -kagi assistant custom create "Release Notes" --model gpt-5-mini --web-access --lens 2 --instructions "Focus on release diffs and migration notes." +kagi assistant custom create "Release Notes" --model gpt-5-4-nano --web-access --lens 2 --instructions "Focus on release diffs and migration notes." kagi assistant custom update "Release Notes" --bang-trigger relnotes --no-personalized ``` diff --git a/docs/SKILL.md b/docs/SKILL.md index 5e0c173..ea8f492 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -178,7 +178,7 @@ kagi assistant "Explain quantum computing" kagi assistant --thread-id "" "Give me an example" # Use a saved assistant profile with prompt overrides -kagi assistant --assistant research --model gpt-5-mini --web-access --no-personalized "Summarize the latest Rust release" +kagi assistant --assistant research --model gpt-5-4-nano --web-access --no-personalized "Summarize the latest Rust release" # Stream markdown deltas as they arrive kagi assistant --stream "Explain quantum computing" @@ -211,7 +211,7 @@ kagi assistant custom list kagi assistant custom get "Release Notes" # Create a custom assistant -kagi assistant custom create "Release Notes" --model gpt-5-mini --web-access --lens 2 --instructions "Focus on release diffs and migrations." +kagi assistant custom create "Release Notes" --model gpt-5-4-nano --web-access --lens 2 --instructions "Focus on release diffs and migrations." # Update an existing custom assistant kagi assistant custom update "Release Notes" --bang-trigger relnotes --no-personalized diff --git a/docs/commands/assistant.mdx b/docs/commands/assistant.mdx index 34646b3..65f8616 100644 --- a/docs/commands/assistant.mdx +++ b/docs/commands/assistant.mdx @@ -108,7 +108,7 @@ Possible values: Override the model slug for a single prompt. ```bash -kagi assistant --model gpt-5-mini "reply with only the model name" +kagi assistant --model gpt-5-4-nano "reply with only the model name" ``` #### `--lens ` @@ -132,7 +132,7 @@ Force Kagi personalizations on or off for a single prompt. `kagi assistant repl` starts a terminal chat loop that keeps the latest Assistant thread id and sends each prompt back into the same conversation. ```bash -kagi assistant repl --model gpt-5-mini --export ./assistant-transcript.json +kagi assistant repl --model gpt-5-4-nano --export ./assistant-transcript.json ``` REPL commands: @@ -224,7 +224,7 @@ Supported options: ```bash kagi assistant custom create "Release Notes" \ --bang-trigger release \ - --model gpt-5-mini \ + --model gpt-5-4-nano \ --web-access \ --instructions "Summarize release posts with a changelog-first bias." ``` @@ -328,7 +328,7 @@ Prompt mode returns: "id": "assistant-1", "name": "Research", "invoke_profile": "research", - "model": "gpt-5-mini", + "model": "gpt-5-4-nano", "bang_trigger": "research", "internet_access": true, "built_in": false, @@ -342,7 +342,7 @@ Prompt mode returns: Start a thread, capture the id, continue it, then export it: ```bash -RESPONSE=$(kagi assistant --model gpt-5-mini "plan a calm terminal research session in 3 bullets") +RESPONSE=$(kagi assistant --model gpt-5-4-nano "plan a calm terminal research session in 3 bullets") THREAD_ID=$(printf '%s' "$RESPONSE" | jq -r '.thread.id') printf '%s\n' "$RESPONSE" | jq -r '.message.markdown' diff --git a/docs/demos.md b/docs/demos.md index f0b5e02..a57ef0c 100644 --- a/docs/demos.md +++ b/docs/demos.md @@ -33,7 +33,7 @@ The current demo commands are: - `kagi ask-page https://rust-lang.org/ "What is this page about in one sentence?" | jq -M ...` - `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` - `kagi translate "Bonjour tout le monde" --to ja | jq -M ...` -- `RESPONSE=$(kagi assistant --model gpt-5-mini "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` +- `RESPONSE=$(kagi assistant --model gpt-5-4-nano "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` - `kagi lens list | jq -M ...` - `kagi bang custom list | jq -M ...` - `kagi redirect list | jq -M ...` diff --git a/docs/project/demos.mdx b/docs/project/demos.mdx index 8d2c8ce..55ad0a3 100644 --- a/docs/project/demos.mdx +++ b/docs/project/demos.mdx @@ -64,7 +64,7 @@ The current demo commands are: - `kagi news --category tech --limit 1 | jq -M ...` - `kagi ask-page https://rust-lang.org/ "What is this page about in one sentence?" | jq -M ...` - `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` -- `RESPONSE=$(kagi assistant --model gpt-5-mini "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` +- `RESPONSE=$(kagi assistant --model gpt-5-4-nano "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` - `kagi translate "Bonjour tout le monde" --to ja | jq -M ...` ```bash diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index ebd634b..f833122 100644 --- a/docs/reference/output-contract.mdx +++ b/docs/reference/output-contract.mdx @@ -176,7 +176,7 @@ Subscriber mode: "id": "assistant-1", "name": "Research", "invoke_profile": "research", - "model": "gpt-5-mini", + "model": "gpt-5-4-nano", "bang_trigger": "research", "internet_access": true, "built_in": false, @@ -195,7 +195,7 @@ Subscriber mode: "internet_access": true, "selected_lens": "", "personalizations": false, - "base_model": "gpt-5-mini", + "base_model": "gpt-5-4-nano", "custom_instructions": "Focus on changelogs first.", "delete_supported": true } diff --git a/packaging/homebrew/Formula/kagi.rb b/packaging/homebrew/Formula/kagi.rb index 498e720..f424cc6 100644 --- a/packaging/homebrew/Formula/kagi.rb +++ b/packaging/homebrew/Formula/kagi.rb @@ -1,30 +1,30 @@ class Kagi < Formula desc "Agent-native Rust CLI for Kagi subscribers with JSON-first output" homepage "https://github.com/Microck/kagi-cli" - version "0.1.6" + version "0.9.0" license "MIT" on_macos do if Hardware::CPU.arm? - url "https://github.com/Microck/kagi-cli/releases/download/v0.1.6/kagi-v0.1.6-aarch64-apple-darwin.tar.gz" - sha256 "bbd5c4093e459357ec1b8a1162d043c776ae93732b17412dd434af446bd71d52" + url "https://github.com/Microck/kagi-cli/releases/download/v0.9.0/kagi-v0.9.0-aarch64-apple-darwin.tar.gz" + sha256 "d5d4daf70a730d7d98f340927c6cf12e109c4e910564469593bef50d12f553f5" end if Hardware::CPU.intel? - url "https://github.com/Microck/kagi-cli/releases/download/v0.1.6/kagi-v0.1.6-x86_64-apple-darwin.tar.gz" - sha256 "af69180d48e2019938c4088ffc276e373dda81685b9b1b15bdfbeb30ca2e441e" + url "https://github.com/Microck/kagi-cli/releases/download/v0.9.0/kagi-v0.9.0-x86_64-apple-darwin.tar.gz" + sha256 "f7d7ecb04d13bdf6be33cb4f1a88a50d2139e8e1123d8f388c4d89c9a9d9b966" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/Microck/kagi-cli/releases/download/v0.1.6/kagi-v0.1.6-aarch64-unknown-linux-gnu.tar.gz" - sha256 "0b7902f8a39e14342e1c7e5d712b28b8de8bf3619aaabd5cd044d39ca54025af" + url "https://github.com/Microck/kagi-cli/releases/download/v0.9.0/kagi-v0.9.0-aarch64-unknown-linux-gnu.tar.gz" + sha256 "a22fde549b32402e062bb46aa43ce968ec7d4432ff7a1ee37e4d45ce739b1cff" end if Hardware::CPU.intel? - url "https://github.com/Microck/kagi-cli/releases/download/v0.1.6/kagi-v0.1.6-x86_64-unknown-linux-gnu.tar.gz" - sha256 "dda9322bba71d3cb109bc610dfd2701675523573341bbf913c620f02c2c0cb7c" + url "https://github.com/Microck/kagi-cli/releases/download/v0.9.0/kagi-v0.9.0-x86_64-unknown-linux-gnu.tar.gz" + sha256 "09bd758be6f374384aa73c1d6d22e92bcfced40537d06ea832dfb80a659dea02" end end @@ -33,6 +33,6 @@ def install end test do - assert_match "Usage: kagi ", shell_output("#{bin}/kagi --help") + assert_match "Usage: kagi [OPTIONS] [COMMAND]", shell_output("#{bin}/kagi --help") end end diff --git a/packaging/scoop/kagi.json b/packaging/scoop/kagi.json index fce892f..00c15b1 100644 --- a/packaging/scoop/kagi.json +++ b/packaging/scoop/kagi.json @@ -1,12 +1,12 @@ { - "version": "0.1.6", + "version": "0.9.0", "description": "Agent-native Rust CLI for Kagi subscribers with JSON-first output.", "homepage": "https://github.com/Microck/kagi-cli", "license": "MIT", "architecture": { "64bit": { - "url": "https://github.com/Microck/kagi-cli/releases/download/v0.1.6/kagi-v0.1.6-x86_64-pc-windows-msvc.zip", - "hash": "2daa51118d076b4593453feba61e703183aa5f6bc0075c43aaa579d1dcdf5159" + "url": "https://github.com/Microck/kagi-cli/releases/download/v0.9.0/kagi-v0.9.0-x86_64-pc-windows-msvc.zip", + "hash": "6474bae2605f1b41c1bfd9072f7707e8aac35d27fdf54e6bd1d3285675ce1d1e" } }, "bin": "kagi.exe", diff --git a/src/api.rs b/src/api.rs index 14c21de..e2723ba 100644 --- a/src/api.rs +++ b/src/api.rs @@ -97,6 +97,10 @@ const KAGI_LOGGED_OUT_MARKERS: [&str; 3] = [ "Welcome to Kagi", "paid search engine that gives power back to the user", ]; +const LENS_MUTATION_LOOKUP_ATTEMPTS: usize = 10; +const LENS_MUTATION_LOOKUP_DELAY_MS: u64 = 500; +const LENS_TOGGLE_VERIFY_ATTEMPTS: usize = 5; +const KAGI_LENS_NAME_MAX_CHARS: usize = 33; #[derive(Debug, Clone)] /// Filter parameters for the Kagi News API. @@ -1122,9 +1126,9 @@ pub async fn execute_lens_create( .await?; let created_id = match url_query_value(&url, "id") { Some(id) => id, - None => resolve_lens_id_by_name(&details.name, token).await?, + None => resolve_lens_id_by_name_after_mutation(&details.name, token, "create").await?, }; - execute_lens_get(&created_id, token).await + execute_lens_get_after_mutation(&created_id, token, "create").await } /// Updates an existing Kagi search lens. @@ -1154,7 +1158,7 @@ pub async fn execute_lens_update( "lens update", ) .await?; - execute_lens_get(&lens.id, token).await + execute_lens_get_after_mutation(&lens.id, token, "update").await } /// Deletes a Kagi search lens. @@ -1227,12 +1231,31 @@ pub async fn execute_lens_set_enabled( ) .await?; - let refreshed = execute_lens_list(token).await?; - let lens = resolve_lens_ref(&refreshed, &lens.id)?; - Ok(ToggleResourceResponse { - id: lens.id.clone(), - enabled: lens.enabled, - }) + for attempt in 0..LENS_TOGGLE_VERIFY_ATTEMPTS { + match execute_lens_list(token).await { + Ok(refreshed) => { + if let Ok(lens) = resolve_lens_ref(&refreshed, &lens.id) { + return Ok(ToggleResourceResponse { + id: lens.id.clone(), + enabled: lens.enabled, + }); + } + } + Err(error) + if attempt + 1 < LENS_TOGGLE_VERIFY_ATTEMPTS + && should_retry_lens_mutation_lookup(&error) => {} + Err(error) => return Err(error), + } + + if attempt + 1 < LENS_TOGGLE_VERIFY_ATTEMPTS { + sleep(Duration::from_secs(1)).await; + } + } + + Err(KagiError::Config(format!( + "lens '{}' was toggled but did not reappear in settings within 5 seconds", + lens.id + ))) } /// Lists all custom bangs for the authenticated user. @@ -2591,6 +2614,18 @@ fn normalize_named_target(raw: &str, label: &str) -> Result { Ok(normalized.to_string()) } +fn normalize_lens_name(raw: &str) -> Result { + let normalized = normalize_named_target(raw, "lens name")?; + let char_count = normalized.chars().count(); + if char_count > KAGI_LENS_NAME_MAX_CHARS { + return Err(KagiError::Config(format!( + "lens name must be at most {KAGI_LENS_NAME_MAX_CHARS} characters; Kagi truncates longer names" + ))); + } + + Ok(normalized) +} + fn normalize_custom_bang_trigger(raw: &str) -> Result { let normalized = raw.trim().trim_start_matches('!').trim(); if normalized.is_empty() { @@ -2818,7 +2853,7 @@ fn apply_lens_create_request( details: &mut LensDetails, request: &LensCreateRequest, ) -> Result<(), KagiError> { - details.name = normalize_named_target(&request.name, "lens name")?; + details.name = normalize_lens_name(&request.name)?; if let Some(value) = request.included_sites.as_ref() { details.included_sites = value.clone(); } @@ -2870,7 +2905,7 @@ fn apply_lens_update_request( request: &LensUpdateRequest, ) -> Result<(), KagiError> { if let Some(value) = request.name.as_deref() { - details.name = normalize_named_target(value, "lens name")?; + details.name = normalize_lens_name(value)?; } if let Some(value) = request.included_sites.as_ref() { details.included_sites = value.clone(); @@ -3035,6 +3070,90 @@ fn resolve_lens_ref<'a>( .ok_or_else(|| KagiError::Config(format!("no lens matched '{target}'"))) } +async fn execute_lens_get_after_mutation( + target: &str, + token: &str, + operation: &str, +) -> Result { + let mut last_retryable_error = None; + + // Kagi can redirect from a successful lens mutation before the settings + // page reflects the new/updated lens. Retry only that narrow lookup gap. + for attempt in 0..LENS_MUTATION_LOOKUP_ATTEMPTS { + match execute_lens_get(target, token).await { + Ok(details) => return Ok(details), + Err(error) + if attempt + 1 < LENS_MUTATION_LOOKUP_ATTEMPTS + && should_retry_lens_mutation_lookup(&error) => + { + last_retryable_error = Some(error); + sleep(Duration::from_millis(LENS_MUTATION_LOOKUP_DELAY_MS)).await; + } + Err(error) => return Err(error), + } + } + + Err(lens_mutation_lookup_timeout_error( + target, + operation, + last_retryable_error, + )) +} + +async fn resolve_lens_id_by_name_after_mutation( + name: &str, + token: &str, + operation: &str, +) -> Result { + let mut last_retryable_error = None; + + for attempt in 0..LENS_MUTATION_LOOKUP_ATTEMPTS { + match resolve_lens_id_by_name(name, token).await { + Ok(id) => return Ok(id), + Err(error) + if attempt + 1 < LENS_MUTATION_LOOKUP_ATTEMPTS + && should_retry_lens_mutation_lookup(&error) => + { + last_retryable_error = Some(error); + sleep(Duration::from_millis(LENS_MUTATION_LOOKUP_DELAY_MS)).await; + } + Err(error) => return Err(error), + } + } + + Err(lens_mutation_lookup_timeout_error( + name, + operation, + last_retryable_error, + )) +} + +fn lens_mutation_lookup_timeout_error( + target: &str, + operation: &str, + last_error: Option, +) -> KagiError { + match last_error { + Some(error) => KagiError::Config(format!( + "lens '{target}' did not become visible after {operation} within {LENS_MUTATION_LOOKUP_ATTEMPTS} attempts: {error}" + )), + None => KagiError::Config(format!( + "lens '{target}' did not become visible after {operation} within {LENS_MUTATION_LOOKUP_ATTEMPTS} attempts" + )), + } +} + +fn should_retry_lens_mutation_lookup(error: &KagiError) -> bool { + match error { + KagiError::Config(message) => message.starts_with("no lens matched "), + KagiError::Network(message) => { + message.contains("error decoding response body") + && (message.contains("lens settings page") || message.contains("lens form")) + } + _ => false, + } +} + fn resolve_custom_bang_ref<'a>( bangs: &'a [CustomBangSummary], target: &str, @@ -4702,7 +4821,7 @@ mod tests { fake_header_map, finalize_translate_text_response, format_client_error_suffix, normalize_ask_page_question, normalize_ask_page_url, normalize_assistant_query, normalize_assistant_thread_id, normalize_aux_quality, normalize_custom_bang_trigger, - normalize_redirect_rule, normalize_subscriber_summary_input, + normalize_lens_name, normalize_redirect_rule, normalize_subscriber_summary_input, normalize_subscriber_summary_length, normalize_subscriber_summary_type, parse_assistant_prompt_stream, parse_assistant_thread_cursor, parse_assistant_thread_delete_stream, parse_assistant_thread_list_stream, @@ -4710,8 +4829,8 @@ mod tests { parse_subscriber_summarize_stream, parse_translate_detect_value, resolve_custom_assistant_ref, resolve_custom_bang_ref, resolve_lens_ref, resolve_news_category, resolve_redirect_ref, resolve_translate_bootstrap, - should_retry_translate_bootstrap, text_contains_news_filter_keyword, - validate_translate_request, + should_retry_lens_mutation_lookup, should_retry_translate_bootstrap, + text_contains_news_filter_keyword, validate_translate_request, }; use crate::api::{ execute_assistant_prompt, execute_assistant_thread_delete, execute_assistant_thread_export, @@ -5705,6 +5824,25 @@ mod tests { .as_nanos() } + async fn live_assistant_base_model(token: &str) -> String { + let catalog = super::execute_assistant_model_catalog(token) + .await + .expect("assistant model catalog should load"); + catalog + .models + .iter() + .find(|model| model.selected) + .or_else(|| { + catalog + .models + .iter() + .find(|model| model.id == "gpt-5-4-nano") + }) + .or_else(|| catalog.models.first()) + .map(|model| model.id.clone()) + .expect("assistant model catalog should contain at least one model") + } + #[tokio::test] #[ignore] async fn live_assistant_thread_roundtrip() { @@ -5713,12 +5851,13 @@ mod tests { return; }; + let model = live_assistant_base_model(&token).await; let request = AssistantPromptRequest { query: format!("Reply with exactly: assistant-v2-smoke-{}", live_nonce()), thread_id: None, attachments: Vec::new(), profile_id: None, - model: Some("gpt-5-mini".to_string()), + model: Some(model.clone()), lens_id: None, internet_access: Some(true), personalizations: Some(false), @@ -5735,7 +5874,7 @@ mod tests { .as_ref() .and_then(|v| v.get("model")) .and_then(|v| v.as_str()), - Some("gpt-5-mini") + Some(model.as_str()) ); let thread_id = prompt.thread.id.clone(); @@ -5774,6 +5913,7 @@ mod tests { let name = format!("codex-assistant-{nonce}"); let updated_name = format!("{name}-updated"); let bang = format!("ca{nonce}"); + let model = live_assistant_base_model(&token).await; let created = execute_custom_assistant_create( &AssistantProfileCreateRequest { @@ -5782,7 +5922,7 @@ mod tests { internet_access: Some(false), selected_lens: Some("0".to_string()), personalizations: Some(false), - base_model: Some("gpt-5-mini".to_string()), + base_model: Some(model.clone()), custom_instructions: Some("Reply in exactly one sentence.".to_string()), }, &token, @@ -5806,7 +5946,7 @@ mod tests { let fetched = execute_custom_assistant_get(&created_id, &token) .await .expect("custom assistant get should succeed"); - assert_eq!(fetched.base_model, "gpt-5-mini"); + assert_eq!(fetched.base_model, model.as_str()); let prompt = execute_assistant_prompt( &AssistantPromptRequest { @@ -5831,9 +5971,9 @@ mod tests { name: Some(updated_name.clone()), bang_trigger: None, internet_access: Some(true), - selected_lens: Some("22524".to_string()), + selected_lens: Some("0".to_string()), personalizations: Some(true), - base_model: Some("gpt-5-mini".to_string()), + base_model: Some(model), custom_instructions: Some("Use bullet points when useful.".to_string()), }, &token, @@ -5843,7 +5983,7 @@ mod tests { assert_eq!(updated.name, updated_name); assert!(updated.internet_access); - assert_eq!(updated.selected_lens, "22524"); + assert_eq!(updated.selected_lens, "0"); assert!(updated.personalizations); let deleted = execute_custom_assistant_delete(&created_id, &token) @@ -6278,6 +6418,46 @@ mod tests { assert!(!should_retry_translate_bootstrap(&error)); } + #[test] + fn retries_lens_mutation_lookup_when_created_lens_is_not_visible_yet() { + let error = KagiError::Config("no lens matched '30147'".to_string()); + assert!(should_retry_lens_mutation_lookup(&error)); + } + + #[test] + fn retries_lens_mutation_lookup_for_transient_settings_decode_errors() { + let error = KagiError::Network( + "failed to read lens settings page response body: error decoding response body" + .to_string(), + ); + assert!(should_retry_lens_mutation_lookup(&error)); + } + + #[test] + fn does_not_retry_lens_mutation_lookup_for_auth_errors() { + let error = KagiError::Auth("invalid or expired Kagi session token".to_string()); + assert!(!should_retry_lens_mutation_lookup(&error)); + } + + #[test] + fn accepts_lens_names_at_kagi_persisted_length_limit() { + let name = "a".repeat(super::KAGI_LENS_NAME_MAX_CHARS); + assert_eq!(normalize_lens_name(&name).unwrap(), name); + } + + #[test] + fn rejects_lens_names_that_kagi_would_truncate() { + let name = "a".repeat(super::KAGI_LENS_NAME_MAX_CHARS + 1); + let error = normalize_lens_name(&name).expect_err("overlong lens name should fail"); + assert!(error.to_string().contains("Kagi truncates longer names")); + } + + #[test] + fn rejects_empty_lens_names() { + let error = normalize_lens_name(" ").expect_err("empty lens name should fail"); + assert!(error.to_string().contains("lens name cannot be empty")); + } + #[test] fn uses_detected_source_language_when_translate_from_is_auto() { let source = effective_translate_source_language("auto", &sample_detected_language()); @@ -6570,28 +6750,38 @@ mod tests { .await .expect("live translate should succeed"); - let suggestions = response - .translation_suggestions - .as_ref() - .expect("suggestions should be present for ja target"); - let insights = response - .word_insights - .as_ref() - .expect("word insights should be present for ja target"); + if let Some(suggestions) = response.translation_suggestions.as_ref() { + assert!( + suggestions + .suggestions + .iter() + .any(|entry| !entry.label.is_ascii()), + "expected at least one localized suggestion label" + ); + } else { + assert!( + response + .warnings + .iter() + .any(|warning| warning.section == "translation_suggestions") + ); + } - assert!( - suggestions - .suggestions - .iter() - .any(|entry| !entry.label.is_ascii()), - "expected at least one localized suggestion label" - ); - assert!( - insights - .insights - .iter() - .any(|entry| !entry.r#type.is_ascii()), - "expected at least one localized insight type" - ); + if let Some(insights) = response.word_insights.as_ref() { + assert!( + insights + .insights + .iter() + .any(|entry| !entry.r#type.is_ascii()), + "expected at least one localized insight type" + ); + } else { + assert!( + response + .warnings + .iter() + .any(|warning| warning.section == "word_insights") + ); + } } } diff --git a/src/auth.rs b/src/auth.rs index ec2c11d..01e6bde 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -180,7 +180,7 @@ impl CredentialInventory { if let Some(session_token) = self.session_token.clone() { return Ok(SearchCredentials { primary: session_token, - fallback_session: self.api_key.clone(), + fallback_session: None, }); } @@ -951,7 +951,7 @@ mod tests { } #[test] - fn base_search_keeps_api_key_as_fallback_when_session_is_preferred() { + fn base_search_has_no_fallback_when_session_is_preferred() { let inventory = CredentialInventory { api_key: Some(Credential { kind: CredentialKind::ApiKey, @@ -973,13 +973,7 @@ mod tests { .resolve_for_search(SearchAuthRequirement::Base) .expect("base search resolves credential"); assert_eq!(credentials.primary.kind, CredentialKind::SessionToken); - assert_eq!( - credentials - .fallback_session - .expect("api fallback exists") - .kind, - CredentialKind::ApiKey - ); + assert!(credentials.fallback_session.is_none()); } #[test] diff --git a/src/cli.rs b/src/cli.rs index b5972e7..cd8614c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -192,11 +192,11 @@ impl NewsFilterScope { long_about = "Search Kagi from the command line with JSON-first output for agents. Features: -• Shell completion generation (bash, zsh, fish, powershell) -• Multiple output formats (json, toon, pretty, compact, markdown, csv) -• Parallel batch searches with rate limiting -• Colorized terminal output (disable with --no-color) -• Full Kagi API coverage with session token support", +- Shell completion generation (bash, zsh, fish, powershell) +- Multiple output formats (json, toon, pretty, compact, markdown, csv) +- Parallel batch searches with rate limiting +- Colorized terminal output (disable with --no-color) +- Full Kagi API coverage with session token support", propagate_version = true )] #[command(disable_help_subcommand = true)] @@ -223,10 +223,10 @@ pub enum Commands { /// Example: kagi search "rust programming" --format pretty /// /// Features: - /// • Multiple output formats: json (default), toon, pretty, compact, markdown, csv - /// • Colorized pretty output (disable with --no-color) - /// • Lens support for scoped searches - /// • Region, time, date, order, verbatim, and personalization filters + /// - Multiple output formats: json (default), toon, pretty, compact, markdown, csv + /// - Colorized pretty output (disable with --no-color) + /// - Lens support for scoped searches + /// - Region, time, date, order, verbatim, and personalization filters Search(SearchArgs), /// Launch the auth setup wizard or use credential management subcommands Auth(AuthCommand), @@ -273,12 +273,12 @@ pub enum Commands { /// Example: kagi batch "rust" "python" "go" --concurrency 5 --rate-limit 120 /// /// Features: - /// • Parallel execution with configurable concurrency - /// • Token bucket rate limiting to respect API limits - /// • All output formats supported (json, toon, pretty, compact, markdown, csv) - /// • Lens support for scoped searches - /// • Shared region, time, date, order, verbatim, and personalization filters - /// • Color output control with --no-color + /// - Parallel execution with configurable concurrency + /// - Token bucket rate limiting to respect API limits + /// - All output formats supported (json, toon, pretty, compact, markdown, csv) + /// - Lens support for scoped searches + /// - Shared region, time, date, order, verbatim, and personalization filters + /// - Color output control with --no-color Batch(BatchSearchArgs), } diff --git a/src/main.rs b/src/main.rs index 7ee5cca..bdcf73d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -165,20 +165,13 @@ async fn run() -> Result<(), KagiError> { no_personalized: args.no_personalized, }; let request = build_search_request(args.query, &options); - let format_str = match args.format { - cli::OutputFormat::Json => "json", - cli::OutputFormat::Toon => "toon", - cli::OutputFormat::Pretty => "pretty", - cli::OutputFormat::Compact => "compact", - cli::OutputFormat::Markdown => "markdown", - cli::OutputFormat::Csv => "csv", - }; + let format_str = args.format.to_string(); if let Some(follow_count) = args.follow { run_search_follow(request, follow_count, args.limit, profile.as_deref()).await } else { run_search( request, - format_str.to_string(), + format_str, !args.no_color, args.template, args.local_cache, @@ -460,13 +453,7 @@ async fn run() -> Result<(), KagiError> { } else { request }; - let format_str = match args.format { - cli::QuickOutputFormat::Json => "json", - cli::QuickOutputFormat::Toon => "toon", - cli::QuickOutputFormat::Pretty => "pretty", - cli::QuickOutputFormat::Compact => "compact", - cli::QuickOutputFormat::Markdown => "markdown", - }; + let format_str = args.format.to_string(); let response = cached_json( args.local_cache, args.cache_ttl.unwrap_or(900), @@ -475,7 +462,7 @@ async fn run() -> Result<(), KagiError> { || async { execute_quick(&request, &token).await }, ) .await?; - print_quick_response(&response, format_str, !args.no_color) + print_quick_response(&response, &format_str, !args.no_color) } Commands::Translate(args) => { let token = resolve_session_token(profile.as_deref())?; @@ -753,19 +740,12 @@ async fn run() -> Result<(), KagiError> { } args.validate().map_err(KagiError::Config)?; - let format_str = match args.format { - cli::OutputFormat::Json => "json", - cli::OutputFormat::Toon => "toon", - cli::OutputFormat::Pretty => "pretty", - cli::OutputFormat::Compact => "compact", - cli::OutputFormat::Markdown => "markdown", - cli::OutputFormat::Csv => "csv", - }; + let format_str = args.format.to_string(); run_batch_search(BatchSearchConfig { queries: args.queries, concurrency: args.concurrency, rate_limit: args.rate_limit, - format: format_str.to_string(), + format: format_str, use_color: !args.no_color, options: SearchRequestOptions { snap: args.snap, @@ -1555,22 +1535,23 @@ async fn run_search( response.data.truncate(n); } - let output = match format.as_str() { - _ if template.is_some() => { - format_template_response(&response, template.as_deref().unwrap()) - } - "pretty" => format_pretty_response(&response, use_color), - "toon" => { - return print_toon(&response); + let output = if let Some(template) = template.as_deref() { + format_template_response(&response, template) + } else { + match format.as_str() { + "pretty" => format_pretty_response(&response, use_color), + "toon" => { + return print_toon(&response); + } + "compact" => serde_json::to_string(&response).map_err(|error| { + KagiError::Parse(format!("failed to serialize search response: {error}")) + })?, + "markdown" => format_markdown_response(&response), + "csv" => format_csv_response(&response), + _ => serde_json::to_string_pretty(&response).map_err(|error| { + KagiError::Parse(format!("failed to serialize search response: {error}")) + })?, } - "compact" => serde_json::to_string(&response).map_err(|error| { - KagiError::Parse(format!("failed to serialize search response: {error}")) - })?, - "markdown" => format_markdown_response(&response), - "csv" => format_csv_response(&response), - _ => serde_json::to_string_pretty(&response).map_err(|error| { - KagiError::Parse(format!("failed to serialize search response: {error}")) - })?, }; println!("{output}"); @@ -2034,16 +2015,17 @@ async fn run_batch_search(config: BatchSearchConfig<'_>) -> Result<(), KagiError } else { // For human-readable formats, output with headers for (query, response) in results { - let output = match format.as_str() { - _ if template.is_some() => { - format_template_response(&response, template.as_deref().unwrap()) + let output = if let Some(template) = template.as_deref() { + format_template_response(&response, template) + } else { + match format.as_str() { + "pretty" => format_pretty_response(&response, use_color), + "markdown" => format_markdown_response(&response), + "csv" => format_csv_response(&response), + _ => serde_json::to_string_pretty(&response).map_err(|error| { + KagiError::Parse(format!("failed to serialize search response: {error}")) + })?, } - "pretty" => format_pretty_response(&response, use_color), - "markdown" => format_markdown_response(&response), - "csv" => format_csv_response(&response), - _ => serde_json::to_string_pretty(&response).map_err(|error| { - KagiError::Parse(format!("failed to serialize search response: {error}")) - })?, }; println!("=== Results for: {query} ==="); println!("{output}");