From c35bb838abc168bb43d84e4ab1bde6231d83b53a Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 10 Sep 2026 21:15:17 +0900 Subject: [PATCH] fix(init): clean up agent onboarding leftovers and persist onboarding source --- install.sh | 9 +- src/commands/init/init_agent_guide.md | 149 -------------------------- src/commands/init/mod.rs | 77 ++++++++++++- src/commands/mod.rs | 2 +- src/config/settings.rs | 33 ++++++ src/telemetry.rs | 63 +++++++++-- 6 files changed, 166 insertions(+), 167 deletions(-) delete mode 100644 src/commands/init/init_agent_guide.md diff --git a/install.sh b/install.sh index 605339e..bdf33f3 100755 --- a/install.sh +++ b/install.sh @@ -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 Tag the originating coding agent (claude-code, cursor, opencode, codex, ...). Informational only; used for onboarding telemetry. Supports `--from=` 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 diff --git a/src/commands/init/init_agent_guide.md b/src/commands/init/init_agent_guide.md deleted file mode 100644 index a61633f..0000000 --- a/src/commands/init/init_agent_guide.md +++ /dev/null @@ -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 ` | -| Take a screenshot | `steel screenshot ` | -| Save a page as PDF | `steel pdf ` | -| Start an interactive browser | `steel browser start --session ` | -| Navigate / click / fill in a session | `steel browser navigate / click / fill` | -| See all sessions | `steel browser sessions` | -| End a session | `steel browser stop --session ` | -| 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. diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 285cdb0..455f3e6 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -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 { + 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")); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 16d592f..f0dfcc0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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 Install a Steel Skill through npx skills diff --git a/src/config/settings.rs b/src/config/settings.rs index 3e54e31..5656879 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -114,6 +114,15 @@ pub struct Config { pub telemetry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub computer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub onboarding: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, } #[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 { @@ -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] diff --git a/src/telemetry.rs b/src/telemetry.rs index 3b47526..154a913 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -31,6 +31,7 @@ struct GlobalTelemetry { client: Option>, queue: Vec, flusher: Option, + onboarding_source: Option, } 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) { 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, 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) { +fn track(event: &str, mut properties: Map) { 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();