mirror of
https://github.com/steel-dev/cli.git
synced 2026-09-14 20:47:34 +08:00
fix(init): clean up agent onboarding leftovers and persist onboarding source (#102)
This commit is contained in:
+5
-4
@@ -8,8 +8,9 @@ Installs the Steel CLI binary and, in an interactive terminal, runs
|
||||
|
||||
Flags:
|
||||
--non-interactive Skip the interactive `steel init` step (install only)
|
||||
--agent Run `steel init --agent` (print the onboarding guide
|
||||
to stdout; intended for AI coding agents)
|
||||
--agent Run `steel init --agent` (non-interactive: auto-accept
|
||||
prompts and print agent-friendly output; intended for
|
||||
AI coding agents)
|
||||
--from <name> Tag the originating coding agent (claude-code, cursor,
|
||||
opencode, codex, ...). Informational only; used for
|
||||
onboarding telemetry. Supports `--from=<name>` too.
|
||||
@@ -230,8 +231,8 @@ if [ -n "$_completion_shell" ]; then
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Agent mode just prints the onboarding guide to stdout, which needs no TTY,
|
||||
# so it runs regardless of whether the surrounding shell is interactive.
|
||||
# Agent mode auto-accepts prompts and needs no TTY, so it runs regardless of
|
||||
# whether the surrounding shell is interactive.
|
||||
# Human mode drives `dialoguer` prompts, so we only run it when /dev/tty
|
||||
# exists.
|
||||
if [ "$STEEL_AGENT_MODE" = "yes" ]; then
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# Steel — Agent Onboarding Guide
|
||||
|
||||
You are an AI coding agent setting up Steel for your user. Steel is a cloud
|
||||
browser API purpose-built for agents: your user wants you to be able to
|
||||
navigate real web pages, fill forms, take screenshots, and bypass bot
|
||||
protection on their behalf.
|
||||
|
||||
Your job is to get Steel installed, authenticated, and verified — **without
|
||||
asking the user to do more than one thing** (clicking an authentication link).
|
||||
|
||||
---
|
||||
|
||||
## Hard rules — read these first
|
||||
|
||||
1. If any step fails, run `steel doctor` and report the failing check to the
|
||||
user. Do not guess at fixes — `steel doctor` will tell you exactly what's
|
||||
wrong and how to repair it.
|
||||
2. Do not run `steel forge`, `steel dev install`, or other scaffolding
|
||||
commands unless the user explicitly asks for them. This guide is only
|
||||
about **getting Steel ready to use**, not creating a starter project.
|
||||
3. If `steel --version` is already available and `steel doctor --preflight`
|
||||
reports healthy, Steel is already set up — tell the user and stop. Do not
|
||||
re-run login.
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1 — Add the Steel binary to PATH
|
||||
|
||||
You're reading this guide because `steel init --agent` just ran — the native
|
||||
binary is already installed at `~/.steel/bin/steel`. The installer appended
|
||||
`~/.steel/bin` to the user's shell rc file, but **that change won't affect
|
||||
your current session**. Add it to PATH now so the rest of this guide works
|
||||
as written:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.steel/bin:$PATH"
|
||||
steel --version
|
||||
```
|
||||
|
||||
If for some reason the binary is not present, install it:
|
||||
|
||||
```bash
|
||||
curl -fsS https://setup.steel.dev | sh
|
||||
export PATH="$HOME/.steel/bin:$PATH"
|
||||
```
|
||||
|
||||
### Step 2 — Authenticate
|
||||
|
||||
The `--agent` flag you just ran only prints this guide. Now run the real
|
||||
thing:
|
||||
|
||||
```bash
|
||||
steel init
|
||||
```
|
||||
|
||||
This command is idempotent: it will no-op if the user is already logged in.
|
||||
|
||||
On first run it will:
|
||||
|
||||
1. Print an authentication URL and open a browser to it.
|
||||
2. Wait (up to 5 minutes) for the user to sign in.
|
||||
3. Persist the resulting API key to the user's Steel config.
|
||||
|
||||
**Your job during this step:** show the user the URL Steel printed and say
|
||||
something like *"Please click this link to sign in. I'll wait here."* Do not
|
||||
continue until Steel prints `Authentication successful!`.
|
||||
|
||||
If the user is in a sandboxed environment without a local browser (e.g.
|
||||
Claude Code Web, Replit, GitHub Codespaces without port-forwarding), the
|
||||
authentication URL will still work — the user just needs to open it from any
|
||||
device they have a browser on. Tell them this explicitly if the browser does
|
||||
not open automatically.
|
||||
|
||||
### Step 3 — Preflight check
|
||||
|
||||
Verify auth + API connectivity:
|
||||
|
||||
```bash
|
||||
steel doctor --preflight
|
||||
```
|
||||
|
||||
All checks should report `pass`. If any report `fail`, surface the `fix`
|
||||
column of the output to the user verbatim and stop.
|
||||
|
||||
### Step 4 — Smoke test
|
||||
|
||||
Confirm Steel can actually run a browser for you:
|
||||
|
||||
```bash
|
||||
steel browser start --session hello
|
||||
steel browser navigate https://example.com --session hello
|
||||
steel browser get title --session hello
|
||||
steel browser stop --session hello
|
||||
```
|
||||
|
||||
If the last command returns `"Example Domain"` as the page title, Steel is
|
||||
ready. Tell the user:
|
||||
|
||||
> Steel is set up and ready. You can now ask me to do any web task and I'll
|
||||
> use Steel to handle pages that need JavaScript, login flows, or CAPTCHA
|
||||
> bypass.
|
||||
|
||||
---
|
||||
|
||||
## Tooling overview (cheatsheet for after setup)
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Scrape a page to Markdown | `steel scrape <url>` |
|
||||
| Take a screenshot | `steel screenshot <url>` |
|
||||
| Save a page as PDF | `steel pdf <url>` |
|
||||
| Start an interactive browser | `steel browser start --session <name>` |
|
||||
| Navigate / click / fill in a session | `steel browser navigate / click / fill` |
|
||||
| See all sessions | `steel browser sessions` |
|
||||
| End a session | `steel browser stop --session <name>` |
|
||||
| Check environment health | `steel doctor` |
|
||||
|
||||
Full reference: https://docs.steel.dev
|
||||
|
||||
---
|
||||
|
||||
## Common failure modes
|
||||
|
||||
| Symptom | What to do |
|
||||
|---|---|
|
||||
| `steel: command not found` | Repeat Step 1: `export PATH="$HOME/.steel/bin:$PATH"`. If `~/.steel/bin/steel` doesn't exist, re-run `curl -fsS https://setup.steel.dev \| sh`. |
|
||||
| Browser does not open on `steel init` | Show the URL to the user and ask them to open it manually on any device. Wait for `Authentication successful!`. |
|
||||
| `steel doctor --preflight` reports `auth: fail` | Run `steel login` again. If it still fails, the user's API key may have been revoked — direct them to https://app.steel.dev/settings/api-keys. |
|
||||
| `steel doctor --preflight` reports `api: fail` | Usually a network issue. Ask the user to check their internet connection. Do **not** retry in a tight loop. |
|
||||
| `steel browser start` hangs | Run `steel browser sessions` to see existing sessions. Stop stale ones with `steel browser stop --all` and retry. |
|
||||
|
||||
---
|
||||
|
||||
## What this command is NOT
|
||||
|
||||
- It is **not** a project scaffolder. For templates, the user can run
|
||||
`steel forge` themselves, but don't suggest it unsolicited.
|
||||
- It does **not** create a Steel account for the user. Steel accounts are
|
||||
created through the normal web sign-up flow at https://app.steel.dev. If
|
||||
the user does not have an account, the login page will prompt them to
|
||||
create one — that's fine, the flow is the same.
|
||||
- It does **not** install any MCP server (Steel doesn't ship one). You
|
||||
interact with Steel by calling `steel` subcommands from your shell tool.
|
||||
|
||||
When this guide ends, you are expected to actually run `steel init` (and the
|
||||
subsequent verification commands) in the user's shell. Reading this guide is
|
||||
step zero, not the whole process.
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::path::Path;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::commands::{doctor, login, skills};
|
||||
use crate::config;
|
||||
use crate::config::settings::{OnboardingConfig, read_config_from, write_config_to};
|
||||
use crate::status;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -21,10 +25,11 @@ pub struct Args {
|
||||
|
||||
pub async fn run(args: Args) -> anyhow::Result<()> {
|
||||
status!("Steel CLI setup");
|
||||
if let Ok(from) = std::env::var("STEEL_ONBOARDING_FROM")
|
||||
&& !from.is_empty()
|
||||
if let Some(source) =
|
||||
onboarding_source_from_env(std::env::var("STEEL_ONBOARDING_FROM").ok().as_deref())
|
||||
{
|
||||
status!("Onboarding source: {from}");
|
||||
status!("Onboarding source: {source}");
|
||||
record_onboarding_source(&source);
|
||||
}
|
||||
status!("");
|
||||
|
||||
@@ -91,3 +96,69 @@ async fn install_skills(args: &Args) -> anyhow::Result<()> {
|
||||
fn is_all_selection(selected: &[String]) -> bool {
|
||||
selected.len() == 1 && matches!(selected[0].as_str(), "__all__" | "all")
|
||||
}
|
||||
|
||||
fn onboarding_source_from_env(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|source| !source.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn record_onboarding_source(source: &str) {
|
||||
let config_path = config::config_path_in(&config::config_dir());
|
||||
let _ = persist_onboarding_source(&config_path, source);
|
||||
crate::telemetry::set_onboarding_source(source);
|
||||
}
|
||||
|
||||
fn persist_onboarding_source(config_path: &Path, source: &str) -> anyhow::Result<()> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut cfg = read_config_from(config_path).unwrap_or_default();
|
||||
cfg.onboarding = Some(OnboardingConfig {
|
||||
source: Some(source.to_string()),
|
||||
});
|
||||
write_config_to(config_path, &cfg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn onboarding_source_from_env_trims_and_drops_blank() {
|
||||
assert_eq!(
|
||||
onboarding_source_from_env(Some(" claude-code ")).as_deref(),
|
||||
Some("claude-code")
|
||||
);
|
||||
assert_eq!(onboarding_source_from_env(Some(" ")), None);
|
||||
assert_eq!(onboarding_source_from_env(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_onboarding_source_creates_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("config.json");
|
||||
|
||||
persist_onboarding_source(&path, "cursor").unwrap();
|
||||
|
||||
let cfg = read_config_from(&path).unwrap();
|
||||
assert_eq!(cfg.onboarding_source(), Some("cursor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_onboarding_source_preserves_existing_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("config.json");
|
||||
std::fs::write(&path, r#"{"apiKey":"k","instance":"cloud"}"#).unwrap();
|
||||
|
||||
persist_onboarding_source(&path, "codex").unwrap();
|
||||
|
||||
let cfg = read_config_from(&path).unwrap();
|
||||
assert_eq!(cfg.api_key.as_deref(), Some("k"));
|
||||
assert_eq!(cfg.instance.as_deref(), Some("cloud"));
|
||||
assert_eq!(cfg.onboarding_source(), Some("codex"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Global Flags:
|
||||
|
||||
Getting Started:
|
||||
steel init Log in, verify, and install Steel skills into detected agents
|
||||
--agent Print the agent onboarding guide to stdout and exit
|
||||
--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 <name> Install a Steel Skill through npx skills
|
||||
|
||||
@@ -114,6 +114,15 @@ pub struct Config {
|
||||
pub telemetry: Option<TelemetryConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub computer: Option<ComputerConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub onboarding: Option<OnboardingConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnboardingConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
@@ -159,6 +168,14 @@ impl Config {
|
||||
.and_then(|c| c.default_id.as_deref())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn onboarding_source(&self) -> Option<&str> {
|
||||
self.onboarding
|
||||
.as_ref()
|
||||
.and_then(|o| o.source.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_config_from(path: &Path) -> Result<Config> {
|
||||
@@ -217,6 +234,9 @@ mod tests {
|
||||
disabled: Some(true),
|
||||
}),
|
||||
computer: None,
|
||||
onboarding: Some(OnboardingConfig {
|
||||
source: Some("claude-code".into()),
|
||||
}),
|
||||
};
|
||||
|
||||
write_config_to(&path, &config).unwrap();
|
||||
@@ -227,6 +247,19 @@ mod tests {
|
||||
assert_eq!(loaded.instance.as_deref(), Some("cloud"));
|
||||
assert_eq!(loaded.local_api_url(), Some("http://localhost:4000/v1"));
|
||||
assert!(loaded.telemetry_disabled());
|
||||
assert_eq!(loaded.onboarding_source(), Some("claude-code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_source_ignores_blank_values() {
|
||||
let config = Config {
|
||||
onboarding: Some(OnboardingConfig {
|
||||
source: Some(" ".into()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(config.onboarding_source(), None);
|
||||
assert_eq!(Config::default().onboarding_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+53
-10
@@ -31,6 +31,7 @@ struct GlobalTelemetry {
|
||||
client: Option<Arc<TelemetryClient>>,
|
||||
queue: Vec<QueuedEvent>,
|
||||
flusher: Option<FlusherHandle>,
|
||||
onboarding_source: Option<String>,
|
||||
}
|
||||
|
||||
struct FlusherHandle {
|
||||
@@ -135,9 +136,10 @@ impl TelemetryClient {
|
||||
}
|
||||
|
||||
pub fn init_from_env() {
|
||||
let config = crate::config::settings::read_config().ok();
|
||||
|
||||
#[cfg(test)]
|
||||
let bootstrap = {
|
||||
let config = crate::config::settings::read_config().ok();
|
||||
let override_state = TEST_OVERRIDE
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
@@ -156,20 +158,21 @@ pub fn init_from_env() {
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
let bootstrap = {
|
||||
let config = crate::config::settings::read_config().ok();
|
||||
TelemetryClient::from_parts(
|
||||
&config::config_dir(),
|
||||
&TelemetryEnv::from_env(),
|
||||
config.as_ref(),
|
||||
)
|
||||
};
|
||||
let bootstrap = TelemetryClient::from_parts(
|
||||
&config::config_dir(),
|
||||
&TelemetryEnv::from_env(),
|
||||
config.as_ref(),
|
||||
);
|
||||
|
||||
let previous_flusher = {
|
||||
let mut state = global().lock();
|
||||
state.client = bootstrap
|
||||
.as_ref()
|
||||
.map(|bootstrap| Arc::clone(&bootstrap.client));
|
||||
state.onboarding_source = config
|
||||
.as_ref()
|
||||
.and_then(|config| config.onboarding_source())
|
||||
.map(str::to_string);
|
||||
state.queue.clear();
|
||||
state.flusher.take()
|
||||
};
|
||||
@@ -248,6 +251,22 @@ pub fn track_event(event: &str, mut properties: Map<String, Value>) {
|
||||
track(event, properties);
|
||||
}
|
||||
|
||||
pub fn set_onboarding_source(source: &str) {
|
||||
let source = source.trim();
|
||||
if source.is_empty() {
|
||||
return;
|
||||
}
|
||||
global().lock().onboarding_source = Some(source.to_string());
|
||||
}
|
||||
|
||||
fn apply_onboarding_source(properties: &mut Map<String, Value>, source: Option<&str>) {
|
||||
if let Some(source) = source {
|
||||
properties
|
||||
.entry("onboarding_source")
|
||||
.or_insert_with(|| json!(source));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn flush_best_effort() {
|
||||
let flusher = {
|
||||
let mut state = global().lock();
|
||||
@@ -262,11 +281,12 @@ pub async fn flush_best_effort() {
|
||||
let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, flusher.join).await;
|
||||
}
|
||||
|
||||
fn track(event: &str, properties: Map<String, Value>) {
|
||||
fn track(event: &str, mut properties: Map<String, Value>) {
|
||||
let mut state = global().lock();
|
||||
if state.client.is_none() {
|
||||
return;
|
||||
}
|
||||
apply_onboarding_source(&mut properties, state.onboarding_source.as_deref());
|
||||
state.queue.push(QueuedEvent {
|
||||
event: event.to_string(),
|
||||
properties,
|
||||
@@ -474,6 +494,7 @@ pub fn reset_for_test() {
|
||||
let mut state = global().lock();
|
||||
state.queue.clear();
|
||||
state.client = None;
|
||||
state.onboarding_source = None;
|
||||
state.flusher.take()
|
||||
};
|
||||
|
||||
@@ -504,6 +525,28 @@ pub fn set_test_override(config_dir: &Path, host: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn onboarding_source_is_attached_when_known() {
|
||||
let mut properties = Map::new();
|
||||
apply_onboarding_source(&mut properties, Some("claude-code"));
|
||||
assert_eq!(properties["onboarding_source"], json!("claude-code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_source_does_not_override_explicit_value() {
|
||||
let mut properties = Map::new();
|
||||
properties.insert("onboarding_source".into(), json!("cursor"));
|
||||
apply_onboarding_source(&mut properties, Some("claude-code"));
|
||||
assert_eq!(properties["onboarding_source"], json!("cursor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_source_is_omitted_when_unknown() {
|
||||
let mut properties = Map::new();
|
||||
apply_onboarding_source(&mut properties, None);
|
||||
assert!(!properties.contains_key("onboarding_source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_enabled_by_default() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user