Files
actionbook__actionbook/packages/cli/src/cli.rs
T
2026-05-09 18:40:57 +08:00

1611 lines
56 KiB
Rust

use clap::{Args, Parser, Subcommand};
use crate::action::Action;
use crate::action_result::ActionResult;
use crate::browser::{cookies, interaction, navigation, observation, session, storage, tab, wait};
use crate::output::ResponseContext;
use crate::setup;
#[derive(Parser, Debug)]
#[command(
name = "actionbook",
about = "Actionbook CLI - Browser automation for AI agents",
disable_version_flag = true
)]
pub struct Cli {
/// JSON output (default is plain text)
#[arg(long, global = true)]
pub json: bool,
/// Timeout in milliseconds
#[arg(long, global = true)]
pub timeout: Option<u64>,
/// API key for authenticated access
#[arg(
long,
env = "ACTIONBOOK_API_KEY",
global = true,
hide_env_values = true
)]
pub api_key: Option<String>,
/// Print version
#[arg(long, short = 'v')]
pub version: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum Commands {
/// Search for action manuals by keyword
Search {
/// Search keyword
keyword: String,
},
/// Get detailed manual information for a site, group, or action
#[command(alias = "man")]
Manual {
/// Site name
site: Option<String>,
/// Group name
group: Option<String>,
/// Action name
action: Option<String>,
},
/// Get complete action details by area ID
Get {
/// Area ID (e.g., "airbnb.com:/:default")
area_id: String,
},
/// Browser automation commands
Browser {
#[command(subcommand)]
command: BrowserCommands,
},
/// Daemon lifecycle management
Daemon {
#[command(subcommand)]
command: DaemonCommands,
},
/// Manage the Actionbook browser extension
Extension {
#[command(subcommand)]
command: ExtensionCommands,
},
/// Interactive configuration wizard
Setup(setup::Cmd),
/// Show help
Help,
/// Print version
Version,
}
/// Daemon-level subcommands. The daemon itself runs via the hidden `__daemon`
/// flag — these commands are user-facing controls over its lifecycle.
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum DaemonCommands {
/// Stop the running daemon. The next CLI call will auto-spawn a fresh one.
///
/// Use this to recover from a stuck bridge (e.g. `BRIDGE_BIND_FAILED`
/// after the holding process has been freed) without manually finding
/// the daemon pid.
Restart,
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum ExtensionCommands {
/// Show extension status (bridge + connection)
Status,
/// Ping the extension bridge and measure RTT
Ping,
/// Show extension install path and installed status
Path,
/// Install the Actionbook extension
Install(ExtensionInstallArgs),
/// Uninstall the Actionbook extension
Uninstall,
}
#[derive(Args, Debug, Clone)]
pub struct ExtensionInstallArgs {
/// Force overwrite of an existing installation
#[arg(long)]
pub force: bool,
}
/// Unimplemented tab-level command args.
#[derive(Args, Debug, Clone)]
pub struct TabArgs {
/// Session ID
#[arg(long)]
pub session: String,
/// Tab ID
#[arg(long)]
pub tab: String,
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum BrowserCommands {
/// Show browser help
Help,
/// Diagnose browser daemon/hand health without requiring an existing session
Doctor(BrowserDoctorArgs),
// ── Session lifecycle ──────────────────────────────────────
/// Start or attach a browser session
Start(session::start::Cmd),
/// List all active sessions
ListSessions(session::list::Cmd),
/// Show session status
Status(session::status::Cmd),
/// Close a session
#[command(alias = "stop")]
Close(session::close::Cmd),
/// Restart a session
Restart(session::restart::Cmd),
// ── Tab management ─────────────────────────────────────────
/// List tabs in a session
ListTabs(tab::list::Cmd),
/// Open a new tab
#[command(alias = "open")]
NewTab(tab::open::Cmd),
/// Open multiple tabs in one call (batch)
#[command(alias = "batch-open")]
BatchNewTab(tab::batch_open::Cmd),
/// Close a tab
CloseTab(tab::close::Cmd),
// ── Navigation ─────────────────────────────────────────────
/// Navigate to URL
Goto(navigation::goto::Cmd),
/// Go back
#[command(after_help = "\
Examples:
actionbook browser back --session s1 --tab t1")]
Back(TabArgs),
/// Go forward
#[command(after_help = "\
Examples:
actionbook browser forward --session s1 --tab t1")]
Forward(TabArgs),
/// Reload page
#[command(after_help = "\
Examples:
actionbook browser reload --session s1 --tab t1")]
Reload(TabArgs),
// ── Observation ────────────────────────────────────────────
/// Capture accessibility snapshots for multiple tabs
BatchSnapshot(observation::batch_snapshot::Cmd),
/// Capture accessibility snapshot
Snapshot(observation::snapshot::Cmd),
/// Get current page title
Title(observation::title::Cmd),
/// Get current page URL
Url(observation::url::Cmd),
/// Get viewport dimensions
Viewport(observation::viewport::Cmd),
/// Read element or page HTML
Html(observation::html::Cmd),
/// Read element or page text
Text(observation::text::Cmd),
/// Read element value
Value(observation::value::Cmd),
/// Read a named element attribute
Attr(observation::attr::Cmd),
/// Read all attributes on an element
Attrs(observation::attrs::Cmd),
/// Read an element bounding box
Box(observation::r#box::Cmd),
/// Read computed styles for an element
Styles(observation::styles::Cmd),
/// Describe element properties and context
Describe(observation::describe::Cmd),
/// Get element state
State(observation::state::Cmd),
/// Query elements with cardinality constraints
Query(observation::query::Cmd),
/// Inspect element at coordinates
InspectPoint(observation::inspect_point::Cmd),
/// Save page as PDF
#[command(after_help = "\
Examples:
actionbook browser pdf /tmp/page.pdf --session s1 --tab t1")]
Pdf(observation::pdf::Cmd),
/// Get browser console or error logs
Logs {
#[command(subcommand)]
command: LogsCommands,
},
/// Observe network requests
Network {
#[command(subcommand)]
command: NetworkCommands,
},
/// Take screenshot
#[command(after_help = "\
Examples:
actionbook browser screenshot /tmp/page.png --session s1 --tab t1")]
Screenshot(observation::screenshot::Cmd),
// ── Cookies ────────────────────────────────────────────────
/// Manage browser cookies
Cookies {
#[command(subcommand)]
command: CookiesCommands,
},
// ── Storage ────────────────────────────────────────────────
/// Manage local storage (window.localStorage)
#[command(name = "local-storage")]
LocalStorage {
#[command(subcommand)]
command: StorageSubCommands,
},
/// Manage session storage (window.sessionStorage)
#[command(name = "session-storage")]
SessionStorage {
#[command(subcommand)]
command: StorageSubCommands,
},
// ── Wait ───────────────────────────────────────────────────
/// Wait for a browser condition
Wait {
#[command(subcommand)]
command: WaitCommands,
},
// ── Interaction ────────────────────────────────────────────
/// Evaluate JavaScript
Eval(interaction::eval::Cmd),
/// Click an element
Click(interaction::click::Cmd),
/// Click multiple elements in sequence (batch)
BatchClick(interaction::batch_click::Cmd),
/// Hover over an element
Hover(interaction::hover::Cmd),
/// Focus an element
Focus(interaction::focus::Cmd),
/// Press a key or key combination
Press(interaction::press::Cmd),
/// Fill an input field
Fill(interaction::fill::Cmd),
/// Type text (keystroke by keystroke)
Type(interaction::type_text::Cmd),
/// Select a value from a dropdown
Select(interaction::select::Cmd),
/// Drag an element to a target
Drag(interaction::drag::Cmd),
/// Upload files to a file input
Upload(interaction::upload::Cmd),
/// Move the mouse to absolute coordinates
MouseMove(interaction::mouse_move::Cmd),
/// Get the current cursor position
CursorPosition(interaction::cursor_position::Cmd),
/// Scroll the page or a container
Scroll(interaction::scroll::Cmd),
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum WaitCommands {
/// Wait for a CSS selector to appear in the DOM
Element(wait::element::Cmd),
/// Wait for a navigation to complete
Navigation(wait::navigation::Cmd),
/// Wait for network activity to become idle
#[command(name = "network-idle")]
NetworkIdle(wait::network_idle::Cmd),
/// Wait for a JavaScript expression to become truthy
Condition(wait::condition::Cmd),
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum LogsCommands {
/// Get console logs (console.log/info/warn/error/debug)
Console(observation::logs_console::Cmd),
/// Get error logs (window error events + unhandled rejections)
Errors(observation::logs_errors::Cmd),
/// Read daemon/browser process logs
Daemon(DaemonLogsArgs),
}
#[derive(Args, Debug, Clone)]
pub struct BrowserDoctorArgs {
/// Start the daemon if it is not running, then re-check health.
#[arg(long)]
pub start: bool,
}
#[derive(Args, Debug, Clone)]
pub struct DaemonLogsArgs {
/// Return only the last N log lines.
#[arg(long, default_value_t = 100)]
pub tail: usize,
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum NetworkCommands {
/// List tracked network requests for a tab
Requests(observation::network_requests::Cmd),
/// Get detail for a single network request (including response body)
Request(observation::network_request_detail::Cmd),
/// HAR recording (start / stop)
Har {
#[command(subcommand)]
command: HarCommands,
},
}
#[derive(Subcommand, Debug, Clone)]
#[command(disable_help_subcommand = true)]
pub enum HarCommands {
/// Start HAR recording for a tab
Start(observation::network_har::StartCmd),
/// Stop HAR recording and export to a file
Stop(observation::network_har::StopCmd),
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum CookiesCommands {
/// List all cookies (optionally filtered by domain)
List(cookies::list::Cmd),
/// Get a single cookie by name
Get(cookies::get::Cmd),
/// Set a cookie
Set(cookies::set::Cmd),
/// Delete a cookie by name
Delete(cookies::delete::Cmd),
/// Clear cookies (optionally filtered by domain)
Clear(cookies::clear::Cmd),
}
#[derive(Subcommand, Debug, Clone)]
#[command(disable_help_subcommand = true)]
pub enum StorageSubCommands {
/// List all key-value entries
#[command(after_help = "\
Examples:
actionbook browser local-storage list --session s1 --tab t1
actionbook browser session-storage list --session s1 --tab t1
Returns all key-value pairs in the storage object.")]
List(StorageArgs),
/// Get a value by key
#[command(after_help = "\
Examples:
actionbook browser local-storage get auth_token --session s1 --tab t1
actionbook browser session-storage get user_id --session s1 --tab t1
Returns null if the key does not exist.")]
Get(StorageKeyArgs),
/// Set a key-value entry
#[command(after_help = "\
Examples:
actionbook browser local-storage set theme dark --session s1 --tab t1
actionbook browser session-storage set lang en --session s1 --tab t1
Creates the key if it does not exist, overwrites if it does.")]
Set(StorageSetArgs),
/// Delete a key
#[command(after_help = "\
Examples:
actionbook browser local-storage delete auth_token --session s1 --tab t1
actionbook browser session-storage delete temp_data --session s1 --tab t1
Removes the key entirely. No-op if the key does not exist.")]
Delete(StorageKeyArgs),
/// Clear the value for a key
#[command(after_help = "\
Examples:
actionbook browser local-storage clear cache_key --session s1 --tab t1
actionbook browser session-storage clear pref --session s1 --tab t1
Removes the key from storage. Returns affected count (1 if existed, 0 if not).")]
Clear(StorageKeyArgs),
}
#[derive(Args, Debug, Clone)]
pub struct StorageArgs {
#[arg(long)]
pub session: String,
#[arg(long)]
pub tab: String,
}
#[derive(Args, Debug, Clone)]
pub struct StorageKeyArgs {
pub key: String,
#[arg(long)]
pub session: String,
#[arg(long)]
pub tab: String,
}
#[derive(Args, Debug, Clone)]
pub struct StorageSetArgs {
pub key: String,
pub value: String,
#[arg(long)]
pub session: String,
#[arg(long)]
pub tab: String,
}
impl BrowserCommands {
/// Convert to wire Action. Returns None for unimplemented commands.
pub fn to_action(&self) -> Option<Action> {
Some(match self {
Self::Help => return None,
Self::Doctor(_) => return None,
Self::Start(cmd) => Action::StartSession(cmd.clone()),
Self::ListSessions(cmd) => Action::ListSessions(cmd.clone()),
Self::Status(cmd) => Action::SessionStatus(cmd.clone()),
Self::Close(cmd) => Action::Close(cmd.clone()),
Self::Restart(cmd) => Action::Restart(cmd.clone()),
Self::ListTabs(cmd) => Action::ListTabs(cmd.clone()),
Self::NewTab(cmd) => Action::NewTab(cmd.clone()),
Self::BatchNewTab(cmd) => Action::BatchOpen(cmd.clone()),
Self::CloseTab(cmd) => Action::CloseTab(cmd.clone()),
Self::Goto(cmd) => Action::Goto(cmd.clone()),
Self::Back(a) => Action::Back(navigation::back::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
}),
Self::Forward(a) => Action::Forward(navigation::forward::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
}),
Self::Reload(a) => Action::Reload(navigation::reload::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
}),
Self::BatchSnapshot(cmd) => Action::BatchSnapshot(cmd.clone()),
Self::Snapshot(cmd) => Action::Snapshot(cmd.clone()),
Self::Title(cmd) => Action::Title(cmd.clone()),
Self::Url(cmd) => Action::Url(cmd.clone()),
Self::Viewport(cmd) => Action::Viewport(cmd.clone()),
Self::Html(cmd) => Action::Html(cmd.clone()),
Self::Text(cmd) => Action::Text(cmd.clone()),
Self::Value(cmd) => Action::Value(cmd.clone()),
Self::Attr(cmd) => Action::Attr(cmd.clone()),
Self::Attrs(cmd) => Action::Attrs(cmd.clone()),
Self::Box(cmd) => Action::Box(cmd.clone()),
Self::Styles(cmd) => Action::Styles(cmd.clone()),
Self::Describe(cmd) => Action::Describe(cmd.clone()),
Self::State(cmd) => Action::State(cmd.clone()),
Self::Query(cmd) => Action::Query(cmd.clone()),
Self::InspectPoint(cmd) => Action::InspectPoint(cmd.clone()),
Self::Pdf(cmd) => Action::Pdf(cmd.clone()),
Self::Cookies { command } => match command {
CookiesCommands::List(cmd) => Action::CookiesList(cmd.clone()),
CookiesCommands::Get(cmd) => Action::CookiesGet(cmd.clone()),
CookiesCommands::Set(cmd) => Action::CookiesSet(cmd.clone()),
CookiesCommands::Delete(cmd) => Action::CookiesDelete(cmd.clone()),
CookiesCommands::Clear(cmd) => Action::CookiesClear(cmd.clone()),
},
Self::LocalStorage { command } => {
storage_to_action(command, storage::StorageKind::Local)
}
Self::SessionStorage { command } => {
storage_to_action(command, storage::StorageKind::Session)
}
Self::Logs { command } => match command {
LogsCommands::Console(cmd) => Action::LogsConsole(cmd.clone()),
LogsCommands::Errors(cmd) => Action::LogsErrors(cmd.clone()),
LogsCommands::Daemon(_) => return None,
},
Self::Network { command } => match command {
NetworkCommands::Requests(cmd) => Action::NetworkRequests(cmd.clone()),
NetworkCommands::Request(cmd) => Action::NetworkRequestDetail(cmd.clone()),
NetworkCommands::Har { command } => match command {
HarCommands::Start(cmd) => Action::NetworkHarStart(cmd.clone()),
HarCommands::Stop(cmd) => {
// Resolve --out against the CLI's CWD before handing
// the command to the daemon. The daemon may have been
// spawned from a different directory, so relying on
// the daemon's CWD would produce a surprising location
// for a user typing `--out test.har` from their shell.
let mut cmd = cmd.clone();
if let Some(ref p) = cmd.out {
let path = std::path::Path::new(p);
if let Ok(abs) = std::path::absolute(path) {
cmd.out = Some(abs.to_string_lossy().into_owned());
}
}
Action::NetworkHarStop(cmd)
}
},
},
Self::Wait { command } => match command {
WaitCommands::Element(cmd) => Action::WaitElement(cmd.clone()),
WaitCommands::Navigation(cmd) => Action::WaitNavigation(cmd.clone()),
WaitCommands::NetworkIdle(cmd) => Action::WaitNetworkIdle(cmd.clone()),
WaitCommands::Condition(cmd) => Action::WaitCondition(cmd.clone()),
},
Self::Screenshot(cmd) => Action::Screenshot(cmd.clone()),
Self::Eval(cmd) => Action::Eval(cmd.clone()),
Self::Click(cmd) => Action::Click(cmd.clone()),
Self::BatchClick(cmd) => Action::BatchClick(cmd.clone()),
Self::Hover(cmd) => Action::Hover(cmd.clone()),
Self::Focus(cmd) => Action::Focus(cmd.clone()),
Self::Press(cmd) => Action::Press(cmd.clone()),
Self::Type(cmd) => Action::Type(cmd.clone()),
Self::Fill(cmd) => Action::Fill(cmd.clone()),
Self::Select(cmd) => Action::Select(cmd.clone()),
Self::Drag(cmd) => Action::Drag(cmd.clone()),
Self::Upload(cmd) => Action::Upload(cmd.clone()),
Self::MouseMove(cmd) => Action::MouseMove(cmd.clone()),
Self::CursorPosition(cmd) => Action::CursorPosition(cmd.clone()),
Self::Scroll(cmd) => Action::Scroll(cmd.clone()),
})
}
/// Normalized command name for the JSON envelope.
pub fn command_name(&self) -> &str {
match self {
Self::Help => "help",
Self::Doctor(_) => "browser doctor",
Self::Start(_) => session::start::COMMAND_NAME,
Self::ListSessions(_) => session::list::COMMAND_NAME,
Self::Status(_) => session::status::COMMAND_NAME,
Self::Close(_) => session::close::COMMAND_NAME,
Self::Restart(_) => session::restart::COMMAND_NAME,
Self::ListTabs(_) => tab::list::COMMAND_NAME,
Self::NewTab(_) => tab::open::COMMAND_NAME,
Self::BatchNewTab(_) => tab::batch_open::COMMAND_NAME,
Self::CloseTab(_) => tab::close::COMMAND_NAME,
Self::Goto(_) => navigation::goto::COMMAND_NAME,
Self::Back(_) => "browser back",
Self::Forward(_) => "browser forward",
Self::Reload(_) => "browser reload",
Self::BatchSnapshot(_) => observation::batch_snapshot::COMMAND_NAME,
Self::Snapshot(_) => observation::snapshot::COMMAND_NAME,
Self::Title(_) => observation::title::COMMAND_NAME,
Self::Url(_) => observation::url::COMMAND_NAME,
Self::Viewport(_) => observation::viewport::COMMAND_NAME,
Self::Html(_) => observation::html::COMMAND_NAME,
Self::Text(_) => observation::text::COMMAND_NAME,
Self::Value(_) => observation::value::COMMAND_NAME,
Self::Attr(_) => observation::attr::COMMAND_NAME,
Self::Attrs(_) => observation::attrs::COMMAND_NAME,
Self::Box(_) => observation::r#box::COMMAND_NAME,
Self::Styles(_) => observation::styles::COMMAND_NAME,
Self::Describe(_) => observation::describe::COMMAND_NAME,
Self::State(_) => observation::state::COMMAND_NAME,
Self::Query(_) => observation::query::COMMAND_NAME,
Self::InspectPoint(_) => observation::inspect_point::COMMAND_NAME,
Self::Pdf(_) => observation::pdf::COMMAND_NAME,
Self::Cookies { command } => match command {
CookiesCommands::List(_) => cookies::list::COMMAND_NAME,
CookiesCommands::Get(_) => cookies::get::COMMAND_NAME,
CookiesCommands::Set(_) => cookies::set::COMMAND_NAME,
CookiesCommands::Delete(_) => cookies::delete::COMMAND_NAME,
CookiesCommands::Clear(_) => cookies::clear::COMMAND_NAME,
},
Self::LocalStorage { command } => {
storage_command_name(command, storage::StorageKind::Local)
}
Self::SessionStorage { command } => {
storage_command_name(command, storage::StorageKind::Session)
}
Self::Logs { command } => match command {
LogsCommands::Console(_) => observation::logs_console::COMMAND_NAME,
LogsCommands::Errors(_) => observation::logs_errors::COMMAND_NAME,
LogsCommands::Daemon(_) => "browser logs daemon",
},
Self::Network { command } => match command {
NetworkCommands::Requests(_) => observation::network_requests::COMMAND_NAME,
NetworkCommands::Request(_) => observation::network_request_detail::COMMAND_NAME,
NetworkCommands::Har { command } => match command {
HarCommands::Start(_) => observation::network_har::START_COMMAND_NAME,
HarCommands::Stop(_) => observation::network_har::STOP_COMMAND_NAME,
},
},
Self::Wait { command } => match command {
WaitCommands::Element(_) => wait::element::COMMAND_NAME,
WaitCommands::Navigation(_) => wait::navigation::COMMAND_NAME,
WaitCommands::NetworkIdle(_) => wait::network_idle::COMMAND_NAME,
WaitCommands::Condition(_) => wait::condition::COMMAND_NAME,
},
Self::Screenshot(_) => observation::screenshot::COMMAND_NAME,
Self::Eval(_) => interaction::eval::COMMAND_NAME,
Self::Click(_) => interaction::click::COMMAND_NAME,
Self::BatchClick(_) => interaction::batch_click::COMMAND_NAME,
Self::Hover(_) => interaction::hover::COMMAND_NAME,
Self::Focus(_) => interaction::focus::COMMAND_NAME,
Self::Press(_) => interaction::press::COMMAND_NAME,
Self::Fill(_) => interaction::fill::COMMAND_NAME,
Self::Type(_) => interaction::type_text::COMMAND_NAME,
Self::Select(_) => interaction::select::COMMAND_NAME,
Self::Drag(_) => interaction::drag::COMMAND_NAME,
Self::Upload(_) => interaction::upload::COMMAND_NAME,
Self::MouseMove(_) => interaction::mouse_move::COMMAND_NAME,
Self::CursorPosition(_) => interaction::cursor_position::COMMAND_NAME,
Self::Scroll(_) => interaction::scroll::COMMAND_NAME,
}
}
/// Build response context from command args and result.
pub fn context(&self, result: &ActionResult) -> Option<ResponseContext> {
match self {
Self::Help => None,
Self::Doctor(_) => None,
Self::Start(cmd) => session::start::context(cmd, result),
Self::ListSessions(cmd) => session::list::context(cmd, result),
Self::Status(cmd) => session::status::context(cmd, result),
Self::Close(cmd) => session::close::context(cmd, result),
Self::Restart(cmd) => session::restart::context(cmd, result),
Self::ListTabs(cmd) => tab::list::context(cmd, result),
Self::NewTab(cmd) => tab::open::context(cmd, result),
Self::BatchNewTab(cmd) => tab::batch_open::context(cmd, result),
Self::CloseTab(cmd) => tab::close::context(cmd, result),
Self::Goto(cmd) => navigation::goto::context(cmd, result),
Self::BatchSnapshot(cmd) => observation::batch_snapshot::context(cmd, result),
Self::Snapshot(cmd) => observation::snapshot::context(cmd, result),
Self::Title(cmd) => observation::title::context(cmd, result),
Self::Url(cmd) => observation::url::context(cmd, result),
Self::Viewport(cmd) => observation::viewport::context(cmd, result),
Self::Html(cmd) => observation::html::context(cmd, result),
Self::Text(cmd) => observation::text::context(cmd, result),
Self::Value(cmd) => observation::value::context(cmd, result),
Self::Attr(cmd) => observation::attr::context(cmd, result),
Self::Attrs(cmd) => observation::attrs::context(cmd, result),
Self::Box(cmd) => observation::r#box::context(cmd, result),
Self::Styles(cmd) => observation::styles::context(cmd, result),
Self::Describe(cmd) => observation::describe::context(cmd, result),
Self::State(cmd) => observation::state::context(cmd, result),
Self::Query(cmd) => observation::query::context(cmd, result),
Self::InspectPoint(cmd) => observation::inspect_point::context(cmd, result),
Self::Pdf(cmd) => observation::pdf::context(cmd, result),
Self::Cookies { command } => match command {
CookiesCommands::List(cmd) => cookies::list::context(cmd, result),
CookiesCommands::Get(cmd) => cookies::get::context(cmd, result),
CookiesCommands::Set(cmd) => cookies::set::context(cmd, result),
CookiesCommands::Delete(cmd) => cookies::delete::context(cmd, result),
CookiesCommands::Clear(cmd) => cookies::clear::context(cmd, result),
},
Self::LocalStorage { command } => {
storage_context(command, storage::StorageKind::Local, result)
}
Self::SessionStorage { command } => {
storage_context(command, storage::StorageKind::Session, result)
}
Self::Logs { command } => match command {
LogsCommands::Console(cmd) => observation::logs_console::context(cmd, result),
LogsCommands::Errors(cmd) => observation::logs_errors::context(cmd, result),
LogsCommands::Daemon(_) => None,
},
Self::Network { command } => match command {
NetworkCommands::Requests(cmd) => {
observation::network_requests::context(cmd, result)
}
NetworkCommands::Request(cmd) => {
observation::network_request_detail::context(cmd, result)
}
NetworkCommands::Har { command } => match command {
HarCommands::Start(cmd) => observation::network_har::start_context(cmd, result),
HarCommands::Stop(cmd) => observation::network_har::stop_context(cmd, result),
},
},
Self::Wait { command } => match command {
WaitCommands::Element(cmd) => wait::element::context(cmd, result),
WaitCommands::Navigation(cmd) => wait::navigation::context(cmd, result),
WaitCommands::NetworkIdle(cmd) => wait::network_idle::context(cmd, result),
WaitCommands::Condition(cmd) => wait::condition::context(cmd, result),
},
Self::Eval(cmd) => interaction::eval::context(cmd, result),
Self::Back(a) => navigation::back::context(
&navigation::back::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
},
result,
),
Self::Forward(a) => navigation::forward::context(
&navigation::forward::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
},
result,
),
Self::Reload(a) => navigation::reload::context(
&navigation::reload::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
},
result,
),
Self::Click(cmd) => interaction::click::context(cmd, result),
Self::BatchClick(cmd) => interaction::batch_click::context(cmd, result),
Self::Hover(cmd) => interaction::hover::context(cmd, result),
Self::Focus(cmd) => interaction::focus::context(cmd, result),
Self::Press(cmd) => interaction::press::context(cmd, result),
Self::Type(cmd) => interaction::type_text::context(cmd, result),
Self::Fill(cmd) => interaction::fill::context(cmd, result),
Self::Select(cmd) => interaction::select::context(cmd, result),
Self::Drag(cmd) => interaction::drag::context(cmd, result),
Self::Upload(cmd) => interaction::upload::context(cmd, result),
Self::MouseMove(cmd) => interaction::mouse_move::context(cmd, result),
Self::CursorPosition(cmd) => interaction::cursor_position::context(cmd, result),
Self::Scroll(cmd) => interaction::scroll::context(cmd, result),
Self::Screenshot(cmd) => observation::screenshot::context(cmd, result),
}
}
}
/// Build an `Action` from storage subcommand args + kind.
fn storage_to_action(cmd: &StorageSubCommands, kind: storage::StorageKind) -> Action {
match cmd {
StorageSubCommands::List(a) => Action::StorageList(storage::list::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
kind,
}),
StorageSubCommands::Get(a) => Action::StorageGet(storage::get::Cmd {
key: a.key.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
}),
StorageSubCommands::Set(a) => Action::StorageSet(storage::set::Cmd {
key: a.key.clone(),
value: a.value.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
}),
StorageSubCommands::Delete(a) => Action::StorageDelete(storage::delete::Cmd {
key: a.key.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
}),
StorageSubCommands::Clear(a) => Action::StorageClear(storage::clear::Cmd {
key: a.key.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
}),
}
}
/// Return the command name string for a storage subcommand + kind.
fn storage_command_name(cmd: &StorageSubCommands, kind: storage::StorageKind) -> &'static str {
match cmd {
StorageSubCommands::List(_) => storage::list::command_name(kind),
StorageSubCommands::Get(_) => storage::get::command_name(kind),
StorageSubCommands::Set(_) => storage::set::command_name(kind),
StorageSubCommands::Delete(_) => storage::delete::command_name(kind),
StorageSubCommands::Clear(_) => storage::clear::command_name(kind),
}
}
/// Build response context for a storage subcommand.
fn storage_context(
cmd: &StorageSubCommands,
kind: storage::StorageKind,
result: &ActionResult,
) -> Option<ResponseContext> {
match cmd {
StorageSubCommands::List(a) => storage::list::context(
&storage::list::Cmd {
session: a.session.clone(),
tab: a.tab.clone(),
kind,
},
result,
),
StorageSubCommands::Get(a) => storage::get::context(
&storage::get::Cmd {
key: a.key.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
},
result,
),
StorageSubCommands::Set(a) => storage::set::context(
&storage::set::Cmd {
key: a.key.clone(),
value: a.value.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
},
result,
),
StorageSubCommands::Delete(a) => storage::delete::context(
&storage::delete::Cmd {
key: a.key.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
},
result,
),
StorageSubCommands::Clear(a) => storage::clear::context(
&storage::clear::Cmd {
key: a.key.clone(),
session: a.session.clone(),
tab: a.tab.clone(),
kind,
},
result,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn try_parse_from_parses_setup_target_only_flags() {
let cli = Cli::try_parse_from(["actionbook", "setup", "--target", "codex"])
.expect("parse setup target-only");
match cli.command {
Some(Commands::Setup(cmd)) => {
assert_eq!(cmd.target, Some(setup::skills::SetupTarget::Codex));
assert!(cmd.api_key.is_none());
assert!(cmd.browser.is_none());
assert!(!cmd.non_interactive);
assert!(!cmd.reset);
}
other => panic!("expected setup command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_setup_target_short_flag() {
let cli = Cli::try_parse_from(["actionbook", "setup", "-t", "claude"])
.expect("parse setup quick mode");
match cli.command {
Some(Commands::Setup(cmd)) => {
assert_eq!(cmd.target, Some(setup::skills::SetupTarget::Claude));
assert!(cmd.api_key.is_none());
assert!(!cmd.non_interactive);
}
other => panic!("expected setup command, got {other:?}"),
}
}
#[test]
fn try_parse_from_rejects_unknown_setup_target() {
let result = Cli::try_parse_from(["actionbook", "setup", "--target", "vim"]);
assert!(result.is_err(), "expected clap to reject unknown target");
}
#[test]
fn try_parse_from_rejects_setup_target_with_api_key() {
let result = Cli::try_parse_from([
"actionbook",
"setup",
"--target",
"codex",
"--api-key",
"sk",
]);
assert!(result.is_err(), "expected clap to reject conflicting flags");
}
#[test]
fn try_parse_from_rejects_setup_target_with_browser() {
let result = Cli::try_parse_from([
"actionbook",
"setup",
"--target",
"codex",
"--browser",
"local",
]);
assert!(result.is_err(), "expected clap to reject conflicting flags");
}
#[test]
fn try_parse_from_rejects_setup_target_with_reset() {
let result = Cli::try_parse_from(["actionbook", "setup", "--target", "codex", "--reset"]);
assert!(result.is_err(), "expected clap to reject conflicting flags");
}
#[test]
fn try_parse_from_parses_search_query() {
let cli =
Cli::try_parse_from(["actionbook", "search", "query text"]).expect("parse search");
assert!(!cli.json);
match cli.command {
Some(Commands::Search { keyword }) => {
assert_eq!(keyword, "query text");
}
other => panic!("expected search command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_search_query_with_json_flag() {
let cli = Cli::try_parse_from(["actionbook", "search", "query", "--json"])
.expect("parse search --json");
assert!(cli.json);
match cli.command {
Some(Commands::Search { keyword }) => {
assert_eq!(keyword, "query");
}
other => panic!("expected search command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_manual_site_only() {
let cli = Cli::try_parse_from(["actionbook", "manual", "notion"]).expect("parse manual");
assert!(!cli.json);
match cli.command {
Some(Commands::Manual {
site,
group,
action,
}) => {
assert_eq!(site.as_deref(), Some("notion"));
assert!(group.is_none());
assert!(action.is_none());
}
other => panic!("expected manual command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_manual_alias_with_full_path_and_json_flag() {
let cli = Cli::try_parse_from([
"actionbook",
"man",
"notion",
"pages",
"create_page",
"--json",
])
.expect("parse manual alias --json");
assert!(cli.json);
match cli.command {
Some(Commands::Manual {
site,
group,
action,
}) => {
assert_eq!(site.as_deref(), Some("notion"));
assert_eq!(group.as_deref(), Some("pages"));
assert_eq!(action.as_deref(), Some("create_page"));
}
other => panic!("expected manual command, got {other:?}"),
}
}
#[test]
fn parse_manual_with_all_args() {
let cli = Cli::try_parse_from(["actionbook", "manual", "notion", "pages", "create_page"])
.expect("parse manual with all args");
assert!(!cli.json);
match cli.command {
Some(Commands::Manual {
site,
group,
action,
}) => {
assert_eq!(site.as_deref(), Some("notion"));
assert_eq!(group.as_deref(), Some("pages"));
assert_eq!(action.as_deref(), Some("create_page"));
}
other => panic!("expected manual command, got {other:?}"),
}
}
#[test]
fn parse_manual_site_only() {
let cli =
Cli::try_parse_from(["actionbook", "manual", "notion"]).expect("parse manual site");
assert!(!cli.json);
match cli.command {
Some(Commands::Manual {
site,
group,
action,
}) => {
assert_eq!(site.as_deref(), Some("notion"));
assert!(group.is_none());
assert!(action.is_none());
}
other => panic!("expected manual command, got {other:?}"),
}
}
#[test]
fn parse_manual_alias() {
let cli = Cli::try_parse_from(["actionbook", "man", "notion"]).expect("parse manual alias");
assert!(!cli.json);
match cli.command {
Some(Commands::Manual {
site,
group,
action,
}) => {
assert_eq!(site.as_deref(), Some("notion"));
assert!(group.is_none());
assert!(action.is_none());
}
other => panic!("expected manual command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_doctor_command() {
let cli = Cli::try_parse_from(["actionbook", "--json", "browser", "doctor", "--start"])
.expect("parse browser doctor");
assert!(cli.json);
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::Doctor(cmd),
}) => assert!(cmd.start),
other => panic!("expected browser doctor command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_logs_daemon_command() {
let cli = Cli::try_parse_from(["actionbook", "browser", "logs", "daemon", "--tail", "7"])
.expect("parse browser logs daemon");
match cli.command {
Some(Commands::Browser {
command:
BrowserCommands::Logs {
command: LogsCommands::Daemon(cmd),
},
}) => assert_eq!(cmd.tail, 7),
other => panic!("expected browser logs daemon command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_hover_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"hover",
"#submit",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser hover command should parse");
}
#[test]
fn try_parse_from_accepts_browser_focus_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"focus",
"#submit",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser focus command should parse");
}
#[test]
fn try_parse_from_accepts_browser_press_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"press",
"Enter",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser press command should parse");
}
#[test]
fn try_parse_from_accepts_browser_drag_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"drag",
"#source",
"#target",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser drag command should parse");
}
#[test]
fn try_parse_from_accepts_browser_upload_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"upload",
"#file-input",
"/tmp/example.txt",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser upload command should parse");
}
#[test]
fn try_parse_from_rejects_browser_upload_without_files() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"upload",
"#file-input",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(
cli.is_err(),
"browser upload should require at least one file"
);
}
#[test]
fn try_parse_from_accepts_browser_eval_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"eval",
"2 + 2",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser eval command should parse");
}
#[test]
fn try_parse_from_accepts_browser_eval_timeout_flag() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"eval",
"await Promise.resolve(1)",
"--session",
"session-1",
"--tab",
"tab-1",
"--timeout",
"250",
]);
assert!(cli.is_ok(), "browser eval should accept --timeout");
}
#[test]
fn try_parse_from_accepts_browser_eval_file_flag() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"eval",
"--file",
"/tmp/script.js",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser eval should accept --file");
}
#[test]
fn try_parse_from_accepts_browser_mouse_move_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"mouse-move",
"120,140",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser mouse-move command should parse");
}
#[test]
fn try_parse_from_accepts_browser_cursor_position_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"cursor-position",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser cursor-position command should parse");
}
#[test]
fn try_parse_from_accepts_browser_scroll_direction_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"scroll",
"down",
"180",
"--session",
"session-1",
"--tab",
"tab-1",
]);
assert!(cli.is_ok(), "browser scroll direction command should parse");
}
#[test]
fn try_parse_from_accepts_browser_scroll_edge_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"scroll",
"bottom",
"--session",
"session-1",
"--tab",
"tab-1",
"--container",
"#scroll-box",
]);
assert!(cli.is_ok(), "browser scroll edge command should parse");
}
#[test]
fn try_parse_from_accepts_browser_scroll_into_view_command() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"scroll",
"into-view",
"#target",
"--session",
"session-1",
"--tab",
"tab-1",
"--align",
"center",
]);
assert!(cli.is_ok(), "browser scroll into-view command should parse");
}
#[test]
fn try_parse_from_parses_html_without_selector() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"html",
"--session",
"s1",
"--tab",
"t1",
])
.expect("parse html");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::Html(cmd),
}) => {
assert_eq!(cmd.selector, None);
assert_eq!(cmd.session, "s1");
assert_eq!(cmd.tab, "t1");
}
other => panic!("expected browser html command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_attr_selector_and_name() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"attr",
"#email",
"aria-label",
"--session",
"s1",
"--tab",
"t1",
])
.expect("parse attr");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::Attr(cmd),
}) => {
assert_eq!(cmd.selector, "#email");
assert_eq!(cmd.name, "aria-label");
assert_eq!(cmd.session, "s1");
assert_eq!(cmd.tab, "t1");
}
other => panic!("expected browser attr command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_styles_names_after_options() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"styles",
"#target",
"--session",
"s1",
"--tab",
"t1",
"color",
"backgroundColor",
"z-index",
])
.expect("parse styles");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::Styles(cmd),
}) => {
assert_eq!(cmd.selector, "#target");
assert_eq!(cmd.session, "s1");
assert_eq!(cmd.tab, "t1");
assert_eq!(cmd.names, vec!["color", "backgroundColor", "z-index"]);
}
other => panic!("expected browser styles command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_start_session_flag() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"start",
"--session",
"my-session",
"-p",
"hyperbrowser",
"--headless",
])
.expect("browser start --session should parse");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::Start(cmd),
}) => {
assert_eq!(cmd.session.as_deref(), Some("my-session"));
assert_eq!(cmd.provider.as_deref(), Some("hyperbrowser"));
assert!(
cmd.set_session_id.is_none(),
"set_session_id should be None when --session is used"
);
}
other => panic!("expected browser start command, got {other:?}"),
}
}
#[test]
fn try_parse_from_rejects_session_and_set_session_id_together() {
let result = Cli::try_parse_from([
"actionbook",
"browser",
"start",
"--session",
"my-session",
"--set-session-id",
"other-session",
]);
assert!(
result.is_err(),
"--session and --set-session-id should conflict"
);
}
#[test]
fn try_parse_from_accepts_browser_batch_new_tab() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"batch-new-tab",
"--urls",
"https://a.com",
"https://b.com",
"--session",
"s1",
])
.expect("batch-new-tab should parse");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::BatchNewTab(cmd),
}) => {
assert_eq!(cmd.urls, vec!["https://a.com", "https://b.com"]);
assert!(cmd.tabs.is_empty());
assert_eq!(cmd.session, "s1");
}
other => panic!("expected browser batch-new-tab, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_batch_new_tab_with_tabs() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"batch-new-tab",
"--urls",
"https://a.com",
"https://b.com",
"--tabs",
"inbox",
"settings",
"--session",
"s1",
])
.expect("batch-new-tab with --tabs should parse");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::BatchNewTab(cmd),
}) => {
assert_eq!(cmd.urls, vec!["https://a.com", "https://b.com"]);
assert_eq!(cmd.tabs, vec!["inbox", "settings"]);
assert_eq!(cmd.session, "s1");
}
other => panic!("expected browser batch-new-tab, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_batch_open_alias() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"batch-open",
"--urls",
"https://a.com",
"--session",
"s1",
]);
assert!(cli.is_ok(), "batch-open alias should parse");
}
#[test]
fn try_parse_from_rejects_batch_new_tab_no_urls() {
let cli =
Cli::try_parse_from(["actionbook", "browser", "batch-new-tab", "--session", "s1"]);
assert!(cli.is_err(), "batch-new-tab without --urls should fail");
}
#[test]
fn try_parse_from_accepts_browser_new_tab_tab_alias() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"new-tab",
"https://example.com",
"--session",
"s1",
"--tab",
"inbox",
])
.expect("browser new-tab --tab should parse");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::NewTab(cmd),
}) => {
assert_eq!(cmd.urls, vec!["https://example.com"]);
assert_eq!(cmd.set_tab_id, vec!["inbox"]);
}
other => panic!("expected browser new-tab command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_new_tab_multiple_urls() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"new-tab",
"https://a.com",
"https://b.com",
"--session",
"s1",
])
.expect("browser new-tab multiple URLs should parse");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::NewTab(cmd),
}) => {
assert_eq!(cmd.urls, vec!["https://a.com", "https://b.com"]);
assert_eq!(cmd.session, "s1");
assert!(cmd.set_tab_id.is_empty());
}
other => panic!("expected browser new-tab command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_new_tab_multiple_tab_aliases() {
let cli = Cli::try_parse_from([
"actionbook",
"browser",
"new-tab",
"https://a.com",
"https://b.com",
"--session",
"s1",
"--tab",
"inbox",
"--tab",
"docs",
])
.expect("browser new-tab repeated --tab should parse");
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::NewTab(cmd),
}) => {
assert_eq!(cmd.urls, vec!["https://a.com", "https://b.com"]);
assert_eq!(cmd.set_tab_id, vec!["inbox", "docs"]);
}
other => panic!("expected browser new-tab command, got {other:?}"),
}
}
}