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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,239 changes: 1,186 additions & 53 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "steel-cli"
version = "0.4.4"
version = "0.5.0-preview.1"
edition = "2024"
description = "Steel CLI - Browser automation for AI agents"
license = "MIT"
Expand Down Expand Up @@ -44,13 +44,15 @@ aes-gcm = "0.10"
getrandom = "0.4"

libc = "0.2"
rustix = { version = "1", features = ["termios"] }
tempfile = "3"
jiff = "0.2"
parking_lot = "0.12"

serde_yaml = "0.9"
tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3.32"
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }

[dev-dependencies]
wiremock = "0.6"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`.
| API tools | `scrape`, `screenshot`, `pdf` |
| Local runtime | `dev install`, `dev start`, `dev stop` |
| Credentials | `credentials list`, `credentials create`, `credentials update`, `credentials delete` |
| Cloud computers | `computer create`, `computer exec`, `computer ssh`, `computer list`, `computer use` |
| Account and utility | `login`, `logout`, `config`, `doctor`, `cache`, `update`, `completion` |

Full flags and schemas: [CLI reference](docs/cli-reference.md).
Expand Down
9 changes: 9 additions & 0 deletions docs/references/steel-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer
- `steel profile list`: list all saved Steel browser profiles.
- `steel profile delete`: delete a saved Steel profile (local metadata only).

### Computer Commands

- `steel computer create`: create a cloud computer from a template.
- `steel computer list`, `steel computer get`, `steel computer delete`: inspect and remove computers.
- `steel computer pause`, `steel computer resume`: pause and wake a computer.
- `steel computer use`: remember a default computer so other commands need no id.
- `steel computer exec -- <command>`: run one command; output streams and the exit code is returned.
- `steel computer ssh`: open an SSH shell, or run a command over SSH with `-- <command>`.

### Credentials Commands

- `steel credentials create`: store a new credential for a given origin.
Expand Down
19 changes: 14 additions & 5 deletions scripts/generate-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function collectOperations(spec) {
requestPath: stripV1(path),
summary: operation.summary || operation.operationId,
command: cli.command.join(" "),
example: exampleFor(cli.command, operation),
example: exampleFor(cli.command, operation, path),
streaming: cli.follow
? {
transport: cli.follow.transport || "websocket",
Expand All @@ -91,17 +91,25 @@ function collectOperations(spec) {
return operations;
}

function exampleFor(command, operation) {
function exampleFor(command, operation, path) {
const cli = operation["x-steel-cli"] || {};
if (cli.example) return cli.example;
const base = `steel ${command.join(" ")}`;
const id = idPlaceholder(path);
if (command.includes("list")) return `${base} --status live --limit 20`;
if (command.includes("agent-logs")) return `${base} <session-id> --limit 100`;
if (operation["x-steel-cli"]?.follow) return `${base} <session-id> --follow`;
if (command.includes("agent-logs")) return `${base} ${id} --limit 100`;
if (cli.follow) return `${base} ${id} --follow`;
if (operation.parameters?.some((parameter) => parameter.in === "path" && parameter.name === "id")) {
return `${base} <session-id>`;
return `${base} ${id}`;
}
return base;
}

function idPlaceholder(path) {
const resource = stripV1(path).split("/").filter(Boolean)[0] || "sessions";
return `<${resource.replace(/s$/, "")}-id>`;
}

function rustQueryType(parameter) {
const schema = parameter.schema || {};
if (schema.type === "array") return "Vec<String>";
Expand Down Expand Up @@ -264,6 +272,7 @@ mod tests {
cursor_id: Some("abc".into()),
limit: Some(25),
status: Some("live".into()),
..Default::default()
});
assert_eq!(path, "/sessions?cursorId=abc&limit=25&status=live");
}
Expand Down
74 changes: 74 additions & 0 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ pub enum ApiError {
body: Option<Value>,
},

#[error("Steel API request failed ({status}): {message}")]
RetryLater {
status: u16,
message: Cow<'static, str>,
seconds: u64,
},

#[error(transparent)]
Other(#[from] reqwest::Error),
}
Expand Down Expand Up @@ -58,6 +65,12 @@ fn extract_error_message(body: &Value, status_text: &str) -> Cow<'static, str> {
return Cow::Owned(msg.to_string());
}

if let Some(msg) = body.get("error").and_then(|v| v.as_str())
&& !msg.trim().is_empty()
{
return Cow::Owned(msg.to_string());
}

if !status_text.is_empty() {
return Cow::Owned(status_text.to_string());
}
Expand Down Expand Up @@ -133,6 +146,67 @@ impl SteelClient {

Ok(response_data)
}

pub async fn request_raw(
&self,
base_url: &str,
mode: ApiMode,
method: reqwest::Method,
path: &str,
body: Option<Value>,
auth: &Auth,
) -> Result<reqwest::Response, ApiError> {
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::<u64>().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)]
Expand Down
Loading
Loading