fix(init): clean up agent onboarding leftovers and persist onboarding source (#102)

This commit is contained in:
junhsss
2026-09-10 21:17:52 +09:00
committed by GitHub
parent b8e53881ca
commit e192a0cc49
6 changed files with 166 additions and 167 deletions
+5 -4
View File
@@ -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
-149
View File
@@ -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.
+74 -3
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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();