Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ Run `agent-browser --help` for the full list, or `agent-browser <command> --help
| Observe | `stream`, `dashboard` | [Streaming](https://agent-browser.dev/streaming), [Dashboard](https://agent-browser.dev/dashboard) |
| Setup | `install`, `upgrade`, `doctor`, `connect`, `close` | [Installation](https://agent-browser.dev/installation) |

`get attr` prints the attribute value. A missing attribute prints `null`. An empty attribute prints a blank line. `--json` reports `"value": null` or `"value": ""`.

Flags, environment variables, and `agent-browser.json` are documented in [Configuration](https://agent-browser.dev/configuration).

## Platforms
Expand Down
56 changes: 56 additions & 0 deletions cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4677,6 +4677,13 @@ fn response_text(value: &Value) -> Option<String> {
origins.join(", ")
));
}
// `get attr` returns JSON null when the attribute is absent.
// Print the same `null` token as the CLI instead of the whole object.
if data.get("value").is_some_and(|value| value.is_null()) {
if let Some(text) = crate::output::format_attribute_text(None, data) {
return Some(text);
}
}
for key in [
"snapshot", "text", "html", "report", "value", "content", "title", "url", "path",
] {
Expand Down Expand Up @@ -5485,6 +5492,55 @@ mod tests {
assert!(text.contains("http://127.0.0.1:8001"));
}

#[test]
fn response_text_distinguishes_missing_attribute_from_empty() {
let origin = json!("https://example.test/page");
let missing = response_text(&json!({
"success": true,
"data": { "value": null, "origin": origin }
}))
.unwrap();
let empty = response_text(&json!({
"success": true,
"data": { "value": "", "origin": origin }
}))
.unwrap();
let present = response_text(&json!({
"success": true,
"data": { "value": "1", "origin": origin }
}))
.unwrap();

assert_eq!(missing, "null");
assert_eq!(empty, "");
assert_eq!(present, "1");
assert_ne!(missing, empty);

let missing_text = tool_text(
Some(&json!({
"success": true,
"data": { "value": null, "origin": origin }
})),
"",
"",
);
let empty_text = tool_text(
Some(&json!({
"success": true,
"data": { "value": "", "origin": origin }
})),
"",
"",
);
assert_eq!(missing_text, "null");
assert_eq!(empty_text, "(no output)");
assert!(response_text(&json!({
"success": true,
"data": { "key": "token", "value": null }
}))
.is_none());
}

#[test]
fn response_text_uses_read_content_before_url_metadata() {
let text = response_text(&json!({
Expand Down
159 changes: 159 additions & 0 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,51 @@ fn boundary_origin(data: &serde_json::Value) -> Option<&str> {
None
}

/// Plain text for a `get attr` result.
///
/// A present attribute prints its string, including the empty string.
/// `Element.getAttribute` returns JSON null when the attribute is absent.
/// That case prints `null`, which is distinct from a blank line and from
/// the generic success line used by mutating commands such as click and fill.
/// `--json` still reports `"value": null` or `"value": ""`.
pub(crate) fn format_attribute_text(
action: Option<&str>,
data: &serde_json::Value,
) -> Option<String> {
let recognized = match action {
Some("getattribute") => true,
Some(_) => false,
None => is_attribute_payload(data),
};
if !recognized {
return None;
}
match data.get("value")? {
serde_json::Value::String(text) => Some(text.clone()),
serde_json::Value::Null => Some("null".to_string()),
_ => None,
}
}

/// `{ value, origin }` is the `get attr` payload. Storage lookups also carry
/// `value` (null when the key is unset) but include `key` and no page origin.
fn is_attribute_payload(data: &serde_json::Value) -> bool {
let Some(obj) = data.as_object() else {
return false;
};
if obj.get("origin").and_then(|value| value.as_str()).is_none()
|| !obj.contains_key("value")
|| obj.contains_key("key")
{
return false;
}
// Leave payloads that already have a more specific text field alone.
const SPECIFIC: &[&str] = &[
"snapshot", "text", "html", "report", "content", "title", "url", "path",
];
SPECIFIC.iter().all(|key| !obj.contains_key(*key))
}

fn format_storage_value(value: &serde_json::Value) -> String {
value
.as_str()
Expand Down Expand Up @@ -1167,6 +1212,13 @@ fn print_primary_response(resp: &Response, action: Option<&str>, opts: &OutputOp
print_with_boundaries(html, origin, opts);
return;
}
// Attribute reads, including a missing attribute (JSON null).
// Other value-bearing commands (get value, set value) fall through
// to the string branch below.
if let Some(text) = format_attribute_text(action, data) {
println!("{text}");
return;
}
// Value
if let Some(value) = data.get("value").and_then(|v| v.as_str()) {
println!("{}", value);
Expand Down Expand Up @@ -2863,6 +2915,8 @@ Subcommands:
html <selector> Get inner HTML of element
value <selector> Get value of input element
attr <selector> <name> Get attribute value
A missing attribute prints null.
An empty attribute prints a blank line.
title Get page title
url Get current URL
count <selector> Count matching elements
Expand Down Expand Up @@ -4998,6 +5052,7 @@ mod tests {
format_storage_text, format_vitals_text, format_webmcp_context, format_webmcp_text,
format_webmcp_tool_text, format_with_boundaries, OutputOptions,
};
use crate::connection::Response;
use serde_json::json;

#[test]
Expand Down Expand Up @@ -5099,6 +5154,110 @@ mod tests {
assert_eq!(rendered, "token: abc123");
}

#[test]
fn get_attr_text_distinguishes_missing_from_empty() {
let origin = "https://example.test/page";
let present = json!({ "value": "1", "origin": origin });
let empty = json!({ "value": "", "origin": origin });
let missing = json!({ "value": null, "origin": origin });
let literal_null = json!({ "value": "null", "origin": origin });

assert_eq!(
super::format_attribute_text(Some("getattribute"), &present).as_deref(),
Some("1")
);
assert_eq!(
super::format_attribute_text(Some("getattribute"), &empty).as_deref(),
Some("")
);
assert_eq!(
super::format_attribute_text(Some("getattribute"), &missing).as_deref(),
Some("null")
);
// The attribute value "null" prints the same word. `--json` still
// separates that string from an absent attribute.
assert_eq!(
super::format_attribute_text(Some("getattribute"), &literal_null).as_deref(),
Some("null")
);
assert_eq!(
super::format_attribute_text(None, &missing).as_deref(),
Some("null")
);
assert_eq!(
super::format_attribute_text(None, &empty).as_deref(),
Some("")
);

let with_lifecycle = json!({
"value": null,
"origin": origin,
"lifecycle": { "restartedBackground": true }
});
assert_eq!(
super::format_attribute_text(Some("getattribute"), &with_lifecycle).as_deref(),
Some("null")
);
assert_eq!(
super::format_attribute_text(None, &with_lifecycle).as_deref(),
Some("null")
);

assert_eq!(
super::format_attribute_text(None, &json!({ "key": "token", "value": null })),
None
);
assert_eq!(
super::format_attribute_text(None, &json!({ "value": "" })),
None
);
assert_eq!(super::format_attribute_text(Some("click"), &missing), None);
assert_ne!(
super::format_attribute_text(Some("getattribute"), &missing).as_deref(),
Some("✓ Done")
);
assert_ne!(
super::format_attribute_text(Some("getattribute"), &empty).as_deref(),
Some("null")
);

let missing_json = serde_json::to_string(&Response {
success: true,
data: Some(missing),
error: None,
code: None,
warning: None,
recorded: None,
})
.unwrap();
let empty_json = serde_json::to_string(&Response {
success: true,
data: Some(empty),
error: None,
code: None,
warning: None,
recorded: None,
})
.unwrap();
let literal_json = serde_json::to_string(&Response {
success: true,
data: Some(literal_null),
error: None,
code: None,
warning: None,
recorded: None,
})
.unwrap();
assert!(missing_json.contains("\"value\":null"), "{missing_json}");
assert!(empty_json.contains("\"value\":\"\""), "{empty_json}");
assert!(
literal_json.contains("\"value\":\"null\""),
"{literal_json}"
);
assert_ne!(missing_json, empty_json);
assert_ne!(missing_json, literal_json);
}

#[test]
fn test_format_storage_text_for_empty_store() {
let data = json!({
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 @@ -75,6 +75,8 @@ agent-browser get box <sel> # Get bounding box
agent-browser get styles <sel> # Get computed styles
```

`get attr` prints the attribute value. A missing attribute prints `null`. An empty attribute prints a blank line. `--json` reports `"value": null` or `"value": ""`.

`get cdp-url` reports the CDP WebSocket URL of a browser that is already running. It requires a running browser and never launches one. A batch containing only `get cdp-url` commands also skips browser launch. A mixed batch can launch a browser for its other commands.

## Read agent-friendly text
Expand Down
2 changes: 2 additions & 0 deletions skill-data/core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ agent-browser get url # also: title, value, html,

Use `read` for documentation and other text pages. A read with a URL fetches over HTTP without launching Chrome.

`get attr` prints the attribute value. A missing attribute prints `null`. An empty attribute prints a blank line. `--json` reports `"value": null` or `"value": ""`.

## Interacting

```bash
Expand Down
2 changes: 2 additions & 0 deletions skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ agent-browser get box @e1 # Get bounding box
agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.)
```

`get attr` prints the attribute value. A missing attribute prints `null`. An empty attribute prints a blank line. `--json` reports `"value": null` or `"value": ""`.

A batch containing only `get cdp-url` commands also skips browser launch. A mixed batch can launch a browser for its other commands.

## Check State
Expand Down