Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0644fb2d0b | ||
|
|
1ea6b1a2c5 | ||
|
|
7bb50d54b3 | ||
|
|
e8864c96e2 | ||
|
|
8432f6cd69 | ||
|
|
a8089310a6 | ||
|
|
ab92d2590b |
@@ -39,6 +39,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
fetch-depth: 0 # full history so the changelog step can diff tags
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -125,6 +126,32 @@ jobs:
|
||||
- name: List assets
|
||||
run: ls -la dist
|
||||
|
||||
# Build the changelog from conventional-commit subjects since the previous
|
||||
# tag. GitHub's built-in generate_release_notes only lists merged PRs,
|
||||
# which is near-empty for this commit-to-main workflow — so we render the
|
||||
# commit log ourselves and every release shows what actually changed.
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# fetch-depth:0 gets history, but the tag refs the changelog needs
|
||||
# aren't always present in a detached-HEAD tag checkout — pull them in.
|
||||
git fetch --tags --force --quiet origin 2>/dev/null || true
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
{
|
||||
echo "notes<<__NOTES_EOF__"
|
||||
echo "## What changed"
|
||||
echo ""
|
||||
if [ -n "$PREV" ]; then
|
||||
git log "${PREV}..${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
|
||||
echo ""
|
||||
echo "**Full changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}"
|
||||
else
|
||||
git log "${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
|
||||
fi
|
||||
echo "__NOTES_EOF__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
@@ -133,5 +160,8 @@ jobs:
|
||||
dist/*.tar.gz
|
||||
dist/*.tar.gz.sha256
|
||||
fail_on_unmatched_files: true
|
||||
# keep existing release notes if the release was created beforehand
|
||||
# The commit-based changelog so every release shows what changed. The
|
||||
# first matrix job to run creates the release with these notes;
|
||||
# append_body:false keeps later platform jobs from duplicating them.
|
||||
body: ${{ steps.changelog.outputs.notes }}
|
||||
append_body: false
|
||||
|
||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.2.1"
|
||||
version = "1.2.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.2.1"
|
||||
version = "1.2.3"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+50
-3
@@ -408,6 +408,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||
"reload" => Ok(json!({ "id": id, "action": "reload" })),
|
||||
// Explicit opt-in to raise the active tab to the foreground (the core
|
||||
// skill references it; the daemon handler existed but the CLI didn't map
|
||||
// it — issue #19). Accept the documented camelCase + kebab/lowercase.
|
||||
"bringToFront" | "bring-to-front" | "bringtofront" => {
|
||||
Ok(json!({ "id": id, "action": "bringtofront" }))
|
||||
}
|
||||
|
||||
// === Core Actions ===
|
||||
"click" => {
|
||||
@@ -1496,7 +1502,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// `tabs` (plural) is a natural guess for the `tab` subcommand tree —
|
||||
// alias it so `tabs` / `tabs list` / `tabs new` all work (issue #8.4).
|
||||
"tab" | "tabs" => {
|
||||
match rest.first().copied() {
|
||||
// `--full` makes `tab list` emit untruncated URLs (needed to re-open
|
||||
// a long SSO/redirect URL after a stale session — issue #19). Pick
|
||||
// the subcommand as the first non-flag arg so the flag can appear
|
||||
// anywhere (`tab --full`, `tab list --full`).
|
||||
let full = rest.contains(&"--full");
|
||||
match rest.iter().find(|a| !a.starts_with("--")).copied() {
|
||||
Some("new") => {
|
||||
// Accepted forms:
|
||||
// tab new [url]
|
||||
@@ -1528,7 +1539,13 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
|
||||
Some("list") => {
|
||||
let mut cmd = json!({ "id": id, "action": "tab_list" });
|
||||
if full {
|
||||
cmd["full"] = json!(true);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("close") => {
|
||||
let mut cmd = json!({ "id": id, "action": "tab_close" });
|
||||
if let Some(tab_ref) = rest.get(1) {
|
||||
@@ -1541,7 +1558,13 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
"action": "tab_switch",
|
||||
"tabId": tab_ref,
|
||||
})),
|
||||
None => Ok(json!({ "id": id, "action": "tab_list" })),
|
||||
None => {
|
||||
let mut cmd = json!({ "id": id, "action": "tab_list" });
|
||||
if full {
|
||||
cmd["full"] = json!(true);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3716,6 +3739,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_list_full_flag() {
|
||||
// issue #19: `--full` → untruncated URLs; works as `tab list --full`,
|
||||
// `tab --full`, and `tabs --full`. Plain list has no `full`.
|
||||
for inv in ["tab list --full", "tab --full", "tabs --full"] {
|
||||
let cmd = parse_command(&args(inv), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "tab_list", "{inv}");
|
||||
assert_eq!(cmd["full"], true, "{inv}");
|
||||
}
|
||||
let plain = parse_command(&args("tab list"), &default_flags()).unwrap();
|
||||
assert_eq!(plain["action"], "tab_list");
|
||||
assert!(plain.get("full").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bring_to_front_aliases() {
|
||||
// issue #19: the documented `bringToFront` (+ kebab/lowercase) maps to
|
||||
// the existing daemon action.
|
||||
for inv in ["bringToFront", "bring-to-front", "bringtofront"] {
|
||||
let cmd = parse_command(&args(inv), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "bringtofront", "{inv}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_text_hyphen_and_underscore_aliases() {
|
||||
for verb in ["get-text", "get_text"] {
|
||||
|
||||
@@ -586,6 +586,17 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hidden update-check worker, spawned detached by maybe_notify_update() to
|
||||
// refresh the cached latest version without blocking a real command.
|
||||
if env::args().nth(1).as_deref() == Some("__update-check") {
|
||||
upgrade::run_update_check();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-blocking "update available" hint (stderr only; self-skips meta
|
||||
// commands, daemon mode, CI, and the opt-out env vars).
|
||||
upgrade::maybe_notify_update();
|
||||
|
||||
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
|
||||
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
|
||||
// Ignore SIGPIPE so the daemon isn't killed when the parent drops
|
||||
|
||||
@@ -1364,7 +1364,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
"recording_stop" => handle_recording_stop(state).await,
|
||||
"recording_restart" => handle_recording_restart(cmd, state).await,
|
||||
"pdf" => handle_pdf(cmd, state).await,
|
||||
"tab_list" => handle_tab_list(state).await,
|
||||
"tab_list" => handle_tab_list(cmd, state).await,
|
||||
"tab_new" => handle_tab_new(cmd, state).await,
|
||||
"tab_switch" => handle_tab_switch(cmd, state).await,
|
||||
"tab_close" => handle_tab_close(cmd, state).await,
|
||||
@@ -4355,10 +4355,15 @@ async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
|
||||
// Phase 5 handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
||||
async fn handle_tab_list(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let tabs = mgr.tab_list();
|
||||
Ok(json!({ "tabs": tabs }))
|
||||
// Echo `full` so the formatter prints untruncated URLs (issue #19).
|
||||
if cmd.get("full").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
Ok(json!({ "tabs": tabs, "full": true }))
|
||||
} else {
|
||||
Ok(json!({ "tabs": tabs }))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
|
||||
+10
-2
@@ -477,6 +477,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
// Tabs
|
||||
if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) {
|
||||
// `tab list --full` prints untruncated URLs so a long SSO/redirect
|
||||
// URL can actually be re-opened after a stale session (issue #19).
|
||||
let full = data.get("full").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
for tab in tabs {
|
||||
let tab_id = tab.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let tab_label = tab.get("label").and_then(|v| v.as_str());
|
||||
@@ -491,8 +494,13 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
let title = title.as_str();
|
||||
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
|
||||
// the list stays readable instead of flooding the terminal.
|
||||
let url = truncate_middle(url, 120);
|
||||
// the list stays readable instead of flooding the terminal —
|
||||
// unless `--full` was asked for (to re-open the exact URL).
|
||||
let url = if full {
|
||||
url.to_string()
|
||||
} else {
|
||||
truncate_middle(url, 120)
|
||||
};
|
||||
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let marker = if active {
|
||||
color::cyan("→")
|
||||
|
||||
+140
-1
@@ -1,5 +1,7 @@
|
||||
use crate::color;
|
||||
use std::process::{exit, Command};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -7,6 +9,143 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
/// upgrade path and the install path are identical (GitHub Release, no npm).
|
||||
const INSTALL_URL: &str = "https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh";
|
||||
|
||||
/// GitHub API for the latest published release (used by the update check).
|
||||
const LATEST_RELEASE_API: &str =
|
||||
"https://api.github.com/repos/leeguooooo/chrome-use/releases/latest";
|
||||
|
||||
/// Re-check the latest version at most this often (seconds).
|
||||
const UPDATE_CHECK_INTERVAL_SECS: u64 = 86_400; // once a day
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn update_cache_path() -> PathBuf {
|
||||
crate::connection::config_home().join("update-check.json")
|
||||
}
|
||||
|
||||
fn write_update_cache(checked_at: u64, latest: &str) {
|
||||
let path = update_cache_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let body = serde_json::json!({ "checked_at": checked_at, "latest": latest }).to_string();
|
||||
let _ = std::fs::write(&path, body);
|
||||
}
|
||||
|
||||
/// Parse a dotted version (`1.2.1`, `v1.2.1`, `1.2.1-fork.3`) into a comparable
|
||||
/// `(major, minor, patch)`, ignoring any pre-release/build suffix.
|
||||
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let core = v.trim().trim_start_matches('v');
|
||||
let core = core.split(['-', '+']).next().unwrap_or(core);
|
||||
let mut parts = core.split('.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next().unwrap_or("0").parse().ok()?;
|
||||
let patch = parts.next().unwrap_or("0").parse().ok()?;
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
fn is_newer(latest: &str, current: &str) -> bool {
|
||||
matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c)
|
||||
}
|
||||
|
||||
/// Hidden `__update-check` subcommand: fetch the latest release tag and cache it.
|
||||
/// Spawned detached by [`maybe_notify_update`] so the network call never blocks a
|
||||
/// real command. Uses `curl` (no extra deps, matches `upgrade`).
|
||||
pub fn run_update_check() {
|
||||
let latest = Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
"--max-time",
|
||||
"8",
|
||||
"-H",
|
||||
"User-Agent: chrome-use-update-check",
|
||||
LATEST_RELEASE_API,
|
||||
])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
|
||||
.and_then(|j| {
|
||||
j.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim_start_matches('v').to_string())
|
||||
});
|
||||
if let Some(latest) = latest {
|
||||
write_update_cache(now_secs(), &latest);
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking "update available" notice. Called once per command run:
|
||||
/// - prints a one-line hint to **stderr** (never stdout, so `--json` is clean)
|
||||
/// when a cached release is newer than the running binary;
|
||||
/// - refreshes the cached latest version at most once a day via a **detached**
|
||||
/// background process, so the current command never waits on the network.
|
||||
///
|
||||
/// Skipped for meta commands (upgrade/install/doctor/`__*`/--version/--help),
|
||||
/// in CI, in daemon mode, and when CHROME_USE_NO_UPDATE_CHECK /
|
||||
/// AGENT_BROWSER_NO_UPDATE_CHECK is set.
|
||||
pub fn maybe_notify_update() {
|
||||
if std::env::var_os("CHROME_USE_NO_UPDATE_CHECK").is_some()
|
||||
|| std::env::var_os("AGENT_BROWSER_NO_UPDATE_CHECK").is_some()
|
||||
|| std::env::var_os("CI").is_some()
|
||||
|| std::env::var_os("AGENT_BROWSER_DAEMON").is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let first = std::env::args().nth(1).unwrap_or_default();
|
||||
if first.starts_with("__")
|
||||
|| matches!(
|
||||
first.as_str(),
|
||||
"upgrade" | "install" | "doctor" | "dashboard"
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::args().any(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (checked_at, latest) = std::fs::read_to_string(update_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.map(|j| {
|
||||
(
|
||||
j.get("checked_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
j.get("latest")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, String::new()));
|
||||
|
||||
if is_newer(&latest, CURRENT_VERSION) {
|
||||
eprintln!(
|
||||
"{} chrome-use {latest} is available (you have {CURRENT_VERSION}) — run `chrome-use upgrade`",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh in the background at most once a day. Bump the timestamp first
|
||||
// (keeping the last-known latest) so concurrent runs don't all spawn a
|
||||
// checker, then fire a detached child that does the network fetch.
|
||||
if now_secs().saturating_sub(checked_at) >= UPDATE_CHECK_INTERVAL_SECS {
|
||||
write_update_cache(now_secs(), &latest);
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let _ = Command::new(exe)
|
||||
.arg("__update-check")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade to the latest GitHub Release.
|
||||
///
|
||||
/// The stealth fork ships as a prebuilt binary attached to a GitHub Release —
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.3",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -610,6 +610,13 @@ forbids debugging). The session no longer has a live tab — re-run
|
||||
replaces the old silent behaviour where the command ran on some *other*
|
||||
tab and returned wrong data.
|
||||
|
||||
To recover, you need the tab's **exact** URL (query params and all — a long
|
||||
SSO/redirect link breaks if truncated). `tab list` shortens long URLs with
|
||||
`…`; use **`tab list --full`** to print them untruncated, then re-`open` the
|
||||
right one. For multi-redirect SSO flows, re-open the **stable entry URL**
|
||||
(not the mid-redirect one) and `wait` a few seconds for the SPA to settle
|
||||
before snapshotting.
|
||||
|
||||
**Reads landing on the wrong page**
|
||||
`eval`, `screenshot`, and `network requests` print the page they ran
|
||||
against to stderr: `eval @ <url>`, `screenshot @ <url>`, `network @ <url>`.
|
||||
|
||||
Reference in New Issue
Block a user