diff --git a/README.md b/README.md index 53f71fd42a..80ead03075 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Run `agent-browser --help` for the full list, or `agent-browser --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 diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index dce1a2830b..3154d8db3a 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -4677,6 +4677,13 @@ fn response_text(value: &Value) -> Option { 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", ] { @@ -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!({ diff --git a/cli/src/output.rs b/cli/src/output.rs index a89c6a3308..16d2c70f43 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -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 { + 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() @@ -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); @@ -2863,6 +2915,8 @@ Subcommands: html Get inner HTML of element value Get value of input element attr Get attribute value + A missing attribute prints null. + An empty attribute prints a blank line. title Get page title url Get current URL count Count matching elements @@ -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] @@ -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!({ diff --git a/docs/content/docs/commands.mdx b/docs/content/docs/commands.mdx index dd88a6abc8..445af1b970 100644 --- a/docs/content/docs/commands.mdx +++ b/docs/content/docs/commands.mdx @@ -75,6 +75,8 @@ agent-browser get box # Get bounding box agent-browser get styles # 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 diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 2861501850..38f93b8bbb 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -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 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index cb067c4f3b..b3f14cac7a 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -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