From e49047bb1489ca91be8b025455ca75c173238557 Mon Sep 17 00:00:00 2001 From: junhsss Date: Sat, 31 Oct 2026 01:26:34 +0900 Subject: [PATCH 1/8] chore: make internals crate-private and drop dead code --- src/api/client.rs | 68 --------- src/api/session.rs | 99 +------------ src/browser/lifecycle.rs | 86 ++++++++--- src/browser/profile_porter.rs | 17 --- src/commands/browser/action.rs | 20 +-- src/commands/browser/mod.rs | 4 +- src/commands/browser/start.rs | 2 +- src/commands/describe.rs | 27 ++++ src/commands/doctor.rs | 3 - src/commands/settings.rs | 48 ------ src/commands/skills/mod.rs | 4 - src/config/settings.rs | 4 - src/lib.rs | 15 +- src/main.rs | 6 +- src/telemetry.rs | 1 - src/util/api.rs | 5 - src/util/output.rs | 138 ----------------- tests/api_openapi_coverage.rs | 53 ------- tests/cli_compat.rs | 4 +- tests/dev_command.rs | 33 +---- tests/lifecycle_contract.rs | 260 --------------------------------- 21 files changed, 123 insertions(+), 774 deletions(-) delete mode 100644 src/commands/settings.rs delete mode 100644 tests/api_openapi_coverage.rs delete mode 100644 tests/lifecycle_contract.rs diff --git a/src/api/client.rs b/src/api/client.rs index 4b59138..4d61b1f 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -27,13 +27,6 @@ pub enum ApiError { body: Option, }, - #[error("Steel API request failed ({status}): {message}")] - RetryLater { - status: u16, - message: Cow<'static, str>, - seconds: u64, - }, - #[error(transparent)] Other(#[from] reqwest::Error), } @@ -163,67 +156,6 @@ impl SteelClient { Ok(response_data) } - - pub async fn request_raw( - &self, - base_url: &str, - mode: ApiMode, - method: reqwest::Method, - path: &str, - body: Option, - auth: &Auth, - ) -> Result { - if mode == ApiMode::Cloud && auth.api_key.is_none() { - return Err(ApiError::MissingAuth); - } - - let url = format!("{base_url}{path}"); - let mut req = self.http.request(method, &url); - req = req.header("Content-Type", "application/json"); - if let Some(key) = &auth.api_key { - req = req.header("Steel-Api-Key", key); - } - if let Some(body) = body { - req = req.json(&body); - } - - let resp = req.send().await.map_err(|e| ApiError::Unreachable { - url: url.clone(), - source: e, - })?; - - let status = resp.status(); - if status.is_success() { - return Ok(resp); - } - - let status_code = status.as_u16(); - let status_text = status.canonical_reason().unwrap_or("").to_string(); - let retry_after = resp - .headers() - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.trim().parse::().ok()); - let response_text = resp.text().await.map_err(ApiError::Other)?; - let response_data: Value = if response_text.trim().is_empty() { - Value::Null - } else { - serde_json::from_str(&response_text).unwrap_or(Value::String(response_text)) - }; - let message = extract_error_message(&response_data, &status_text); - if let Some(seconds) = retry_after { - return Err(ApiError::RetryLater { - status: status_code, - message, - seconds, - }); - } - Err(ApiError::RequestFailed { - status: status_code, - message, - body: Some(response_data), - }) - } } #[cfg(test)] diff --git a/src/api/session.rs b/src/api/session.rs index 9e8d41f..6a1f49a 100644 --- a/src/api/session.rs +++ b/src/api/session.rs @@ -23,28 +23,6 @@ pub struct CreateSessionOptions { pub credentials: bool, } -/// Extract session list from response. Matches TS `extractSessionList()`. -fn extract_session_list(data: &Value) -> Vec { - // Direct array - if let Some(arr) = data.as_array() { - return arr.iter().filter(|v| v.is_object()).cloned().collect(); - } - - // Nested { sessions: [...] } - if let Some(obj) = data.as_object() { - if let Some(Value::Array(arr)) = obj.get("sessions") { - return arr.iter().filter(|v| v.is_object()).cloned().collect(); - } - - // Single session object with id - if obj.contains_key("id") { - return vec![data.clone()]; - } - } - - vec![] -} - /// Extract a single session from response. Matches TS `extractSingleSession()`. fn extract_single_session(data: &Value) -> Result { if let Some(obj) = data.as_object() { @@ -130,25 +108,6 @@ fn build_create_body(options: &CreateSessionOptions) -> Value { } impl SteelClient { - pub async fn list_sessions( - &self, - base_url: &str, - mode: ApiMode, - auth: &Auth, - ) -> Result, ApiError> { - let data = self - .request( - base_url, - mode, - reqwest::Method::GET, - "/sessions", - None, - auth, - ) - .await?; - Ok(extract_session_list(&data)) - } - pub async fn get_session( &self, base_url: &str, @@ -286,35 +245,6 @@ impl SteelClient { mod tests { use super::*; - // --- extract_session_list --- - - #[test] - fn extract_list_from_array() { - let data = json!([{"id": "s1"}, {"id": "s2"}]); - let list = extract_session_list(&data); - assert_eq!(list.len(), 2); - } - - #[test] - fn extract_list_from_nested() { - let data = json!({"sessions": [{"id": "s1"}]}); - let list = extract_session_list(&data); - assert_eq!(list.len(), 1); - } - - #[test] - fn extract_list_single_object() { - let data = json!({"id": "s1", "status": "live"}); - let list = extract_session_list(&data); - assert_eq!(list.len(), 1); - } - - #[test] - fn extract_list_null() { - let list = extract_session_list(&Value::Null); - assert!(list.is_empty()); - } - // --- extract_single_session --- #[test] @@ -398,7 +328,7 @@ mod tests { // --- Integration tests with wiremock --- use crate::config::auth::AuthSource; - use wiremock::matchers::{header, method, path}; + use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn test_auth() -> Auth { @@ -408,29 +338,6 @@ mod tests { } } - #[tokio::test] - async fn list_sessions_success() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/sessions")) - .and(header("Steel-Api-Key", "test-key")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!([{"id": "s1", "status": "live"}])), - ) - .mount(&server) - .await; - - let client = SteelClient::new().unwrap(); - let sessions = client - .list_sessions(&server.uri(), ApiMode::Local, &test_auth()) - .await - .unwrap(); - - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0]["id"], "s1"); - } - #[tokio::test] async fn get_session_success() { let server = MockServer::start().await; @@ -501,7 +408,7 @@ mod tests { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/sessions")) + .and(path("/sessions/s1")) .respond_with( ResponseTemplate::new(403).set_body_json(json!({"message": "Invalid API key"})), ) @@ -510,7 +417,7 @@ mod tests { let client = SteelClient::new().unwrap(); let err = client - .list_sessions(&server.uri(), ApiMode::Local, &test_auth()) + .get_session(&server.uri(), ApiMode::Local, "s1", &test_auth()) .await .unwrap_err(); diff --git a/src/browser/lifecycle.rs b/src/browser/lifecycle.rs index 3ba69bb..6be76a6 100644 --- a/src/browser/lifecycle.rs +++ b/src/browser/lifecycle.rs @@ -11,10 +11,6 @@ use crate::config::settings::ApiMode; #[derive(Debug)] pub struct SessionSummary { pub id: String, - pub mode: ApiMode, - pub name: Option, - pub live: bool, - pub status: Option, pub connect_url: Option, pub viewer_url: Option, pub profile_id: Option, @@ -26,16 +22,12 @@ pub struct CaptchaSolveResult { pub session_id: String, pub success: bool, pub message: Option, - pub raw: Value, } /// Result of checking CAPTCHA status. pub struct CaptchaStatusResult { - pub mode: ApiMode, - pub session_id: String, pub status: String, pub types: Vec, - pub raw: Value, } const CLOSED_SESSION_STATUSES: &[&str] = &[ @@ -202,7 +194,6 @@ pub fn get_viewer_url(session: &Value, mode: ApiMode, session_id: &str) -> Optio pub fn to_session_summary( session: &Value, mode: ApiMode, - name: Option<&str>, auth: &Auth, ) -> anyhow::Result { let session_id = get_session_id(session) @@ -233,10 +224,6 @@ pub fn to_session_summary( Ok(SessionSummary { id: session_id.clone(), - mode, - name: name.map(|s| s.to_string()), - live: is_session_live(session), - status: get_session_status(session), connect_url, viewer_url: get_viewer_url(session, mode, &session_id), profile_id: session @@ -276,7 +263,6 @@ pub async fn solve_captcha( session_id: session_id.to_string(), success, message, - raw, }) } @@ -306,13 +292,7 @@ pub async fn captcha_status( let (status, types) = normalize_captcha_status(&pages); if !wait || is_terminal_captcha_status(&status) { - return Ok(CaptchaStatusResult { - mode, - session_id: session_id.to_string(), - status, - types, - raw: serde_json::json!({"pages": pages}), - }); + return Ok(CaptchaStatusResult { status, types }); } if start.elapsed().as_millis() as u64 >= timeout { @@ -511,6 +491,70 @@ mod tests { let sanitized = sanitize_connect_url(url); assert!(sanitized.contains("sk-1234...")); assert!(sanitized.contains("sessionId=s1")); + assert!(!sanitized.contains("sk-12345678")); + } + + #[test] + fn sanitize_leaves_short_keys_and_other_urls_alone() { + let short = "wss://connect.steel.dev?apiKey=short&sessionId=s1"; + assert!(sanitize_connect_url(short).contains("apiKey=short")); + let local = "ws://localhost:3000/ws?sessionId=sess-1"; + assert_eq!(sanitize_connect_url(local), local); + assert_eq!(sanitize_connect_url("not a valid url"), "not a valid url"); + } + + fn auth(key: Option<&str>) -> Auth { + Auth { + api_key: key.map(str::to_string), + source: crate::config::auth::AuthSource::Env, + } + } + + fn summary(session: Value, mode: ApiMode) -> SessionSummary { + to_session_summary(&session, mode, &auth(Some("sk-test-key-12345678"))).unwrap() + } + + #[test] + fn summary_adds_the_api_key_to_cloud_connect_urls_once() { + let fresh = summary( + json!({"id": "sess-1", "websocketUrl": "wss://connect.steel.dev?sessionId=sess-1"}), + ApiMode::Cloud, + ); + let url = fresh.connect_url.unwrap(); + assert!(url.contains("apiKey=sk-test-key-12345678")); + assert!(url.contains("sessionId=sess-1")); + + let keyed = summary( + json!({"id": "sess-1", "websocketUrl": "wss://connect.steel.dev?apiKey=existing&sessionId=sess-1"}), + ApiMode::Cloud, + ); + let url = keyed.connect_url.unwrap(); + assert!(url.contains("apiKey=existing")); + assert_eq!(url.matches("apiKey").count(), 1); + + let local = summary( + json!({"id": "sess-1", "websocketUrl": "ws://localhost:3000/ws?sessionId=sess-1"}), + ApiMode::Local, + ); + assert!(!local.connect_url.unwrap().contains("apiKey")); + } + + #[test] + fn summary_builds_a_cloud_connect_url_only_in_cloud_mode() { + let cloud = summary(json!({"id": "sess-1"}), ApiMode::Cloud); + let url = cloud.connect_url.unwrap(); + assert!(url.starts_with("wss://connect.steel.dev")); + assert!(url.contains("apiKey=sk-test-key-12345678")); + assert!(url.contains("sessionId=sess-1")); + + let local = to_session_summary(&json!({"id": "sess-1"}), ApiMode::Local, &auth(None)); + assert!(local.unwrap().connect_url.is_none()); + } + + #[test] + fn summary_needs_an_id() { + let missing = to_session_summary(&json!({"status": "live"}), ApiMode::Cloud, &auth(None)); + assert!(missing.is_err()); } #[test] diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index 3f83470..1ceeca4 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -53,7 +53,6 @@ impl BrowserId { pub const fn descriptor(self) -> BrowserDescriptor { match self { Self::Chrome => BrowserDescriptor { - id: self, profile_base_dirs: PlatformStr { darwin: Some("Google/Chrome"), win32: Some("Google/Chrome/User Data"), @@ -71,7 +70,6 @@ impl BrowserId { }, }, Self::Edge => BrowserDescriptor { - id: self, profile_base_dirs: PlatformStr { darwin: Some("Microsoft Edge"), win32: Some("Microsoft/Edge/User Data"), @@ -89,7 +87,6 @@ impl BrowserId { }, }, Self::Brave => BrowserDescriptor { - id: self, profile_base_dirs: PlatformStr { darwin: Some("BraveSoftware/Brave-Browser"), win32: Some("BraveSoftware/Brave-Browser/User Data"), @@ -107,7 +104,6 @@ impl BrowserId { }, }, Self::Arc => BrowserDescriptor { - id: self, profile_base_dirs: PlatformStr { darwin: Some("Arc/User Data"), win32: None, @@ -125,7 +121,6 @@ impl BrowserId { }, }, Self::Opera => BrowserDescriptor { - id: self, profile_base_dirs: PlatformStr { darwin: Some("com.operasoftware.Opera"), win32: Some("Opera Software/Opera Stable"), @@ -144,7 +139,6 @@ impl BrowserId { }, }, Self::Vivaldi => BrowserDescriptor { - id: self, profile_base_dirs: PlatformStr { darwin: Some("Vivaldi"), win32: Some("Vivaldi/User Data"), @@ -195,7 +189,6 @@ pub struct PlatformStrs { } pub struct BrowserDescriptor { - pub id: BrowserId, pub profile_base_dirs: PlatformStr, pub process_names: PlatformStrs, pub keychain_service: PlatformStr, @@ -279,7 +272,6 @@ pub struct BrowserProfile { pub dir_name: String, pub display_name: String, pub email: Option, - pub browser: BrowserId, } pub fn find_browser_profiles(browser: BrowserId) -> Vec { @@ -310,7 +302,6 @@ pub fn find_browser_profiles(browser: BrowserId) -> Vec { dir_name, display_name, email, - browser, }); } profiles.sort_by(|a, b| a.dir_name.cmp(&b.dir_name)); @@ -1212,14 +1203,6 @@ mod tests { assert!("firefox".parse::().is_err()); } - #[test] - fn all_browsers_have_descriptors() { - for id in BrowserId::all() { - let desc = id.descriptor(); - assert_eq!(desc.id, *id); - } - } - #[test] fn minimal_preferences_has_clean_exit() { let data = minimal_preferences(); diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index 72b798f..b913eb6 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -524,7 +524,7 @@ fn parse_tab(value: &str) -> Result { } } -pub(crate) enum Output { +pub enum Output { None, Snapshot, Value, @@ -538,7 +538,7 @@ pub(crate) enum Output { Dialog, } -pub(crate) struct Step { +pub struct Step { pub commands: Vec, pub output: Output, pub uploads: Vec<(PathBuf, String)>, @@ -553,7 +553,7 @@ impl Step { } } - pub(crate) fn batchable(&self) -> bool { + pub fn batchable(&self) -> bool { !matches!(self.output, Output::Switch(_) | Output::TabOpened) && !self .commands @@ -562,7 +562,7 @@ impl Step { } } -pub(crate) enum Plan { +pub enum Plan { Run(Step), Control(Value, Output), Close, @@ -633,7 +633,7 @@ fn image_format(output: &str) -> Option<&'static str> { } impl ActionCommand { - pub(crate) fn plan(self) -> Result { + pub fn plan(self) -> Result { use Output as O; match self { Self::Navigate(a) => { @@ -1022,7 +1022,7 @@ fn session_flag(name: &str) -> String { } } -pub(crate) fn fail(code: &str, message: &str, data: Option<&Value>) -> anyhow::Error { +pub fn fail(code: &str, message: &str, data: Option<&Value>) -> anyhow::Error { if output::is_json() { let mut obj = json!({"success": false, "error": message, "error_code": code}); if let Some(data) = data { @@ -1196,7 +1196,7 @@ fn restarted(body: &Value) -> bool { }) } -pub(crate) struct Browser { +pub struct Browser { client: SteelClient, auth: Auth, pub state: BrowserSession, @@ -1221,7 +1221,7 @@ async fn attach( } impl Browser { - pub(crate) async fn open(name: Option<&str>, start_default: bool) -> Result { + pub async fn open(name: Option<&str>, start_default: bool) -> Result { let (mode, base_url, auth) = api::resolve_with_auth(); let client = SteelClient::new()?; let state = match name { @@ -1409,7 +1409,7 @@ impl Browser { } } - pub(crate) async fn execute(&mut self, steps: Vec, diff: bool) -> Result<()> { + pub async fn execute(&mut self, steps: Vec, diff: bool) -> Result<()> { for (path, name) in steps.iter().flat_map(|step| step.uploads.iter()) { let bytes = std::fs::read(path) .with_context(|| format!("Could not read {}", path.display()))?; @@ -1575,7 +1575,7 @@ impl Browser { }) } - pub(crate) async fn control(&mut self, mut command: Value, output: Output) -> Result<()> { + pub async fn control(&mut self, mut command: Value, output: Output) -> Result<()> { if command["command"] == "dialog" && command.get("dialog").is_none() { let Some(id) = self.state.dialog.clone() else { bail!( diff --git a/src/commands/browser/mod.rs b/src/commands/browser/mod.rs index 6dc86d1..fd65dee 100644 --- a/src/commands/browser/mod.rs +++ b/src/commands/browser/mod.rs @@ -10,7 +10,7 @@ use clap::{Parser, Subcommand}; use crate::browser::state::{self, BrowserSession}; -pub(crate) const DEFAULT_SESSION: &str = "default"; +pub const DEFAULT_SESSION: &str = "default"; #[derive(Parser)] pub struct BrowserArgs { @@ -68,7 +68,7 @@ impl Command { } } -pub(crate) fn existing_session(name: Option<&str>) -> anyhow::Result { +pub fn existing_session(name: Option<&str>) -> anyhow::Result { let wanted = name.unwrap_or(DEFAULT_SESSION); if let Some(found) = state::load(wanted)? { return Ok(found); diff --git a/src/commands/browser/start.rs b/src/commands/browser/start.rs index 4a8f956..af5a069 100644 --- a/src/commands/browser/start.rs +++ b/src/commands/browser/start.rs @@ -86,7 +86,7 @@ pub async fn create( options: &CreateSessionOptions, ) -> anyhow::Result<(BrowserSession, Option)> { let session = client.create_session(base_url, mode, options, auth).await?; - let summary = to_session_summary(&session, mode, Some(name), auth)?; + let summary = to_session_summary(&session, mode, auth)?; let mut created = BrowserSession::new( name.to_string(), summary.id.clone(), diff --git a/src/commands/describe.rs b/src/commands/describe.rs index 5c3de98..c33e3b7 100644 --- a/src/commands/describe.rs +++ b/src/commands/describe.rs @@ -600,4 +600,31 @@ mod tests { assert_eq!(ops[0]["streaming"]["transport"], "websocket"); assert_eq!(ops[0]["streaming"]["path"], "/v1/sessions/{id}/logs"); } + + #[test] + fn generated_operation_ids_are_unique() { + let ids: Vec<_> = CLI_OPERATION_METADATA.iter().map(|op| op.id).collect(); + let unique: std::collections::BTreeSet<_> = ids.iter().collect(); + assert_eq!(ids.len(), unique.len()); + } + + #[test] + fn generated_command_paths_exist_in_clap_tree() { + let root = root_cmd(); + for op in CLI_OPERATION_METADATA { + let mut current = root.clone(); + for segment in op + .command + .split_whitespace() + .filter(|s| !s.starts_with('-')) + { + current = current + .find_subcommand(segment) + .unwrap_or_else(|| { + panic!("generated command path does not exist: {}", op.command) + }) + .clone(); + } + } + } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index fc29ef9..edf77ef 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -215,9 +215,6 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec { checks.push(Check { category: "api", diff --git a/src/commands/settings.rs b/src/commands/settings.rs deleted file mode 100644 index a75c666..0000000 --- a/src/commands/settings.rs +++ /dev/null @@ -1,48 +0,0 @@ -use clap::Parser; -use dialoguer::Select; - -#[derive(Parser)] -pub struct Args; - -pub async fn run(_args: Args) -> anyhow::Result<()> { - let config = crate::config::settings::read_config().ok(); - let current_instance = config - .as_ref() - .and_then(|c| c.instance.as_deref()) - .unwrap_or("cloud"); - - let items = vec![ - format!( - "Cloud{}", - if current_instance == "cloud" { - " (current)" - } else { - "" - } - ), - format!( - "Local{}", - if current_instance == "local" { - " (current)" - } else { - "" - } - ), - ]; - - let selection = Select::new() - .with_prompt("Select instance type") - .items(&items) - .default(if current_instance == "local" { 1 } else { 0 }) - .interact()?; - - let value = if selection == 0 { "cloud" } else { "local" }; - - let mut config = crate::config::settings::read_config().unwrap_or_default(); - config.instance = Some(value.to_string()); - crate::config::settings::write_config(&config)?; - - println!("Instance set to: {value}"); - - Ok(()) -} diff --git a/src/commands/skills/mod.rs b/src/commands/skills/mod.rs index 5395a0c..5c9ea8a 100644 --- a/src/commands/skills/mod.rs +++ b/src/commands/skills/mod.rs @@ -264,10 +264,6 @@ pub async fn install_names(names: &[String], yes: bool) -> anyhow::Result<()> { install_names_with_options(names, None, false, yes).await } -pub async fn install_all(yes: bool) -> anyhow::Result<()> { - install_all_with_options(None, false, yes).await -} - pub async fn install_catalog_flow() -> anyhow::Result<()> { run_npx(&["skills", "add", REPO_SPEC]) .context("starting Steel skills installer. You can also run manually: npx skills add steel-dev/skills")?; diff --git a/src/config/settings.rs b/src/config/settings.rs index ac4aab6..15650f3 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -193,10 +193,6 @@ pub fn write_config_to(path: &Path, config: &Config) -> Result<()> { Ok(()) } -pub fn write_config(config: &Config) -> Result<()> { - write_config_to(&super::config_path(), config) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 7118dea..6c65a4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,11 @@ #![allow(clippy::too_many_arguments)] -pub mod api; -pub mod browser; -pub mod commands; -pub mod config; -pub mod telemetry; -pub mod util; +mod api; +mod browser; +mod commands; +mod config; +mod telemetry; +mod util; + +pub use commands::{Cli, run}; +pub use util::output::handle_error; diff --git a/src/main.rs b/src/main.rs index 0f0cfe7..9946b44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ use clap::Parser; -use steel_cli::commands::{self, Cli}; +use steel_cli::Cli; #[tokio::main] async fn main() { let cli = Cli::parse(); - if let Err(e) = commands::run(cli).await { - steel_cli::util::output::handle_error(&e); + if let Err(e) = steel_cli::run(cli).await { + steel_cli::handle_error(&e); } } diff --git a/src/telemetry.rs b/src/telemetry.rs index 154a913..c0d8bd4 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -397,7 +397,6 @@ fn error_class(err: &anyhow::Error) -> &'static str { s if s >= 500 => "api_server_error", _ => "api_request_failed", }, - crate::api::client::ApiError::RetryLater { .. } => "api_not_ready", crate::api::client::ApiError::Other(_) => "network_error", }; } diff --git a/src/util/api.rs b/src/util/api.rs index cb6cd53..65d5a6c 100644 --- a/src/util/api.rs +++ b/src/util/api.rs @@ -26,11 +26,6 @@ fn api_url() -> Option<&'static str> { API_URL.get().and_then(|o| o.as_deref()) } -/// Resolve the API mode from global flags. -pub fn mode() -> ApiMode { - ApiMode::resolve(is_local(), api_url()) -} - /// Resolve API mode and base URL from global flags + env + config. pub fn resolve() -> (ApiMode, String) { let mode = ApiMode::resolve(is_local(), api_url()); diff --git a/src/util/output.rs b/src/util/output.rs index d937b2e..51c4acd 100644 --- a/src/util/output.rs +++ b/src/util/output.rs @@ -6,7 +6,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use serde_json::{Value, json}; static JSON_MODE: AtomicBool = AtomicBool::new(false); -static IS_TTY: AtomicBool = AtomicBool::new(true); static NO_COLOR: AtomicBool = AtomicBool::new(false); /// Initialize output settings. Called once at startup. @@ -21,29 +20,17 @@ pub fn init(explicit_json: bool) { let force_tty = std::env::var("STEEL_FORCE_TTY").is_ok_and(|v| v == "1"); let tty = force_tty || stdout_is_tty; - IS_TTY.store(tty, Ordering::Relaxed); NO_COLOR.store(std::env::var("NO_COLOR").is_ok(), Ordering::Relaxed); let json = explicit_json || !tty; JSON_MODE.store(json, Ordering::Relaxed); } -/// Enable JSON output mode directly. For tests only. -#[cfg(test)] -pub fn set_json_mode(enabled: bool) { - JSON_MODE.store(enabled, Ordering::Relaxed); -} - /// Check if JSON output mode is active. pub fn is_json() -> bool { JSON_MODE.load(Ordering::Relaxed) } -/// Check if stdout is a TTY (terminal). -pub fn is_tty() -> bool { - IS_TTY.load(Ordering::Relaxed) -} - /// Check if `NO_COLOR` environment variable is set. pub fn no_color() -> bool { NO_COLOR.load(Ordering::Relaxed) @@ -110,38 +97,6 @@ fn format_success_silent(json_mode: bool) -> Option { } } -fn format_success_field(json_mode: bool, data: &Value, field: &str) -> Option { - if json_mode { - Some(format_success_data(json_mode, data)) - } else { - let v = &data[field]; - if let Some(s) = v.as_str() { - Some(s.to_string()) - } else if !v.is_null() { - Some(v.to_string()) - } else { - None - } - } -} - -fn format_success_text(json_mode: bool, data: &Value) -> Option { - if json_mode { - Some(format_success_data(json_mode, data)) - } else { - data.as_str().map(|s| s.to_string()) - } -} - -#[cfg(test)] -fn format_error(json_mode: bool, msg: &str) -> String { - if json_mode { - json!({"success": false, "error": msg}).to_string() - } else { - format!("Error: {msg}") - } -} - // --- Public I/O functions (delegate to format_*) --- /// Print a success response. In JSON mode, wraps data in `{"success": true, "data": ...}`. @@ -168,21 +123,6 @@ pub fn success_silent() { } } -/// Print success: JSON wraps in envelope, text prints `data[field]`. -/// Strings are printed without quotes; numbers/bools are printed as-is. -pub fn success_field(data: Value, field: &str) { - if let Some(output) = format_success_field(is_json(), &data, field) { - println!("{output}"); - } -} - -/// Print success: JSON wraps in envelope, text prints data as a string. -pub fn success_text(data: Value) { - if let Some(output) = format_success_text(is_json(), &data) { - println!("{output}"); - } -} - pub fn print_table(header: &[&str], rows: &[Vec]) { let widths: Vec = header .iter() @@ -253,11 +193,6 @@ fn classify_error(err: &anyhow::Error) -> (i32, &'static str, Option<&'static st "unreachable", Some("Check your network connection and API URL."), ), - crate::api::client::ApiError::RetryLater { .. } => ( - exit_code::API_SERVER, - "not_ready", - Some("The API asked to retry later. Try again shortly."), - ), crate::api::client::ApiError::RequestFailed { status, .. } => match *status { 401 => ( exit_code::AUTH, @@ -353,79 +288,6 @@ mod tests { assert!(format_success_silent(false).is_none()); } - // --- format_success_field --- - - #[test] - fn format_success_field_json_mode() { - let data = json!({"url": "https://example.com"}); - let out = format_success_field(true, &data, "url").unwrap(); - let parsed: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(parsed["success"], true); - assert_eq!(parsed["data"]["url"], "https://example.com"); - } - - #[test] - fn format_success_field_text_string() { - let data = json!({"name": "Alice"}); - let out = format_success_field(false, &data, "name").unwrap(); - assert_eq!(out, "Alice"); - } - - #[test] - fn format_success_field_text_number() { - let data = json!({"count": 42}); - let out = format_success_field(false, &data, "count").unwrap(); - assert_eq!(out, "42"); - } - - #[test] - fn format_success_field_text_null() { - let data = json!({"missing": null}); - let out = format_success_field(false, &data, "missing"); - assert!(out.is_none()); - } - - // --- format_success_text --- - - #[test] - fn format_success_text_json_mode() { - let data = json!("hello"); - let out = format_success_text(true, &data).unwrap(); - let parsed: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(parsed["success"], true); - assert_eq!(parsed["data"], "hello"); - } - - #[test] - fn format_success_text_text_string() { - let data = json!("hello"); - let out = format_success_text(false, &data).unwrap(); - assert_eq!(out, "hello"); - } - - #[test] - fn format_success_text_text_non_string() { - let data = json!(123); - let out = format_success_text(false, &data); - assert!(out.is_none()); - } - - // --- format_error --- - - #[test] - fn format_error_json_mode() { - let out = format_error(true, "something broke"); - let parsed: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(parsed["success"], false); - assert_eq!(parsed["error"], "something broke"); - } - - #[test] - fn format_error_text_mode() { - let out = format_error(false, "something broke"); - assert_eq!(out, "Error: something broke"); - } - // --- classify_error --- #[test] diff --git a/tests/api_openapi_coverage.rs b/tests/api_openapi_coverage.rs deleted file mode 100644 index 1cf134a..0000000 --- a/tests/api_openapi_coverage.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Ensures generated API metadata points at real CLI commands. - -use std::collections::BTreeSet; - -use clap::CommandFactory; - -use steel_cli::api::generated::CLI_OPERATION_METADATA; -use steel_cli::commands; - -fn generated_operation_ids_are_unique() { - let operation_ids: Vec<_> = CLI_OPERATION_METADATA - .iter() - .map(|operation| operation.id) - .collect(); - let unique_ids: BTreeSet<_> = operation_ids.iter().copied().collect(); - assert_eq!(operation_ids.len(), unique_ids.len()); -} - -fn generated_command_paths() -> BTreeSet { - CLI_OPERATION_METADATA - .iter() - .map(|operation| { - operation - .command - .split_whitespace() - .filter(|part| !part.starts_with('-')) - .collect::>() - .join(" ") - }) - .collect() -} - -#[test] -fn generated_operation_ids_are_unique_test() { - generated_operation_ids_are_unique(); -} - -#[test] -fn generated_command_paths_exist_in_clap_tree() { - let root = commands::Cli::command(); - - for command_path in generated_command_paths() { - let mut current = root.clone(); - for segment in command_path.split_whitespace() { - let next = current - .get_subcommands() - .find(|subcommand| subcommand.get_name() == segment) - .unwrap_or_else(|| panic!("generated command path does not exist: {command_path}")) - .clone(); - current = next; - } - } -} diff --git a/tests/cli_compat.rs b/tests/cli_compat.rs index a18c97c..918e1a5 100644 --- a/tests/cli_compat.rs +++ b/tests/cli_compat.rs @@ -8,7 +8,7 @@ use std::path::Path; use clap::{ArgAction, Command, CommandFactory}; -use steel_cli::commands; +use steel_cli::Cli; /// Describes a single CLI flag. #[derive(Debug)] @@ -130,7 +130,7 @@ fn get_subcommand(root: &Command, path: &[&str]) -> Command { } fn root_cmd() -> Command { - commands::Cli::command() + Cli::command() } /// Verify the complete subcommand tree matches the spec. diff --git a/tests/dev_command.rs b/tests/dev_command.rs index 921ac03..0812259 100644 --- a/tests/dev_command.rs +++ b/tests/dev_command.rs @@ -24,37 +24,6 @@ fn steel_with_tmp_config(tmp: &tempfile::TempDir) -> Command { cmd } -// ─── repo_path derivation ──────────────────────────────────────────────────── - -#[test] -fn repo_path_is_config_dir_plus_steel_browser() { - // The config module is public, so we can verify the derivation in-process. - let dir = steel_cli::config::config_dir_with(Some("/tmp/test-steel-cfg")); - let repo = dir.join("steel-browser"); - assert_eq!( - repo, - std::path::PathBuf::from("/tmp/test-steel-cfg/steel-browser"), - ); -} - -#[test] -fn repo_path_respects_steel_config_dir_env() { - // When STEEL_CONFIG_DIR is set, config_dir() picks it up. - let dir = steel_cli::config::config_dir_with(Some("/custom/dir")); - assert_eq!(dir, std::path::PathBuf::from("/custom/dir")); -} - -#[test] -fn repo_path_falls_back_to_home_config() { - // When env var is absent, falls back to ~/.config/steel - let dir = steel_cli::config::config_dir_with(None); - assert!( - dir.ends_with(".config/steel"), - "expected path ending with .config/steel, got: {}", - dir.display() - ); -} - // ─── Help output: verify flags exist ───────────────────────────────────────── #[test] @@ -209,7 +178,7 @@ fn start_default_port_is_3000() { // Verify via clap introspection that port default is None (meaning the code // falls back to 3000 in run_start). use clap::CommandFactory; - let root = steel_cli::commands::Cli::command(); + let root = steel_cli::Cli::command(); let dev = root .get_subcommands() .find(|s| s.get_name() == "dev") diff --git a/tests/lifecycle_contract.rs b/tests/lifecycle_contract.rs deleted file mode 100644 index 1352316..0000000 --- a/tests/lifecycle_contract.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! Integration tests for browser session lifecycle contracts. -//! -//! Pure-function tests for `to_session_summary`, `sanitize_connect_url`, and -//! connect-URL injection logic. - -use serde_json::json; - -use steel_cli::browser::lifecycle::*; -use steel_cli::config::auth::{Auth, AuthSource}; -use steel_cli::config::settings::ApiMode; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn cloud_auth() -> Auth { - Auth { - api_key: Some("sk-test-key-12345678".to_string()), - source: AuthSource::Env, - } -} - -const fn local_auth() -> Auth { - Auth { - api_key: None, - source: AuthSource::None, - } -} - -// =========================================================================== -// Connect URL Contract: injects apiKey into connect URL for cloud mode -// =========================================================================== - -#[test] -fn injects_api_key_into_connect_url_for_cloud() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - "websocketUrl": "wss://connect.steel.dev?sessionId=sess-1", - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); - - let url = summary.connect_url.unwrap(); - assert!( - url.contains("apiKey=sk-test-key-12345678"), - "Expected apiKey in URL, got: {url}" - ); - assert!( - url.contains("sessionId=sess-1"), - "Expected sessionId preserved in URL, got: {url}" - ); -} - -#[test] -fn does_not_inject_api_key_for_local_mode() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - "websocketUrl": "ws://localhost:3000/ws?sessionId=sess-1", - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap(); - - let url = summary.connect_url.unwrap(); - assert!( - !url.contains("apiKey"), - "Local mode should not inject apiKey, got: {url}" - ); -} - -#[test] -fn does_not_duplicate_api_key_if_already_present() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - "websocketUrl": "wss://connect.steel.dev?apiKey=existing-key&sessionId=sess-1", - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); - - let url = summary.connect_url.unwrap(); - // Should keep existing key, not inject a second one - assert!(url.contains("apiKey=existing-key")); - let count = url.matches("apiKey").count(); - assert_eq!( - count, 1, - "Expected exactly one apiKey, got {count} in: {url}" - ); -} - -// =========================================================================== -// Connect URL Contract: fallback builds wss://connect.steel.dev URL -// =========================================================================== - -#[test] -fn builds_fallback_connect_url_when_not_provided() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); - - let url = summary.connect_url.unwrap(); - assert!( - url.starts_with("wss://connect.steel.dev"), - "Expected fallback URL, got: {url}" - ); - assert!( - url.contains("apiKey=sk-test-key-12345678"), - "Expected apiKey in fallback URL, got: {url}" - ); - assert!( - url.contains("sessionId=sess-1"), - "Expected sessionId in fallback URL, got: {url}" - ); -} - -#[test] -fn no_fallback_connect_url_for_local_mode() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - }); - let auth = local_auth(); - let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap(); - - assert!( - summary.connect_url.is_none(), - "Local mode without connect URL should not build a fallback" - ); -} - -// =========================================================================== -// Connect URL Contract: sanitize_connect_url masks apiKey -// =========================================================================== - -#[test] -fn sanitize_masks_api_key_in_url() { - let url = "wss://connect.steel.dev?apiKey=sk-12345678901234&sessionId=sess-1"; - let sanitized = sanitize_connect_url(url); - - assert!( - sanitized.contains("sk-1234..."), - "Expected masked key, got: {sanitized}" - ); - assert!( - sanitized.contains("sessionId=sess-1"), - "Expected sessionId preserved, got: {sanitized}" - ); - assert!( - !sanitized.contains("sk-12345678901234"), - "Full API key should be masked, got: {sanitized}" - ); -} - -#[test] -fn sanitize_preserves_short_api_key() { - // Keys with 7 or fewer chars are not masked (threshold check) - let url = "wss://connect.steel.dev?apiKey=short&sessionId=s1"; - let sanitized = sanitize_connect_url(url); - assert!( - sanitized.contains("apiKey=short"), - "Short key should not be masked, got: {sanitized}" - ); -} - -#[test] -fn sanitize_handles_url_without_api_key() { - let url = "ws://localhost:3000/ws?sessionId=sess-1"; - let sanitized = sanitize_connect_url(url); - assert_eq!(sanitized, url); -} - -#[test] -fn sanitize_handles_invalid_url() { - let url = "not a valid url"; - let sanitized = sanitize_connect_url(url); - assert_eq!(sanitized, url); -} - -// =========================================================================== -// Viewer URL tests -// =========================================================================== - -#[test] -fn viewer_url_defaults_to_cloud_url() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); - - assert_eq!( - summary.viewer_url.as_deref(), - Some("https://app.steel.dev/sessions/sess-1") - ); -} - -#[test] -fn viewer_url_none_for_local_without_explicit() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - }); - let auth = local_auth(); - let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap(); - - assert!(summary.viewer_url.is_none()); -} - -#[test] -fn viewer_url_uses_api_response_when_present() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - "sessionViewerUrl": "https://custom-viewer.example.com/view/sess-1", - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); - - assert_eq!( - summary.viewer_url.as_deref(), - Some("https://custom-viewer.example.com/view/sess-1") - ); -} - -// =========================================================================== -// to_session_summary edge cases -// =========================================================================== - -#[test] -fn to_session_summary_requires_id() { - let session = json!({"status": "live"}); - let auth = cloud_auth(); - let result = to_session_summary(&session, ApiMode::Cloud, None, &auth); - assert!(result.is_err()); -} - -#[test] -fn to_session_summary_passes_name_through() { - let session = json!({ - "id": "sess-1", - "status": "live", - "isLive": true, - }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, Some("my-session"), &auth).unwrap(); - assert_eq!(summary.name.as_deref(), Some("my-session")); -} From c71fdd9c934135c8da39b095e5cfb5b110c119c5 Mon Sep 17 00:00:00 2001 From: junhsss Date: Sat, 31 Oct 2026 01:29:02 +0900 Subject: [PATCH 2/8] docs(cli): generate the long help from clap --- README.md | 2 +- docs/references/steel-browser.md | 1 - docs/references/steel-cli.md | 4 +- src/commands/browser/batch.rs | 2 +- src/commands/describe.rs | 85 +++++++++ src/commands/mod.rs | 296 ++++++++----------------------- src/lib.rs | 2 +- src/main.rs | 5 +- 8 files changed, 164 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index a8a4da4..b80ea3c 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Cloud precedence: ## Output and Runtime Notes -- `steel scrape` defaults to markdown-first output for token efficiency; use `--raw` for the full JSON payload. +- `steel scrape` prints markdown by default for token efficiency; use `--json` for the full API response. - `steel browser start` prints a display-safe connect URL with sensitive query values redacted. - Piped output auto-switches to JSON for machine-readable workflows. Use `--json` to force it, or `STEEL_FORCE_TTY=1` to disable. diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index aa36a38..081f143 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -142,7 +142,6 @@ Main flags: - `--task-id ` - `--local` - `--api-url ` -- `--raw` API mapping: diff --git a/docs/references/steel-cli.md b/docs/references/steel-cli.md index 8e8cb30..f93c1e1 100644 --- a/docs/references/steel-cli.md +++ b/docs/references/steel-cli.md @@ -64,8 +64,8 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer keys redacted. - `steel scrape`, `steel screenshot`, and `steel pdf` share local/cloud endpoint resolution conventions with browser lifecycle commands. -- `steel scrape` defaults to markdown-first output for token efficiency; use - `--raw` to print the full API JSON payload. +- `steel scrape` prints markdown by default for token efficiency; use `--json` + to print the full API response. ## Core Config Paths diff --git a/src/commands/browser/batch.rs b/src/commands/browser/batch.rs index 3698ebd..fa2e4ca 100644 --- a/src/commands/browser/batch.rs +++ b/src/commands/browser/batch.rs @@ -22,7 +22,7 @@ struct ActionParser { const MAX_COMMANDS: usize = 50; -fn split_command_str(s: &str) -> Vec { +pub fn split_command_str(s: &str) -> Vec { let mut args = Vec::new(); let mut current = String::new(); let mut in_quote: Option = None; diff --git a/src/commands/describe.rs b/src/commands/describe.rs index c33e3b7..e3c2e8b 100644 --- a/src/commands/describe.rs +++ b/src/commands/describe.rs @@ -394,6 +394,91 @@ fn has_visible_subcommands(cmd: &clap::Command) -> bool { .any(|s| !s.is_hide_set() && s.get_name() != "help") } +const OVERVIEW_COLUMN: usize = 40; + +pub fn overview(root: &clap::Command) -> String { + let mut lines = Vec::new(); + for sub in get_visible_subcommands(root) { + overview_lines(&sub, &format!("steel {}", sub.get_name()), &mut lines); + } + lines.join("\n") +} + +fn overview_lines(cmd: &clap::Command, path: &str, lines: &mut Vec) { + let (positionals, flags): (Vec<&clap::Arg>, Vec<&clap::Arg>) = cmd + .get_arguments() + .filter(|a| is_visible_arg(a)) + .partition(|a| a.is_positional()); + let usage = positionals.iter().fold(path.to_string(), |usage, arg| { + format!("{usage} {}", placeholder(arg)) + }); + let mut about = cmd.get_about().map(|a| a.to_string()).unwrap_or_default(); + let aliases = get_aliases(cmd); + if !aliases.is_empty() { + about.push_str(&format!(" (aliases: {})", aliases.join(", "))); + } + lines.push(overview_row(2, &usage, &about)); + for arg in flags { + lines.push(overview_row(4, &flag_usage(arg), &arg_summary(arg))); + } + for sub in get_visible_subcommands(cmd) { + overview_lines(&sub, &format!("{path} {}", sub.get_name()), lines); + } +} + +fn overview_row(indent: usize, left: &str, right: &str) -> String { + let left = format!("{:indent$}{left}", ""); + if right.is_empty() { + return left; + } + let width = OVERVIEW_COLUMN.max(left.len() + 2); + format!("{left: String { + arg.get_value_names() + .and_then(|names| names.first()) + .map_or_else(|| arg.get_id().to_string(), |name| name.to_string()) +} + +fn takes_many(arg: &clap::Arg) -> bool { + arg.get_num_args() + .is_some_and(|range| range.max_values() > 1) +} + +fn placeholder(arg: &clap::Arg) -> String { + let name = value_name(arg); + let many = if takes_many(arg) { "..." } else { "" }; + if arg.is_required_set() { + format!("<{name}>{many}") + } else { + format!("[{name}]{many}") + } +} + +fn flag_usage(arg: &clap::Arg) -> String { + let mut usage = match (arg.get_short(), arg.get_long()) { + (Some(short), Some(long)) => format!("-{short}, --{long}"), + (Some(short), None) => format!("-{short}"), + (None, Some(long)) => format!("--{long}"), + (None, None) => value_name(arg), + }; + if arg.get_action().takes_values() { + usage.push_str(&format!(" <{}>", value_name(arg))); + } + usage +} + +fn arg_summary(arg: &clap::Arg) -> String { + let mut summary = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); + if arg.get_action().takes_values() + && let Some(default) = arg.get_default_values().first() + { + summary.push_str(&format!(" [default: {}]", default.to_string_lossy())); + } + summary +} + // ── Tests ─────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b1d9daf..3347623 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -18,228 +18,69 @@ pub mod sessions; pub mod skills; pub mod update; -use clap::{Parser, Subcommand, builder::styling::Styles}; - -const LONG_HELP: &str = "\ -Global Flags: - --json Structured JSON output for automation - -l, --local Use local Steel runtime - --api-url Explicit self-hosted API endpoint URL - -Getting Started: - steel init Log in, verify, and install Steel skills into detected agents - --agent Auto-accept prompts and print agent-friendly output - steel skills list List available Steel Skills - steel skills install --all Install all Steel Skills through npx skills - steel skills install Install a Steel Skill through npx skills - steel skills doctor Check skills installer, auth, manifest, and paths - -Quick Actions: - steel scrape Scrape webpage content (markdown by default) - --format Output formats: html, readability, cleaned_html, markdown - -d, --delay Delay before scraping - --pdf / --screenshot Include PDF or screenshot in response - --use-proxy Use Steel-managed residential proxy - -r, --region Region for request execution - -l, --local Use local Steel runtime - steel screenshot Capture a screenshot - -f, --full-page Full-page capture - steel pdf Generate a PDF from a webpage - -Browser Sessions: - steel browser start [--session ] Create a browser session - --stealth Enable stealth mode (humanize + auto CAPTCHA) - -p, --proxy Use residential proxy - --session-timeout Session timeout - --inactivity-timeout Release after this long without commands (default: 10 min) - --session-solve-captcha Enable manual CAPTCHA solving - --profile Named profile to persist browser state - --update-profile Save state back to profile on session end - --namespace / --credentials Inject stored credentials - steel browser stop [--session ] Stop a session (-a for all) - steel browser sessions List active sessions (use --json for structured output) - steel browser live [--session ] Open live session viewer - steel browser batch \"\" \"\" Run several commands in one request - --session Target a named session or a session ID - --diff Print what a command changed on the page - -Cloud Sessions: - steel sessions list List cloud sessions - --status Filter: live, released, failed - --limit Number of sessions to return - steel sessions get Show cloud session details - steel sessions release Release a cloud session - -a, --all Release all active cloud sessions - steel sessions logs Show browser event logs - -f, --follow Follow live logs over WebSocket - --since