From 9ca182a4df19c88adad785c5c1d760f573cef440 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 17 Feb 2026 22:27:44 -0600 Subject: [PATCH] add config (#494) * add config * improvements * cleaner flags * fixes * fixes --- README.md | 39 ++ cli/src/flags.rs | 675 ++++++++++++++++++++++++++-- cli/src/output.rs | 27 ++ docs/src/app/configuration/page.mdx | 136 ++++++ docs/src/components/header.tsx | 2 +- docs/src/lib/docs-navigation.ts | 1 + skills/agent-browser/SKILL.md | 14 + 7 files changed, 858 insertions(+), 36 deletions(-) create mode 100644 docs/src/app/configuration/page.mdx diff --git a/README.md b/README.md index ab0bafa..4b8a2b3 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 ` | 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 ` 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 ` 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) diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 84d41eb..8214374 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -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, + pub json: Option, + pub full: Option, + pub debug: Option, + pub session: Option, + pub session_name: Option, + pub executable_path: Option, + pub extensions: Option>, + pub profile: Option, + pub state: Option, + pub proxy: Option, + pub proxy_bypass: Option, + pub args: Option, + pub user_agent: Option, + pub provider: Option, + pub device: Option, + pub ignore_https_errors: Option, + pub allow_file_access: Option, + pub cdp: Option, + pub auto_connect: Option, + pub headers: Option, +} + +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 { + let content = fs::read_to_string(path).ok()?; + match serde_json::from_str::(&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 from args before full flag parsing. +/// Returns `Some(Some(path))` if --config found, `Some(None)` if --config +/// was the last arg with no value, `None` if --config not present. +fn extract_config_path(args: &[String]) -> Option> { + 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 { + 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 { 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 { "--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 { "--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()])); + } } diff --git a/cli/src/output.rs b/cli/src/output.rs index e5ab55a..7daf60c 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1878,18 +1878,45 @@ Options: --cdp Connect via CDP (Chrome DevTools Protocol) --auto-connect Auto-discover and connect to running Chrome --session-name Auto-save/restore session state (cookies, localStorage) + --config 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 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 diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx new file mode 100644 index 0000000..660152b --- /dev/null +++ b/docs/src/app/configuration/page.mdx @@ -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 ` 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 `** 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`. diff --git a/docs/src/components/header.tsx b/docs/src/components/header.tsx index 498a805..8405860 100644 --- a/docs/src/components/header.tsx +++ b/docs/src/components/header.tsx @@ -66,7 +66,7 @@ export function Header() { > - 13.4k + 14k 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 ` 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 |