add config (#494)
* add config * improvements * cleaner flags * fixes * fixes
This commit is contained in:
@@ -423,8 +423,47 @@ The `-C` flag is useful for modern web apps that use custom clickable elements (
|
||||
| `--headed` | Show browser window (not headless) |
|
||||
| `--cdp <port\|url>` | Connect via Chrome DevTools Protocol (port or WebSocket URL) |
|
||||
| `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) |
|
||||
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
|
||||
| `--debug` | Debug output |
|
||||
|
||||
## Configuration
|
||||
|
||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||
|
||||
**Locations (lowest to highest priority):**
|
||||
|
||||
1. `~/.agent-browser/config.json` -- user-level defaults
|
||||
2. `./agent-browser.json` -- project-level overrides (in working directory)
|
||||
3. `AGENT_BROWSER_*` environment variables override config file values
|
||||
4. CLI flags override everything
|
||||
|
||||
**Example `agent-browser.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"profile": "./browser-data",
|
||||
"userAgent": "my-agent/1.0",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
Use `--config <path>` or `AGENT_BROWSER_CONFIG` to load a specific config file instead of the defaults:
|
||||
|
||||
```bash
|
||||
agent-browser --config ./ci-config.json open example.com
|
||||
AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
||||
```
|
||||
|
||||
All options from the table above can be set in the config file using camelCase keys (e.g., `--executable-path` becomes `"executablePath"`, `--proxy-bypass` becomes `"proxyBypass"`). Unknown keys are ignored for forward compatibility.
|
||||
|
||||
Boolean flags accept an optional `true`/`false` value to override config settings. For example, `--headed false` disables `"headed": true` from config. A bare `--headed` is equivalent to `--headed true`.
|
||||
|
||||
Auto-discovered config files that are missing are silently ignored. If `--config <path>` points to a missing or invalid file, agent-browser exits with an error. Extensions from user and project configs are merged (concatenated), not replaced.
|
||||
|
||||
> **Tip:** If your project-level `agent-browser.json` contains environment-specific values (paths, proxies), consider adding it to `.gitignore`.
|
||||
|
||||
## Selectors
|
||||
|
||||
### Refs (Recommended for AI)
|
||||
|
||||
+640
-35
@@ -1,4 +1,169 @@
|
||||
use crate::color;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const CONFIG_DIR: &str = ".agent-browser";
|
||||
const CONFIG_FILENAME: &str = "config.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
pub headed: Option<bool>,
|
||||
pub json: Option<bool>,
|
||||
pub full: Option<bool>,
|
||||
pub debug: Option<bool>,
|
||||
pub session: Option<String>,
|
||||
pub session_name: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
pub profile: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub proxy_bypass: Option<String>,
|
||||
pub args: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub device: Option<String>,
|
||||
pub ignore_https_errors: Option<bool>,
|
||||
pub allow_file_access: Option<bool>,
|
||||
pub cdp: Option<String>,
|
||||
pub auto_connect: Option<bool>,
|
||||
pub headers: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn merge(self, other: Config) -> Config {
|
||||
Config {
|
||||
headed: other.headed.or(self.headed),
|
||||
json: other.json.or(self.json),
|
||||
full: other.full.or(self.full),
|
||||
debug: other.debug.or(self.debug),
|
||||
session: other.session.or(self.session),
|
||||
session_name: other.session_name.or(self.session_name),
|
||||
executable_path: other.executable_path.or(self.executable_path),
|
||||
extensions: match (self.extensions, other.extensions) {
|
||||
(Some(mut a), Some(b)) => {
|
||||
a.extend(b);
|
||||
Some(a)
|
||||
}
|
||||
(a, b) => b.or(a),
|
||||
},
|
||||
profile: other.profile.or(self.profile),
|
||||
state: other.state.or(self.state),
|
||||
proxy: other.proxy.or(self.proxy),
|
||||
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
|
||||
args: other.args.or(self.args),
|
||||
user_agent: other.user_agent.or(self.user_agent),
|
||||
provider: other.provider.or(self.provider),
|
||||
device: other.device.or(self.device),
|
||||
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
|
||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||
cdp: other.cdp.or(self.cdp),
|
||||
auto_connect: other.auto_connect.or(self.auto_connect),
|
||||
headers: other.headers.or(self.headers),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_config_file(path: &Path) -> Option<Config> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
match serde_json::from_str::<Config>(&content) {
|
||||
Ok(config) => Some(config),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} invalid config file {}: {}",
|
||||
color::warning_indicator(),
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
|
||||
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
|
||||
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
match v.as_str() {
|
||||
"true" => (true, true),
|
||||
"false" => (false, true),
|
||||
_ => (true, false),
|
||||
}
|
||||
} else {
|
||||
(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract --config <path> from args before full flag parsing.
|
||||
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
|
||||
/// was the last arg with no value, `None` if --config not present.
|
||||
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
const FLAGS_WITH_VALUE: &[&str] = &[
|
||||
"--session",
|
||||
"--headers",
|
||||
"--executable-path",
|
||||
"--cdp",
|
||||
"--extension",
|
||||
"--profile",
|
||||
"--state",
|
||||
"--proxy",
|
||||
"--proxy-bypass",
|
||||
"--args",
|
||||
"--user-agent",
|
||||
"-p",
|
||||
"--provider",
|
||||
"--device",
|
||||
"--session-name",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if args[i] == "--config" {
|
||||
return Some(args.get(i + 1).cloned());
|
||||
}
|
||||
if FLAGS_WITH_VALUE.contains(&args[i].as_str()) {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn load_config(args: &[String]) -> Result<Config, String> {
|
||||
let explicit = extract_config_path(args)
|
||||
.map(|p| ("--config", p))
|
||||
.or_else(|| {
|
||||
env::var("AGENT_BROWSER_CONFIG")
|
||||
.ok()
|
||||
.map(|p| ("AGENT_BROWSER_CONFIG", Some(p)))
|
||||
});
|
||||
|
||||
if let Some((source, maybe_path)) = explicit {
|
||||
let path_str =
|
||||
maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
|
||||
let path = PathBuf::from(&path_str);
|
||||
if !path.exists() {
|
||||
return Err(format!("config file not found: {}", path_str));
|
||||
}
|
||||
return read_config_file(&path)
|
||||
.ok_or_else(|| format!("failed to load config from {}", path_str));
|
||||
}
|
||||
|
||||
let user_config = dirs::home_dir()
|
||||
.map(|d| d.join(CONFIG_DIR).join(CONFIG_FILENAME))
|
||||
.and_then(|p| read_config_file(&p))
|
||||
.unwrap_or_default();
|
||||
|
||||
let project_config = read_config_file(&PathBuf::from(PROJECT_CONFIG_FILENAME));
|
||||
|
||||
Ok(match project_config {
|
||||
Some(project) => user_config.merge(project),
|
||||
None => user_config,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct Flags {
|
||||
pub json: bool,
|
||||
@@ -37,6 +202,11 @@ pub struct Flags {
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
let config = load_config(args).unwrap_or_else(|e| {
|
||||
eprintln!("{} {}", color::warning_indicator(), e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
@@ -47,29 +217,53 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let extensions = if !extensions_env.is_empty() {
|
||||
extensions_env
|
||||
} else {
|
||||
config.extensions.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut flags = Flags {
|
||||
json: false,
|
||||
full: false,
|
||||
headed: false,
|
||||
debug: false,
|
||||
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
|
||||
headers: None,
|
||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
|
||||
cdp: None,
|
||||
extensions: extensions_env,
|
||||
profile: env::var("AGENT_BROWSER_PROFILE").ok(),
|
||||
state: env::var("AGENT_BROWSER_STATE").ok(),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY").ok(),
|
||||
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(),
|
||||
args: env::var("AGENT_BROWSER_ARGS").ok(),
|
||||
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(),
|
||||
provider: env::var("AGENT_BROWSER_PROVIDER").ok(),
|
||||
ignore_https_errors: false,
|
||||
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS").is_ok(),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok(),
|
||||
auto_connect: env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok(),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(),
|
||||
// Track CLI-passed flags (default false, set to true when flag is passed)
|
||||
json: env::var("AGENT_BROWSER_JSON").is_ok()
|
||||
|| config.json.unwrap_or(false),
|
||||
full: env::var("AGENT_BROWSER_FULL").is_ok()
|
||||
|| config.full.unwrap_or(false),
|
||||
headed: env::var("AGENT_BROWSER_HEADED").is_ok()
|
||||
|| config.headed.unwrap_or(false),
|
||||
debug: env::var("AGENT_BROWSER_DEBUG").is_ok()
|
||||
|| config.debug.unwrap_or(false),
|
||||
session: env::var("AGENT_BROWSER_SESSION").ok()
|
||||
.or(config.session)
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
headers: config.headers,
|
||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()
|
||||
.or(config.executable_path),
|
||||
cdp: config.cdp,
|
||||
extensions,
|
||||
profile: env::var("AGENT_BROWSER_PROFILE").ok()
|
||||
.or(config.profile),
|
||||
state: env::var("AGENT_BROWSER_STATE").ok()
|
||||
.or(config.state),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY").ok()
|
||||
.or(config.proxy),
|
||||
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok()
|
||||
.or(config.proxy_bypass),
|
||||
args: env::var("AGENT_BROWSER_ARGS").ok()
|
||||
.or(config.args),
|
||||
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok()
|
||||
.or(config.user_agent),
|
||||
provider: env::var("AGENT_BROWSER_PROVIDER").ok()
|
||||
.or(config.provider),
|
||||
ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS").is_ok()
|
||||
|| config.ignore_https_errors.unwrap_or(false),
|
||||
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS").is_ok()
|
||||
|| config.allow_file_access.unwrap_or(false),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok()
|
||||
.or(config.device),
|
||||
auto_connect: env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok()
|
||||
|| config.auto_connect.unwrap_or(false),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok()
|
||||
.or(config.session_name),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_profile: false,
|
||||
@@ -84,10 +278,26 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--json" => flags.json = true,
|
||||
"--full" | "-f" => flags.full = true,
|
||||
"--headed" => flags.headed = true,
|
||||
"--debug" => flags.debug = true,
|
||||
"--json" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.json = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--full" | "-f" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.full = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--headed" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.headed = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--debug" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.debug = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--session" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session = s.clone();
|
||||
@@ -168,10 +378,16 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--ignore-https-errors" => flags.ignore_https_errors = true,
|
||||
"--ignore-https-errors" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.ignore_https_errors = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--allow-file-access" => {
|
||||
flags.allow_file_access = true;
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.allow_file_access = val;
|
||||
flags.cli_allow_file_access = true;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--device" => {
|
||||
if let Some(d) = args.get(i + 1) {
|
||||
@@ -179,13 +395,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--auto-connect" => flags.auto_connect = true,
|
||||
"--auto-connect" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.auto_connect = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
@@ -197,8 +421,8 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
let mut skip_next = false;
|
||||
|
||||
// Global flags that should be stripped from command args
|
||||
const GLOBAL_FLAGS: &[&str] = &[
|
||||
// Boolean flags that optionally take true/false
|
||||
const GLOBAL_BOOL_FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--full",
|
||||
"--headed",
|
||||
@@ -207,7 +431,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
];
|
||||
// Global flags that take a value (need to skip the next arg too)
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
||||
"--session",
|
||||
"--headers",
|
||||
@@ -224,22 +448,33 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--provider",
|
||||
"--device",
|
||||
"--session-name",
|
||||
"--config",
|
||||
];
|
||||
|
||||
for arg in args.iter() {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
|
||||
skip_next = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Only strip known global flags, not command-specific flags
|
||||
if GLOBAL_FLAGS.contains(&arg.as_str()) || arg == "-f" {
|
||||
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) || arg == "-f" {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
if matches!(v.as_str(), "true" | "false") {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
result.push(arg.clone());
|
||||
i += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -401,4 +636,374 @@ mod tests {
|
||||
assert!(!flags.cli_extensions);
|
||||
assert!(!flags.cli_state);
|
||||
}
|
||||
|
||||
// === Config file tests ===
|
||||
|
||||
#[test]
|
||||
fn test_config_deserialize_full() {
|
||||
let json = r#"{
|
||||
"headed": true,
|
||||
"json": true,
|
||||
"full": true,
|
||||
"debug": true,
|
||||
"session": "test-session",
|
||||
"sessionName": "my-app",
|
||||
"executablePath": "/usr/bin/chromium",
|
||||
"extensions": ["/ext1", "/ext2"],
|
||||
"profile": "/tmp/profile",
|
||||
"state": "/tmp/state.json",
|
||||
"proxy": "http://proxy:8080",
|
||||
"proxyBypass": "localhost",
|
||||
"args": "--no-sandbox",
|
||||
"userAgent": "test-agent",
|
||||
"provider": "ios",
|
||||
"device": "iPhone 15",
|
||||
"ignoreHttpsErrors": true,
|
||||
"allowFileAccess": true,
|
||||
"cdp": "9222",
|
||||
"autoConnect": true,
|
||||
"headers": "{\"Auth\":\"token\"}"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
assert_eq!(config.json, Some(true));
|
||||
assert_eq!(config.full, Some(true));
|
||||
assert_eq!(config.debug, Some(true));
|
||||
assert_eq!(config.session.as_deref(), Some("test-session"));
|
||||
assert_eq!(config.session_name.as_deref(), Some("my-app"));
|
||||
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
|
||||
assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()]));
|
||||
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
|
||||
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
|
||||
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
|
||||
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
|
||||
assert_eq!(config.args.as_deref(), Some("--no-sandbox"));
|
||||
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
|
||||
assert_eq!(config.provider.as_deref(), Some("ios"));
|
||||
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
|
||||
assert_eq!(config.ignore_https_errors, Some(true));
|
||||
assert_eq!(config.allow_file_access, Some(true));
|
||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||
assert_eq!(config.auto_connect, Some(true));
|
||||
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserialize_partial() {
|
||||
let json = r#"{"headed": true, "proxy": "http://localhost:8080"}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
assert_eq!(config.proxy.as_deref(), Some("http://localhost:8080"));
|
||||
assert_eq!(config.session, None);
|
||||
assert_eq!(config.extensions, None);
|
||||
assert_eq!(config.debug, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserialize_empty() {
|
||||
let config: Config = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(config.headed, None);
|
||||
assert_eq!(config.session, None);
|
||||
assert_eq!(config.proxy, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_ignores_unknown_keys() {
|
||||
let json = r#"{"headed": true, "unknownFutureKey": "value", "anotherOne": 42}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_merge_project_overrides_user() {
|
||||
let user = Config {
|
||||
headed: Some(true),
|
||||
proxy: Some("http://user-proxy:8080".to_string()),
|
||||
profile: Some("/user/profile".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
let project = Config {
|
||||
proxy: Some("http://project-proxy:9090".to_string()),
|
||||
debug: Some(true),
|
||||
..Config::default()
|
||||
};
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.headed, Some(true)); // kept from user
|
||||
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090")); // overridden by project
|
||||
assert_eq!(merged.profile.as_deref(), Some("/user/profile")); // kept from user
|
||||
assert_eq!(merged.debug, Some(true)); // added by project
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_merge_none_does_not_override() {
|
||||
let user = Config {
|
||||
headed: Some(true),
|
||||
proxy: Some("http://proxy:8080".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
let project = Config::default();
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.headed, Some(true));
|
||||
assert_eq!(merged.proxy.as_deref(), Some("http://proxy:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_from_file() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("ab-test-config");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("test-config.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, r#"{{"headed": true, "proxy": "http://test:1234"}}"#).unwrap();
|
||||
|
||||
let config = read_config_file(&config_path).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
assert_eq!(config.proxy.as_deref(), Some("http://test:1234"));
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_missing_file_returns_none() {
|
||||
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_malformed_json_returns_none() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("ab-test-malformed");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("bad-config.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, "{{not valid json}}").unwrap();
|
||||
|
||||
let result = read_config_file(&config_path);
|
||||
assert!(result.is_none());
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_config_path() {
|
||||
assert_eq!(
|
||||
extract_config_path(&args("--config ./my-config.json open example.com")),
|
||||
Some(Some("./my-config.json".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_config_path_missing() {
|
||||
assert_eq!(extract_config_path(&args("open example.com")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_config_path_no_value() {
|
||||
assert_eq!(extract_config_path(&args("--config")), Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_config_path_skips_flag_values() {
|
||||
assert_eq!(extract_config_path(&args("--args --config open")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_config() {
|
||||
let cleaned = clean_args(&args("--config ./config.json open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_with_config_flag() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("ab-test-flag-config");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("custom.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, r#"{{"headed": true, "session": "custom"}}"#).unwrap();
|
||||
|
||||
let flag_args = vec![
|
||||
"--config".to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
];
|
||||
let config = load_config(&flag_args).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
assert_eq!(config.session.as_deref(), Some("custom"));
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_error_missing_config_value() {
|
||||
let result = load_config(&args("--config"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("requires a file path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_error_nonexistent_file() {
|
||||
let result = load_config(&args("--config /nonexistent/config.json open"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("config file not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_error_malformed_explicit() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("ab-test-explicit-malformed");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("bad.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, "{{not valid}}").unwrap();
|
||||
|
||||
let flag_args = vec![
|
||||
"--config".to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
];
|
||||
let result = load_config(&flag_args);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("failed to load config"));
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
// === Boolean flag value tests ===
|
||||
|
||||
#[test]
|
||||
fn test_headed_false() {
|
||||
let flags = parse_flags(&args("--headed false open example.com"));
|
||||
assert!(!flags.headed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_headed_true_explicit() {
|
||||
let flags = parse_flags(&args("--headed true open example.com"));
|
||||
assert!(flags.headed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_headed_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--headed open example.com"));
|
||||
assert!(flags.headed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_false() {
|
||||
let flags = parse_flags(&args("--debug false open example.com"));
|
||||
assert!(!flags.debug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_false() {
|
||||
let flags = parse_flags(&args("--json false open example.com"));
|
||||
assert!(!flags.json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_https_errors_false() {
|
||||
let flags = parse_flags(&args("--ignore-https-errors false open"));
|
||||
assert!(!flags.ignore_https_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_file_access_false() {
|
||||
let flags = parse_flags(&args("--allow-file-access false open"));
|
||||
assert!(!flags.allow_file_access);
|
||||
assert!(flags.cli_allow_file_access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_connect_false() {
|
||||
let flags = parse_flags(&args("--auto-connect false open"));
|
||||
assert!(!flags.auto_connect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--full open example.com"));
|
||||
assert!(flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_false() {
|
||||
let flags = parse_flags(&args("--full false open example.com"));
|
||||
assert!(!flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_short_flag() {
|
||||
let flags = parse_flags(&args("-f open example.com"));
|
||||
assert!(flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_full_with_value() {
|
||||
let cleaned = clean_args(&args("--full false open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_short_full() {
|
||||
let cleaned = clean_args(&args("-f open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_bool_flag_with_value() {
|
||||
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_bare_bool_flag() {
|
||||
let cleaned = clean_args(&args("--headed --debug open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
// === Extensions merge tests ===
|
||||
|
||||
#[test]
|
||||
fn test_config_merge_extensions_concatenated() {
|
||||
let user = Config {
|
||||
extensions: Some(vec!["/ext1".to_string()]),
|
||||
..Config::default()
|
||||
};
|
||||
let project = Config {
|
||||
extensions: Some(vec!["/ext2".to_string(), "/ext3".to_string()]),
|
||||
..Config::default()
|
||||
};
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(
|
||||
merged.extensions,
|
||||
Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_merge_extensions_user_only() {
|
||||
let user = Config {
|
||||
extensions: Some(vec!["/ext1".to_string()]),
|
||||
..Config::default()
|
||||
};
|
||||
let project = Config::default();
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.extensions, Some(vec!["/ext1".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_merge_extensions_project_only() {
|
||||
let user = Config::default();
|
||||
let project = Config {
|
||||
extensions: Some(vec!["/ext2".to_string()]),
|
||||
..Config::default()
|
||||
};
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1878,18 +1878,45 @@ Options:
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
--version, -V Show version
|
||||
|
||||
Configuration:
|
||||
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
||||
1. ~/.agent-browser/config.json User-level defaults
|
||||
2. ./agent-browser.json Project-level overrides
|
||||
3. Environment variables Override config file values
|
||||
4. CLI flags Override everything
|
||||
|
||||
Use --config <path> to load a specific config file instead of the defaults.
|
||||
If --config points to a missing or invalid file, agent-browser exits with an error.
|
||||
|
||||
Boolean flags accept an optional true/false value to override config:
|
||||
--headed (same as --headed true)
|
||||
--headed false (disables "headed": true from config)
|
||||
|
||||
Extensions from user and project configs are merged (not replaced).
|
||||
|
||||
Example agent-browser.json:
|
||||
{{"headed": true, "proxy": "http://localhost:8080", "profile": "./browser-data"}}
|
||||
|
||||
Environment:
|
||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||
AGENT_BROWSER_SESSION Session name (default: "default")
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name
|
||||
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
||||
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless)
|
||||
AGENT_BROWSER_JSON JSON output
|
||||
AGENT_BROWSER_FULL Full page screenshot
|
||||
AGENT_BROWSER_DEBUG Debug output
|
||||
AGENT_BROWSER_IGNORE_HTTPS_ERRORS Ignore HTTPS certificate errors
|
||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
||||
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
||||
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
|
||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
||||
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
||||
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
export const metadata = { title: "Configuration" }
|
||||
|
||||
# Configuration
|
||||
|
||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||
|
||||
## Config File Locations
|
||||
|
||||
agent-browser checks two locations, merged in priority order:
|
||||
|
||||
| Priority | Location | Scope |
|
||||
|----------|----------|-------|
|
||||
| 1 (lowest) | `~/.agent-browser/config.json` | User-level defaults |
|
||||
| 2 | `./agent-browser.json` | Project-level overrides |
|
||||
| 3 | `AGENT_BROWSER_*` env vars | Override config values |
|
||||
| 4 (highest) | CLI flags | Override everything |
|
||||
|
||||
Project-level values override user-level values. Environment variables override both. CLI flags always win.
|
||||
|
||||
Use `--config <path>` or the `AGENT_BROWSER_CONFIG` environment variable to load a specific config file instead of the default locations:
|
||||
|
||||
```bash
|
||||
agent-browser --config ./ci-config.json open example.com
|
||||
AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
||||
```
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"profile": "./browser-data",
|
||||
"userAgent": "my-agent/1.0",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
## All Options
|
||||
|
||||
Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
|
||||
| Config Key | CLI Flag | Type |
|
||||
|------------|----------|------|
|
||||
| `headed` | `--headed` | boolean |
|
||||
| `json` | `--json` | boolean |
|
||||
| `full` | `--full, -f` | boolean |
|
||||
| `debug` | `--debug` | boolean |
|
||||
| `session` | `--session` | string |
|
||||
| `sessionName` | `--session-name` | string |
|
||||
| `executablePath` | `--executable-path` | string |
|
||||
| `extensions` | `--extension` | string[] |
|
||||
| `profile` | `--profile` | string |
|
||||
| `state` | `--state` | string |
|
||||
| `proxy` | `--proxy` | string |
|
||||
| `proxyBypass` | `--proxy-bypass` | string |
|
||||
| `args` | `--args` | string |
|
||||
| `userAgent` | `--user-agent` | string |
|
||||
| `provider` | `-p, --provider` | string |
|
||||
| `device` | `--device` | string |
|
||||
| `ignoreHttpsErrors` | `--ignore-https-errors` | boolean |
|
||||
| `allowFileAccess` | `--allow-file-access` | boolean |
|
||||
| `cdp` | `--cdp` | string |
|
||||
| `autoConnect` | `--auto-connect` | boolean |
|
||||
| `headers` | `--headers` | string (JSON) |
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Local Development
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"profile": "./browser-data"
|
||||
}
|
||||
```
|
||||
|
||||
### Behind a Proxy
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy": "http://proxy.corp.example.com:8080",
|
||||
"proxyBypass": "localhost,*.internal.com",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
### CI / Devcontainer
|
||||
|
||||
```json
|
||||
{
|
||||
"args": "--no-sandbox,--disable-gpu",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
### iOS Testing
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "ios",
|
||||
"device": "iPhone 16 Pro"
|
||||
}
|
||||
```
|
||||
|
||||
## Overriding Boolean Options
|
||||
|
||||
Boolean flags accept an optional `true`/`false` value to override config settings:
|
||||
|
||||
```bash
|
||||
agent-browser --headed false open example.com
|
||||
```
|
||||
|
||||
A bare flag is equivalent to passing `true`:
|
||||
|
||||
```bash
|
||||
agent-browser --headed open example.com # same as --headed true
|
||||
agent-browser --headed true open example.com # explicit
|
||||
```
|
||||
|
||||
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`.
|
||||
|
||||
## Extensions Merging
|
||||
|
||||
Extensions from user-level and project-level configs are **concatenated**, not replaced. For example, if `~/.agent-browser/config.json` specifies `["/ext1"]` and `./agent-browser.json` specifies `["/ext2"]`, the result is `["/ext1", "/ext2"]`.
|
||||
|
||||
The `AGENT_BROWSER_EXTENSIONS` environment variable and CLI `--extension` flags follow the standard priority rules (env replaces config, CLI appends).
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Auto-discovered config files** (`~/.agent-browser/config.json`, `./agent-browser.json`) that are missing are silently ignored.
|
||||
- **`--config <path>`** with a missing or malformed file exits with an error.
|
||||
- **Malformed JSON** in auto-discovered files prints a warning to stderr and continues without that file.
|
||||
- **Unknown keys** are silently ignored for forward compatibility.
|
||||
|
||||
> **Tip:** If your project-level `agent-browser.json` contains environment-specific values (paths, proxies), consider adding it to `.gitignore`.
|
||||
@@ -66,7 +66,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>13.4k</span>
|
||||
<span>14k</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/agent-browser"
|
||||
|
||||
@@ -21,6 +21,7 @@ export const navigation: NavSection[] = [
|
||||
title: "Reference",
|
||||
items: [
|
||||
{ name: "Commands", href: "/commands" },
|
||||
{ name: "Configuration", href: "/configuration" },
|
||||
{ name: "Selectors", href: "/selectors" },
|
||||
{ name: "Snapshots", href: "/snapshots" },
|
||||
],
|
||||
|
||||
@@ -300,6 +300,20 @@ agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map
|
||||
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
|
||||
- Programmatic/generated scripts -> use `eval -b` with base64
|
||||
|
||||
## Configuration File
|
||||
|
||||
Create `agent-browser.json` in the project root for persistent settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"profile": "./browser-data"
|
||||
}
|
||||
```
|
||||
|
||||
Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config <path>` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--executable-path` -> `"executablePath"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Extensions from user and project configs are merged, not replaced.
|
||||
|
||||
## Deep-Dive Documentation
|
||||
|
||||
| Reference | When to Use |
|
||||
|
||||
Reference in New Issue
Block a user