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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "keenable-cli"
version = "0.1.22"
version = "0.1.23"
edition = "2024"
description = "Keenable CLI — authenticate, manage API keys, configure MCP, and search the web"
authors = ["grigorevp <grigoryev.pete@gmail.com>"]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Works without login (free tier). Log in for higher rate limits.
```bash
keenable fetch https://example.com # Fetch page content
keenable fetch https://example.com -p # Pretty output
keenable fetch https://example.com --live # Fetch the live page (skip cache)
```

### Authentication
Expand Down
11 changes: 9 additions & 2 deletions src/commands/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,13 @@ async fn execute(req: &DaemonRequest, api_key_override: Option<&str>) -> Result<
}
"fetch" => {
let urls = req.urls.as_ref().ok_or_else(|| missing("urls"))?;
let mut query: Vec<(&str, &str)> = urls.iter().map(|u| ("url", u.as_str())).collect();
if req.live {
query.push(("live", "true"));
}
let resp = client
.get(endpoint("/v1/fetch", authenticated))
.query(&urls.iter().map(|u| ("url", u)).collect::<Vec<_>>())
.query(&query)
.send()
.await
.map_err(send_err)?;
Expand Down Expand Up @@ -326,6 +330,7 @@ pub async fn search(
command: "search".to_string(),
urls: None,
body: Some(body),
live: false,
};

let api_key = key_override(api_key);
Expand Down Expand Up @@ -380,11 +385,12 @@ pub async fn search(
}
}

pub async fn fetch(url: &str, human: bool, api_key: Option<&str>) {
pub async fn fetch(url: &str, live: bool, human: bool, api_key: Option<&str>) {
let req = DaemonRequest {
command: "fetch".to_string(),
urls: Some(vec![url.to_string()]),
body: None,
live,
};

let api_key = key_override(api_key);
Expand Down Expand Up @@ -460,6 +466,7 @@ pub async fn feedback(query: &str, scores: &[String], human: bool, api_key: Opti
command: "feedback".to_string(),
urls: None,
body: Some(body),
live: false,
};

let api_key = key_override(api_key);
Expand Down
10 changes: 9 additions & 1 deletion src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ pub struct DaemonRequest {
pub urls: Option<Vec<String>>,
#[serde(default)]
pub body: Option<Value>,
/// Fetch only: request live content instead of the cached copy.
#[serde(default)]
pub live: bool,
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Old daemon ignores --live 🐞 Bug ≡ Correctness

The CLI reuses any already-running daemon without checking capabilities, so after an upgrade an
older daemon can keep serving fetch requests but cannot forward live=true, causing `fetch
--live to return cached content until the daemon restarts. Because execute()` returns immediately
on successful daemon responses, there is no opportunity to fall back to the direct HTTP path for
--live freshness semantics.
Agent Prompt
### Issue description
`fetch --live` depends on daemon support to append `live=true`, but the CLI will reuse an already-running daemon process without any version/feature negotiation. If the user upgrades the CLI while an older daemon is still running, the request can succeed yet not be “live” (cached content returned) until the daemon exits/restarts.

### Issue Context
- The CLI tries the daemon first and returns the daemon’s successful response immediately.
- The daemon only appends the `live=true` query param when it understands/implements `req.live`.
- There’s explicit precedent in the codebase that older daemons can remain running and behave differently (see comments in `kill_daemon`).

### Fix Focus Areas
- src/commands/search.rs[96-170]
- src/commands/search.rs[388-420]
- src/daemon.rs[383-420]
- src/daemon.rs[260-314]
- src/daemon.rs[316-339]

### Suggested fix
Choose one (or combine):
1) **Bypass daemon when `req.live == true`**: in `execute()`, skip the daemon fast-path for fetch-live so the direct HTTP request always includes `live=true`.
2) **Force daemon refresh on `--live`**: if `req.live` is true and a daemon is running, call `kill_daemon()` then `ensure_daemon()` to guarantee a daemon with live support.
3) **Add a capability handshake**: extend `ping` to return a version/feature set; when `--live` is requested, only use daemon if it advertises support, otherwise fall back to direct HTTP.

(Option 1 is the simplest/least invasive and makes `--live` semantics reliable immediately after an upgrade.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

impl DaemonRequest {
Expand Down Expand Up @@ -277,10 +280,15 @@ mod platform {
Some(u) => u,
None => return err_response("Missing urls"),
};
let mut query: Vec<(&str, &str)> =
urls.iter().map(|u| ("url", u.as_str())).collect();
if req.live {
query.push(("live", "true"));
}
send_api(
client
.get(endpoint("/v1/fetch", authenticated))
.query(&urls.iter().map(|u| ("url", u)).collect::<Vec<_>>()),
.query(&query),
)
.await
}
Expand Down
9 changes: 7 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,16 @@ enum Commands {

/// Fetch page content as markdown (outputs YAML by default, use -p for pretty output)
#[command(
after_help = "Works without login (free tier). Log in for higher rate limits.\n\nExamples:\n keenable fetch https://example.com YAML output\n keenable fetch https://example.com -p Pretty output\n keenable fetch https://example.com --api-key keen_***_***** Use a specific API key"
after_help = "Works without login (free tier). Log in for higher rate limits.\n\nExamples:\n keenable fetch https://example.com YAML output\n keenable fetch https://example.com -p Pretty output\n keenable fetch https://example.com --live Fetch the live page (skip cache)\n keenable fetch https://example.com --api-key keen_***_***** Use a specific API key"
)]
Fetch {
/// URL to fetch
url: String,

/// Fetch the live page instead of the cached copy
#[arg(long)]
live: bool,

/// Pretty-print output for humans instead of YAML
#[arg(short = 'p', long = "pretty")]
pretty: bool,
Expand Down Expand Up @@ -319,10 +323,11 @@ async fn main() {
}
Commands::Fetch {
url,
live,
pretty,
api_key,
} => {
commands::search::fetch(&url, pretty, api_key.as_deref()).await;
commands::search::fetch(&url, live, pretty, api_key.as_deref()).await;
}
Commands::Feedback {
query,
Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/test_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ def test_fetch_single_url(kn):
assert "description" not in data


def test_fetch_live(kn):
res = kn("fetch", "https://example.com", "--live")
assert res.code == 0
data = res.yaml()
assert data["title"] == "Example Domain"
assert "# Example Domain" in data["content"]


def test_pretty_fetch(kn):
res = kn("fetch", "https://example.com", "-p")
assert res.code == 0
Expand Down
Loading