Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ pub fn parse_cdp_headers(raw: &str) -> Result<Value, String> {
Ok(headers)
}

/// Parses command arguments into normalized command data.
/// Parses command arguments, including literal fill/type text after `--`.
/// Wait selector `--state` values are accepted only for selector waits.
pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
let mut result = parse_command_inner(args, flags)?;
Expand Down Expand Up @@ -476,7 +476,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
context: "fill".to_string(),
usage: "fill <selector> <text>",
})?;
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
let text = if rest.get(1) == Some(&"--") {
&rest[2..]
} else {
&rest[1..]
};
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": text.join(" ") }))
}
"type" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
Expand All @@ -492,6 +497,10 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
let mut i = 1;
while i < rest.len() {
match rest[i] {
"--" => {
text_parts.extend_from_slice(&rest[i + 1..]);
break;
}
"--clear" => clear = true,
"--delay" => {
// Error rather than silently drop a malformed value;
Expand Down Expand Up @@ -4703,6 +4712,29 @@ mod tests {
assert_eq!(cmd["value"], "hello world");
}

#[test]
fn test_fill_and_type_preserve_text_after_end_of_options() {
let fill_args = args("fill #input -- --config /missing --json café 世界");
let fill = parse_command(&crate::flags::clean_args(&fill_args), &default_flags()).unwrap();
assert_eq!(fill["value"], "--config /missing --json café 世界");

let empty_fill = parse_command(&args("fill #input --"), &default_flags()).unwrap();
assert_eq!(empty_fill["value"], "");

let type_args =
args("type #input --clear --delay 7 -- --clear --delay 99 café 世界 -- --help");
let typed = parse_command(&crate::flags::clean_args(&type_args), &default_flags()).unwrap();
assert_eq!(typed["clear"], true);
assert_eq!(typed["delay"], 7);
assert_eq!(typed["text"], "--clear --delay 99 café 世界 -- --help");

let empty_type =
parse_command(&args("type #input --clear --delay 7 --"), &default_flags()).unwrap();
assert_eq!(empty_type["clear"], true);
assert_eq!(empty_type["delay"], 7);
assert_eq!(empty_type["text"], "");
}

#[test]
fn test_type_command() {
let cmd = parse_command(&args("type #input some text"), &default_flags()).unwrap();
Expand Down
40 changes: 40 additions & 0 deletions cli/src/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,9 @@ pub(crate) fn extract_config_path(args: &[String]) -> Option<Option<String>> {
];
let mut i = 0;
while i < args.len() {
if args[i] == "--" {
break;
}
if args[i] == "--config" {
return Some(args.get(i + 1).cloned());
}
Expand Down Expand Up @@ -823,6 +826,9 @@ pub fn try_parse_flags(args: &[String]) -> Result<Flags, String> {
let mut wait_command_seen = false;
while i < args.len() {
let arg = args[i].as_str();
if arg == "--" {
break;
}
if !arg.starts_with('-') && looks_like_command(arg) {
if !seen_command {
wait_command_seen = arg == "wait";
Expand Down Expand Up @@ -1290,6 +1296,14 @@ fn looks_like_command(value: &str) -> bool {
crate::commands::is_top_level_command(value)
}

/// Return arguments before the end-of-options marker.
pub fn before_end_of_options(args: &[String]) -> &[String] {
&args[..args
.iter()
.position(|arg| arg == "--")
.unwrap_or(args.len())]
}

pub fn clean_args(args: &[String]) -> Vec<String> {
let mut result = Vec::new();
let mut skip_next = false;
Expand Down Expand Up @@ -1374,6 +1388,10 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
i += 1;
continue;
}
if arg == "--" {
result.extend_from_slice(&args[i..]);
break;
}
if arg.starts_with("--restore=") {
i += 1;
continue;
Expand Down Expand Up @@ -2214,6 +2232,28 @@ mod tests {
assert_eq!(extract_config_path(&args("--args --config open")), None);
}

#[test]
fn test_end_of_options_stops_global_and_config_parsing() {
let env = EnvGuard::new(&["AGENT_BROWSER_CONFIG"]);
env.remove("AGENT_BROWSER_CONFIG");
let input = args("fill #field -- --config /missing --json --session literal");
let baseline = try_parse_flags(&args("fill #field")).unwrap();
let flags = try_parse_flags(&input).unwrap();

assert_eq!(flags.json, baseline.json);
assert_eq!(flags.session, baseline.session);
assert_eq!(extract_config_path(&input), None);
assert_eq!(
clean_args(&input),
args("fill #field -- --config /missing --json --session literal")
);

let help_args = args("fill #field --help -- --version");
assert_eq!(before_end_of_options(&help_args), &help_args[..3]);
let version_args = args("fill #field --version -- --help");
assert_eq!(before_end_of_options(&version_args), &version_args[..3]);
}

#[test]
fn test_clean_args_removes_config() {
let cleaned = clean_args(&args("--config ./config.json open example.com"));
Expand Down
5 changes: 3 additions & 2 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1492,8 +1492,9 @@ fn main() {
let clean = clean_args(&args);
tls::configure_process(&flags);

let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
let options = flags::before_end_of_options(&clean);
let has_help = options.iter().any(|a| a == "--help" || a == "-h");
let has_version = options.iter().any(|a| a == "--version" || a == "-V");

if has_help {
if let Some(cmd) = clean.first() {
Expand Down
125 changes: 112 additions & 13 deletions cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2661,9 +2661,22 @@ fn cli_tool_args(
// a selector could otherwise carry its own --allowed-domains or --session.
let trusted: Vec<&str> = args.iter().map(|a| global_flag_name(a)).collect();
let wait_selector_state = wait_selector_state_flag_index(&command_args);
let text_boundary = if matches!(
command_args.first().map(String::as_str),
Some("fill" | "type")
) {
command_args
.iter()
.enumerate()
.skip(2)
.find_map(|(i, arg)| (arg == "--").then_some(i))
} else {
None
};
let tokens = command_args
.iter()
.enumerate()
.take(text_boundary.unwrap_or(command_args.len()))
.map(|(i, t)| (t, MCP_TOOL_GLOBALS, Some(i)))
.chain(extra_args.iter().map(|t| (t, &[][..], None)));
for (token, tool_globals, command_index) in tokens {
Expand Down Expand Up @@ -2692,8 +2705,21 @@ fn cli_tool_args(
)));
}
}
args.extend(command_args);
args.extend(extra_args);
if let Some(boundary) = text_boundary {
args.extend(command_args[..boundary].iter().cloned());
args.extend(extra_args);
args.push(command_args[boundary].clone());
let cleaned = crate::flags::clean_args(&args);
if crate::flags::before_end_of_options(&cleaned).len() + 1 != cleaned.len() {
return Err(ProtocolError::invalid_params(
"Tool extraArgs must preserve the text boundary",
));
}
args.extend(command_args[boundary + 1..].iter().cloned());
} else {
args.extend(command_args);
args.extend(extra_args);
}
Ok(args)
}

Expand Down Expand Up @@ -2942,23 +2968,29 @@ fn call_click(arguments: &Value) -> Result<Value, ProtocolError> {
}

fn call_fill(arguments: &Value) -> Result<Value, ProtocolError> {
let selector = required_string(arguments, "selector")?;
let text = required_string(arguments, "text")?;
call_cli_tool(arguments, vec!["fill".to_string(), selector, text], None)
call_cli_tool(arguments, text_command_args(arguments, "fill")?, None)
}

fn call_type(arguments: &Value) -> Result<Value, ProtocolError> {
call_cli_tool(arguments, text_command_args(arguments, "type")?, None)
}

fn text_command_args(arguments: &Value, command: &str) -> Result<Vec<String>, ProtocolError> {
let selector = required_string(arguments, "selector")?;
let text = required_string(arguments, "text")?;
let mut args = vec!["type".to_string(), selector, text];
if optional_bool(arguments, "clear")?.unwrap_or(false) {
args.push("--clear".to_string());
}
if let Some(delay) = optional_u64(arguments, "delayMs")? {
args.push("--delay".to_string());
args.push(delay.to_string());
let mut args = vec![command.to_string(), selector];
if command == "type" {
if optional_bool(arguments, "clear")?.unwrap_or(false) {
args.push("--clear".to_string());
}
if let Some(delay) = optional_u64(arguments, "delayMs")? {
args.push("--delay".to_string());
args.push(delay.to_string());
}
}
call_cli_tool(arguments, args, None)
args.push("--".to_string());
args.push(text);
Ok(args)
}

fn call_press(arguments: &Value) -> Result<Value, ProtocolError> {
Expand Down Expand Up @@ -5261,6 +5293,73 @@ mod tests {
assert_eq!(args, vec!["click", "@e1", "--new-tab"]);
}

#[test]
fn fill_and_type_text_remains_literal_through_cli_parser() {
let guard = crate::test_utils::EnvGuard::new(&[
"AGENT_BROWSER_ALLOWED_DOMAINS",
"AGENT_BROWSER_CONFIG",
]);
guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS");
guard.remove("AGENT_BROWSER_CONFIG");

for command in ["fill", "type"] {
for text in ["--config", "--session", "--help", "--clear", "--json"] {
let mut extra_args = vec![
"--session",
"work",
"--engine",
"chrome",
"--user-agent",
"--",
];
if command == "type" && text == "--clear" {
extra_args.push("--clear");
}
let arguments = json!({
"selector": "#input",
"text": text,
"clear": text != "--clear",
"delayMs": 17,
"extraArgs": extra_args
});
let args = cli_tool_args(
&arguments,
text_command_args(&arguments, command).unwrap(),
None,
)
.unwrap();
let flags = crate::flags::parse_flags(&args);
let parsed =
crate::commands::parse_command(&crate::flags::clean_args(&args), &flags)
.unwrap();
assert_eq!(flags.session, "work");
assert_eq!(flags.user_agent.as_deref(), Some("--"));
assert_eq!(parsed["selector"], "#input");
if command == "fill" {
assert_eq!(parsed["value"], text);
} else {
assert_eq!(parsed["text"], text);
assert_eq!(parsed["clear"], true);
assert_eq!(parsed["delay"], 17);
}
}
for extra in [
vec!["--config", "/tmp/config"],
vec!["--", "--state", "/tmp/state"],
vec!["--"],
vec!["--session"],
vec!["--engine"],
] {
let arguments = json!({"selector": "#input", "text": "--json", "extraArgs": extra});
let args = text_command_args(&arguments, command).unwrap();
assert!(cli_tool_args(&arguments, args, None).is_err());
}
let arguments = json!({"selector": "--config", "text": "--help"});
let args = text_command_args(&arguments, command).unwrap();
assert!(cli_tool_args(&arguments, args, None).is_err());
}
}

#[test]
fn react_json_uses_structured_cli_mode_and_command_local_raw_json_flag() {
let mut args = vec!["react".to_string(), "tree".to_string()];
Expand Down
10 changes: 7 additions & 3 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1822,10 +1822,11 @@ Examples:
r##"
agent-browser fill - Clear and fill an input field

Usage: agent-browser fill <selector> <text>
Usage: agent-browser fill <selector> [--] <text>

Clears the input field and fills it with the specified text.
This replaces any existing content in the field.
Put flags before --. Text after -- is literal, including values that look like flags.

Global Options:
--json Output as JSON
Expand All @@ -1835,16 +1836,18 @@ Examples:
agent-browser fill "#email" "user@example.com"
agent-browser fill @e3 "Hello World"
agent-browser fill "input[name='search']" "query"
agent-browser fill "#search" -- --json
"##
}
"type" => {
r##"
agent-browser type - Type text into an element

Usage: agent-browser type <selector> <text>
Usage: agent-browser type <selector> [--clear] [--delay <ms>] [--] <text>

Types text into the specified element character by character.
Unlike fill, this does not clear existing content first.
Use --clear to clear existing content first. Use --delay to set the delay between keystrokes in milliseconds.
Use -- before text that looks like a flag to pass it literally. Put options before --.

Global Options:
--json Output as JSON
Expand All @@ -1853,6 +1856,7 @@ Global Options:
Examples:
agent-browser type "#search" "hello"
agent-browser type @e2 "additional text"
agent-browser --json type "#search" --clear --delay 10 -- --clear

See Also:
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ agent-browser mcp # Start an MCP stdio server
agent-browser mcp call <tool> <json> # Run one MCP tool and print its result
```

For `fill` and `type`, put options before the optional `--` marker. Text after the marker is literal, including values that look like flags. For example, `agent-browser fill "#search" -- --json` fills with `--json`, and `agent-browser --json type "#search" --clear --delay 10 -- --clear` clears the field, then types `--clear`. MCP `agent_browser_fill` and `agent_browser_type` pass typed `text` values literally, so they do not need the marker.

Browser responses automatically announce WebMCP tools on first discovery and when the catalog changes. Summaries contain only names, brief descriptions, origins, and frame IDs. Choose a relevant tool, then fetch its full schema with `agent-browser webmcp list <tool> --frame <frame-id> --json` before invoking it. Schemas and annotations are never included proactively. Unchanged catalogs and pages without tools add no context. JSON exposes updates as `data.webmcp`; CLI and MCP text include the same bounded summary. See [WebMCP](/webmcp) for update semantics and security.

Clicks fail before dispatch when another element covers the target's click point. The error names the covering element, for example `covered by <div#consent-banner>`. Dismiss or interact with that element, take a fresh snapshot, then retry the original action. Click and dblclick fail, instead of reporting success, when the element is hidden, disabled, or outside the viewport. `click --new-tab` opens the link's URL directly, so these checks do not apply to it.
Expand Down
4 changes: 3 additions & 1 deletion skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ agent-browser click @e1 # Click
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser fill @e2 "text" # Clear and fill
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key (alias: key)
agent-browser press Control+a # Key combination
Expand All @@ -89,6 +89,8 @@ agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```

For `fill` and `type`, put options before the optional `--` marker. Text after the marker is literal, including values that look like flags. For example, `agent-browser fill @e2 -- --json` fills with `--json`, and `agent-browser type @e2 --clear --delay 10 -- --clear` clears the field, then types `--clear`. MCP `agent_browser_fill` and `agent_browser_type` pass typed `text` values literally, so they do not need the marker.

Visible-label matching treats non-breaking and ordinary spaces equivalently.

Clicks fail before dispatch when another element covers the target's click point. The error names the covering element, for example `covered by <div#consent-banner>`. Dismiss or interact with that element, run a fresh snapshot, then retry the original action. Click and dblclick fail, instead of reporting success, when the element is hidden, disabled, or outside the viewport. `click --new-tab` opens the link's URL directly, so these checks do not apply to it.
Expand Down
Loading