diff --git a/cli/src/findurl.rs b/cli/src/findurl.rs new file mode 100644 index 0000000..240a001 --- /dev/null +++ b/cli/src/findurl.rs @@ -0,0 +1,244 @@ +//! `find-url` — search the user's local Chrome/Edge **bookmarks** for pages they +//! saved, by keyword. Borrowed from web-access's `find-url.mjs`; lets an agent +//! locate an internal system or a previously-saved page that public search +//! can't reach, without opening a browser. +//! +//! v1 covers bookmarks only (a zero-dependency JSON read). Visited-history lives +//! in a locked SQLite DB and would need a SQLite dependency — not included yet. + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::color; + +struct Hit { + name: String, + url: String, + folder: String, + date_added: i64, +} + +/// Entry point for the `find-url` subcommand. `args` is the full cleaned argv +/// (including the leading "find-url"). +pub fn run_find_url(args: &[String], json: bool) { + // Parse flags out of args[1..]; everything else is a keyword. + let mut browser = "chrome".to_string(); + let mut profile = "Default".to_string(); + let mut limit: usize = 20; + let mut keywords: Vec = Vec::new(); + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--browser" => { + if let Some(v) = args.get(i + 1) { + browser = v.to_lowercase(); + i += 1; + } + } + "--profile" => { + if let Some(v) = args.get(i + 1) { + profile = v.clone(); + i += 1; + } + } + "--limit" => { + if let Some(v) = args.get(i + 1).and_then(|s| s.parse::().ok()) { + limit = v; + i += 1; + } + } + "--json" => {} + other if other.starts_with("--") => {} + other => keywords.push(other.to_lowercase()), + } + i += 1; + } + + let path = match bookmarks_path(&browser, &profile) { + Some(p) => p, + None => { + emit_error( + json, + &format!("Could not locate {browser} bookmarks for profile '{profile}'"), + ); + return; + } + }; + + let raw = match std::fs::read_to_string(&path) { + Ok(r) => r, + Err(e) => { + emit_error(json, &format!("Failed to read {}: {e}", path.display())); + return; + } + }; + let root: Value = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => { + emit_error(json, &format!("Failed to parse bookmarks JSON: {e}")); + return; + } + }; + + let mut hits: Vec = Vec::new(); + if let Some(roots) = root.get("roots").and_then(|r| r.as_object()) { + for node in roots.values() { + walk(node, "", &keywords, &mut hits); + } + } + + // Most-recently-added first (date_added is microseconds since 1601). + hits.sort_by(|a, b| b.date_added.cmp(&a.date_added)); + hits.truncate(limit); + + if json { + let arr: Vec = hits + .iter() + .map(|h| { + serde_json::json!({ + "name": h.name, + "url": h.url, + "folder": h.folder, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "success": true, + "data": { "results": arr, "count": hits.len() }, + })) + .unwrap_or_default() + ); + return; + } + + if hits.is_empty() { + let kw = if keywords.is_empty() { + String::new() + } else { + format!(" matching {:?}", keywords.join(" ")) + }; + println!("No {browser} bookmarks found{kw}."); + return; + } + for h in &hits { + if h.folder.is_empty() { + println!("{}\n {}", h.name, h.url); + } else { + println!("{} ({})\n {}", h.name, h.folder, h.url); + } + } +} + +/// Recursively walk a bookmark node, collecting URL entries that match every +/// keyword (in name or url). Empty keyword list matches everything. +fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec) { + match node.get("type").and_then(|t| t.as_str()) { + Some("url") => { + let name = node.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let url = node.get("url").and_then(|v| v.as_str()).unwrap_or(""); + // Skip non-navigable bookmarks: javascript: bookmarklets and data: + // URIs aren't pages you can visit, and their bodies can be huge. + if url.is_empty() + || url.starts_with("javascript:") + || url.starts_with("data:") + { + return; + } + let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase()); + if keywords.iter().all(|k| hay.contains(k.as_str())) { + let date_added = node + .get("date_added") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + out.push(Hit { + name: name.to_string(), + url: url.to_string(), + folder: folder.to_string(), + date_added, + }); + } + } + Some("folder") => { + let fname = node.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let child_folder = if folder.is_empty() { + fname.to_string() + } else { + format!("{folder}/{fname}") + }; + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for child in children { + walk(child, &child_folder, keywords, out); + } + } + } + _ => {} + } +} + +/// Resolve the Bookmarks file path for a browser + profile across platforms. +fn bookmarks_path(browser: &str, profile: &str) -> Option { + let base = browser_user_data_dir(browser)?; + let path = base.join(profile).join("Bookmarks"); + if path.exists() { + Some(path) + } else { + None + } +} + +/// The "User Data" directory that holds per-profile folders, per OS/browser. +fn browser_user_data_dir(browser: &str) -> Option { + let is_edge = browser == "edge" || browser == "msedge"; + + #[cfg(target_os = "macos")] + { + let app_support = dirs::config_dir()?; // ~/Library/Application Support + let sub = if is_edge { + "Microsoft Edge" + } else { + "Google/Chrome" + }; + Some(app_support.join(sub)) + } + #[cfg(target_os = "windows")] + { + let local = dirs::data_local_dir()?; // %LOCALAPPDATA% + let sub = if is_edge { + "Microsoft/Edge/User Data" + } else { + "Google/Chrome/User Data" + }; + Some(local.join(sub)) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let config = dirs::config_dir()?; // ~/.config + let sub = if is_edge { + "microsoft-edge" + } else { + "google-chrome" + }; + Some(config.join(sub)) + } +} + +fn emit_error(json: bool, msg: &str) { + if json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "success": false, + "error": msg, + })) + .unwrap_or_default() + ); + } else { + eprintln!("{} {msg}", color::error_indicator()); + } + std::process::exit(1); +} diff --git a/cli/src/main.rs b/cli/src/main.rs index e6c65c3..ab1909e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,6 +3,7 @@ mod color; mod commands; mod connection; mod doctor; +mod findurl; mod flags; mod install; mod native; @@ -631,6 +632,15 @@ fn main() { return; } + // Handle find-url (doesn't need daemon): search local bookmarks + if matches!( + clean.first().map(|s| s.as_str()), + Some("find-url") | Some("findurl") + ) { + findurl::run_find_url(&clean, flags.json); + return; + } + // Handle session separately (doesn't need daemon) if clean.first().map(|s| s.as_str()) == Some("session") { run_session(&clean, &flags.session, flags.json); diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 375ba0f..4b5cb66 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -39,7 +39,7 @@ need a **real, logged-in browser** — not for reading text off a public page. | Discover what exists / find sources | `WebSearch` | | Specific facts from a static or public page | `WebFetch` or `curl` (no browser) | | Login state, interaction, JS-rendered or anti-bot pages | **agent-browser** (this skill) | -| A page the user visited before / an internal system | local bookmarks & history, then agent-browser | +| A page the user saved before / an internal system | `agent-browser find-url ` (their bookmarks), then open it | Don't hand-build deep URLs with query params — links discovered by *interacting* with the site carry the right hidden context and dodge anti-bot checks; a diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 90fe80e..282b48c 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -336,6 +336,22 @@ agent-browser profiler start # Start Chrome DevTools profiling agent-browser profiler stop trace.json # Stop and save profile ``` +### Finding a page the user saved (`find-url`) + +Search the user's local Chrome/Edge **bookmarks** by keyword — for internal +systems or previously-saved pages that public search can't reach. Local read, no +browser/daemon needed. + +```bash +agent-browser find-url jira board # all keywords must match (name or url) +agent-browser find-url --limit 10 invoices +agent-browser find-url --browser edge --profile "Profile 1" wiki +agent-browser find-url grafana --json # {results:[{name,url,folder}], count} +``` + +Results are most-recently-added first. `javascript:`/`data:` bookmarklets are +skipped. (Visited-history search isn't included yet — bookmarks only.) + ### Debugging forms / hidden state with `eval` The a11y `snapshot` shows visible, interactive elements — it does **not** show