From 5d2a9d41c6255a988760ec5387e9709b29171f88 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 18:16:29 -0500 Subject: [PATCH 01/32] feat: setup installs ElevenLabs in your coding agents, and onboard can offer it (1.11.0) `elevenlabs setup --yes` installs the ElevenLabs plugin (skills and the hosted MCP server) into Claude Code and Codex through their own plugin commands, prints the marketplace link for Cursor, and with none of them copies the skills into ~/.agents/skills. Without --yes it only says what it would do. Onboarding asks the approval page whether to offer it (setup_offer=1). When the callback says yes, the reply that ended the flow after a test request that worked, or for a voice agent project, asks instead whether to set ElevenLabs up in the agents found, and `onboard setup` does it. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 24 + cli/elevenlabs/workflow/loopback.rs | 61 +- cli/elevenlabs/workflow/mod.rs | 2 + cli/elevenlabs/workflow/onboard/auth.rs | 9 +- cli/elevenlabs/workflow/onboard/check.rs | 6 +- cli/elevenlabs/workflow/onboard/connect.rs | 39 +- cli/elevenlabs/workflow/onboard/mod.rs | 72 +- cli/elevenlabs/workflow/onboard/reply.rs | 21 +- cli/elevenlabs/workflow/onboard/session.rs | 37 +- .../workflow/onboard/test_request.rs | 5 +- cli/elevenlabs/workflow/onboard/tests.rs | 45 + cli/elevenlabs/workflow/setup.rs | 1041 +++++++++++++++++ tests/onboard_test.rs | 424 +++++++ 15 files changed, 1740 insertions(+), 50 deletions(-) create mode 100644 cli/elevenlabs/workflow/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 502144e5..2979765a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,7 +528,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "elevenlabs-cli" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 6f5ddc19..3a1c5252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "elevenlabs-cli" -version = "1.10.0" +version = "1.11.0" edition = "2021" description = "CLI for the ElevenLabs API" license = "MIT" diff --git a/README.md b/README.md index 2e5ec098..c959870d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ The CLI does two things: - [Agents as Code](#agents-as-code) - [Data residency](#data-residency) - [UI components](#ui-components) +- [Set up your coding agents](#set-up-your-coding-agents) - [Usage](#usage) - [Documentation](#documentation) - [Advanced](#advanced) @@ -443,6 +444,24 @@ elevenlabs components add # all components elevenlabs components add conversation-bar ``` +## Set up your coding agents + +`elevenlabs setup` installs the [ElevenLabs plugin](https://github.com/elevenlabs/plugin) (the ElevenLabs skills and the hosted MCP server) into the coding agents it finds, through each agent's own plugin command: + +```bash +elevenlabs setup # say what it would do, and change nothing +elevenlabs setup --yes # do it +``` + +- **Claude Code** (the program in `CLAUDE_CODE_EXECPATH` when run inside it, else `claude` on `PATH`): `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user`, then `claude plugin update elevenlabs@elevenlabs`. +- **Codex** (`codex` on `PATH`): `codex plugin marketplace add elevenlabs/plugin`, then `codex plugin add elevenlabs@elevenlabs`. +- **Cursor** (a `~/.cursor` folder, or run inside Cursor) has no install command: `setup` prints the link to [its marketplace page](https://cursor.com/marketplace/elevenlabs), where you select Install. +- **None of these**: `setup` copies the [ElevenLabs skills](https://github.com/elevenlabs/skills) into `~/.agents/skills`, one folder per skill. A folder there that `setup` didn't write is left as it is, and named in the output. + +Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. + +When onboarding through a coding agent (`npx -y @elevenlabs/cli@latest onboard init`), the flow can end by offering to do this for you; saying yes runs `onboard setup`, which does what `setup --yes` does. + ## Usage Every API resource appears as a subcommand (e.g. `elevenlabs `). Run `elevenlabs --help` to see available methods. @@ -563,6 +582,11 @@ approval link, where `` is a fixed word such as `timed-out`, SHA-256 hash of that sign-in's `state`, so it can be matched to its approval page; the state itself is never sent. +When you accept the offer to set up your coding agents at the end of +onboarding, `onboard setup` makes one account request (`GET /v1/user`, tagged +`cmd/onboard.setup`) with the project's key, so we can count how many people +accept it; with telemetry off it makes none. + To turn it off, pass `--telemetry off` to any command, or set `ELEVENLABS_TELEMETRY=off` (or `DO_NOT_TRACK=1`) to turn it off everywhere. `--telemetry on` turns it back on for one command. Turning it off does not diff --git a/cli/elevenlabs/workflow/loopback.rs b/cli/elevenlabs/workflow/loopback.rs index 07a73315..e53ed5a1 100644 --- a/cli/elevenlabs/workflow/loopback.rs +++ b/cli/elevenlabs/workflow/loopback.rs @@ -66,8 +66,9 @@ pub(super) struct Login<'a> { } /// Sign in through the browser and store the token where `auth login` does. -/// Returns the access token, which works even when the store refused it. -pub(super) async fn sign_in(login: Login<'_>) -> Result { +/// Returns the access token, which works even when the store refused it, and +/// whether the callback asked the CLI to offer `setup` (`setup_offer=1`). +pub(super) async fn sign_in(login: Login<'_>) -> Result<(String, bool), CliError> { let verifier = random_token(); let state = random_token(); let (listeners, port) = bind(login.redirect_ports).await?; @@ -78,7 +79,7 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result { if !login.no_browser { let _ = webbrowser::open(&url); } - let (code, mut browser) = + let (code, setup_offer, mut browser) = wait_for_callback(listeners, &state, login.error_url, login.timeout).await?; // The browser waits on the result page until the code is exchanged. let token = exchange( @@ -102,7 +103,7 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result { eprintln!("Signed in, but the sign-in couldn't be saved for later commands: {e}"); } let _ = redirect(&mut browser, login.success_url).await; - Ok(token.access_token) + Ok((token.access_token, setup_offer)) } Err(failure) => { // Its own OAuth code, so the analytics can tell these apart. @@ -177,7 +178,8 @@ pub(super) enum Callback { Other, /// A callback this sign-in can't use: another sign-in's `state`, or none. NotOurs, - Code(String), + /// The code, and whether the page asked the CLI to offer `setup`. + Code { code: String, setup_offer: bool }, /// The page's error, e.g. `access_denied` when the developer clicked Deny. Error { error: String, @@ -192,6 +194,7 @@ pub(super) fn classify(target: &str, state: &str) -> Callback { return Callback::Other; } let (mut code, mut got_state, mut error, mut description) = (None, None, None, None); + let mut setup_offer = false; for (key, value) in form_urlencoded::parse(query.as_bytes()) { let value = Some(value.into_owned()); match key.as_ref() { @@ -199,13 +202,14 @@ pub(super) fn classify(target: &str, state: &str) -> Callback { "state" => got_state = value, "error" => error = value, "error_description" => description = value, + "setup_offer" => setup_offer = value.as_deref() == Some("1"), _ => {} } } match (got_state, code, error) { (Some(got), _, _) if got != state => Callback::NotOurs, (Some(_), _, Some(error)) => Callback::Error { error, description }, - (Some(_), Some(code), None) => Callback::Code(code), + (Some(_), Some(code), None) => Callback::Code { code, setup_offer }, // Without a `state`, nothing from ElevenLabs: the page's Deny and the // server's error redirects always carry it, so any page could have // sent this, to end the sign-in or to put words in the agent's reply. @@ -213,14 +217,15 @@ pub(super) fn classify(target: &str, state: &str) -> Callback { } } -/// Wait for this sign-in's callback. A code comes back with the browser's -/// connection still open, so the browser is answered once the login is done. +/// Wait for this sign-in's callback. A code comes back, with whether the page +/// asked for the `setup` offer, and with the browser's connection still open, +/// so the browser is answered once the login is done. pub(super) async fn wait_for_callback( listeners: Vec, state: &str, error_url: &str, timeout: Duration, -) -> Result<(String, TcpStream), CliError> { +) -> Result<(String, bool, TcpStream), CliError> { let (tx, mut rx) = mpsc::channel::<(String, TcpStream)>(16); // Dropped on return, which stops every listener. let mut accepting = JoinSet::new(); @@ -260,7 +265,7 @@ pub(super) async fn wait_for_callback( let page = error_page(error_url, "invalid_request", Some(NOT_THIS_SIGN_IN)); let _ = redirect(&mut socket, &page).await; } - Callback::Code(code) => return Ok((code, socket)), + Callback::Code { code, setup_offer } => return Ok((code, setup_offer, socket)), Callback::Error { error, description } => { let page = error_page(error_url, &error, description.as_deref()); let _ = redirect(&mut socket, &page).await; @@ -547,7 +552,10 @@ mod tests { let ours = "s1"; assert_eq!( classify("/callback?code=c&state=s1", ours), - Callback::Code("c".into()) + Callback::Code { + code: "c".into(), + setup_offer: false + } ); assert_eq!( classify( @@ -575,6 +583,35 @@ mod tests { } } + #[test] + fn a_callback_asks_for_the_setup_offer_only_with_setup_offer_1() { + let code = |setup_offer| Callback::Code { + code: "c".into(), + setup_offer, + }; + assert_eq!( + classify("/callback?code=c&state=s1&setup_offer=1", "s1"), + code(true) + ); + assert_eq!( + classify("/callback?setup_offer=1&code=c&state=s1", "s1"), + code(true) + ); + for without in [ + "/callback?code=c&state=s1", + "/callback?code=c&state=s1&setup_offer=0", + "/callback?code=c&state=s1&setup_offer=true", + "/callback?code=c&state=s1&setup_offer=", + ] { + assert_eq!(classify(without, "s1"), code(false), "{without}"); + } + // It changes nothing about whose callback it is. + assert_eq!( + classify("/callback?code=c&state=s0&setup_offer=1", "s1"), + Callback::NotOurs + ); + } + #[tokio::test(flavor = "multi_thread")] async fn the_listener_takes_both_loopback_addresses_or_moves_on() { if no_ipv6() { @@ -633,7 +670,7 @@ mod tests { let this_tab = request("::1", port, "/callback?code=new&state=current"); (favicon, old_tab, this_tab) }); - let (code, mut socket) = + let (code, _, mut socket) = wait_for_callback(listeners, "current", ERROR_PAGE, Duration::from_secs(3)) .await .unwrap(); diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 6734ba63..1ae90071 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -29,6 +29,7 @@ mod project; mod residency; mod say; mod settings; +mod setup; mod skills; mod telemetry; mod templates; @@ -57,6 +58,7 @@ pub fn register(app: CliApp) -> CliApp { let app = feedback::register(app); let app = say::register(app); let app = flows_wait::register(app); + let app = setup::register(app); // Shadows the framework's built-in; see that module's docs. let app = skills::register(app); components::register(app) diff --git a/cli/elevenlabs/workflow/onboard/auth.rs b/cli/elevenlabs/workflow/onboard/auth.rs index f3932e46..18faa16a 100644 --- a/cli/elevenlabs/workflow/onboard/auth.rs +++ b/cli/elevenlabs/workflow/onboard/auth.rs @@ -89,13 +89,13 @@ fn parse_sign_in(main_rs: &str) -> Result { /// Sign in through the browser, asking the page for a key. `previous` is how /// the last sign-in for this project ended, for the page to record. Returns -/// the access token. +/// the access token, and whether the page asked for the `setup` offer. pub(super) fn sign_in( ctx: &AppContext, authorize_url: &str, no_browser: bool, previous: Option<&LastSignIn>, -) -> Result { +) -> Result<(String, bool), CliError> { let config = sign_in_config()?; // A local --base-url also moves the token endpoint, for testing against a local backend. let oauth_base = ctx @@ -104,11 +104,14 @@ pub(super) fn sign_in( .unwrap_or_else(|| config.token_url.trim_end_matches("/v1/oauth/token").into()); let token_url = format!("{oauth_base}/v1/oauth/token"); let on = telemetry::enabled(); - let params = authorize_params( + let mut params = authorize_params( on.then(|| (platform::detect(), platform::install_source())), on.then(platform::agent).flatten(), previous.filter(|_| on), ); + // Whatever telemetry says: it tells the page this CLI can offer `setup` + // at the end of the flow, and the page answers on the callback. + params.push(("setup_offer", "1".into())); let params: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); block_on(loopback::sign_in(loopback::Login { cli_name: CLI_NAME, diff --git a/cli/elevenlabs/workflow/onboard/check.rs b/cli/elevenlabs/workflow/onboard/check.rs index 5664d22b..0fce522b 100644 --- a/cli/elevenlabs/workflow/onboard/check.rs +++ b/cli/elevenlabs/workflow/onboard/check.rs @@ -8,6 +8,7 @@ use serde_json::Value; use super::super::say::run_async as block_on; use super::super::settings; +use super::super::setup::SKILLS_ARCHIVE; use super::auth::{api_base, check_key, sign_in_config, KeyCheck}; use super::connect; use super::env_file::{self, API_KEY_ENV}; @@ -77,7 +78,7 @@ pub(super) fn verify(ctx: &AppContext, project: &Project, answered: bool) -> Res load_key(project, &ending, &name, say) } (true, _, None) => match own_agent(ctx, project, &key) { - Some(agent) => agent_ready(&ending, file, &agent, reads_key)?, + Some(agent) => super::or_offer_setup(project, agent_ready(&ending, file, &agent, reads_key)?), None => offer_test(project, &ending, file), }, (false, false, _) => ask_what_to_build(project, &ending, file, "Your key works."), @@ -245,9 +246,6 @@ pub(super) fn app_origin_for(residency: &str, authorize_url: &str) -> Result, - on_signed_in: impl FnOnce(), + on_signed_in: impl FnOnce(bool), ) -> Result { let client = ctx.http_config().build_client()?; - let token = match auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser, previous) { - Ok(token) => token, - Err(error) => { - // The page may have copied a key before the callback failed; nothing - // can vouch for it now, so it is cleared, not stored. - Clipboard::open().discard_key(); - return Ok(failed( - project, - &error.to_string(), - matches.get_flag("replace"), - )); - } - }; - on_signed_in(); + let (token, setup_offer) = + match auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser, previous) { + Ok(signed_in) => signed_in, + Err(error) => { + // The page may have copied a key before the callback failed; nothing + // can vouch for it now, so it is cleared, not stored. + Clipboard::open().discard_key(); + return Ok(failed( + project, + &error.to_string(), + matches.get_flag("replace"), + )); + } + }; + on_signed_in(setup_offer); let mut clipboard = Clipboard::open(); let clip = clipboard.read_handoff(); let copied = match &clip { diff --git a/cli/elevenlabs/workflow/onboard/mod.rs b/cli/elevenlabs/workflow/onboard/mod.rs index 32b91978..8c26ff5b 100644 --- a/cli/elevenlabs/workflow/onboard/mod.rs +++ b/cli/elevenlabs/workflow/onboard/mod.rs @@ -6,6 +6,8 @@ //! - `connect`: sign in in the browser and store an API key for the project //! - `check`: confirm the key and the integration (`status` is an alias) //! - `test`: a short text-to-speech request, only when the developer says yes +//! - `setup`: install ElevenLabs in the developer's coding agents, when the +//! flow offered it and they said yes mod auth; mod browser; @@ -44,6 +46,7 @@ fn dispatch(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError "init" => "onboard.init", "connect" => "onboard.connect", "check" => "onboard.check", + "setup" => "onboard.setup", _ => "onboard.test", }, sign_in_tag(last.as_ref()), @@ -52,6 +55,7 @@ fn dispatch(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError "init" => Some(init::handle(sub, ctx)?.to_value()), "connect" => connect::handle(sub, ctx)?, "check" => Some(check::handle(sub, ctx)?.to_value()), + "setup" => Some(setup(sub, ctx)?.to_value()), _ => Some(test_request::handle(sub, ctx)?.to_value()), }; match reply { @@ -60,6 +64,64 @@ fn dispatch(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError } } +/// `onboard setup`: what `setup --yes` does, as a `done` reply. With +/// telemetry on it first makes the account request `check` makes, so the +/// offers accepted can be counted; its answer changes nothing. +fn setup(matches: &clap::ArgMatches, ctx: &AppContext) -> Result { + let started = std::time::Instant::now(); + let project = project::Project::open(matches)?; + if super::telemetry::enabled() { + if let Some(key) = project.key() { + let _ = auth::check_key(ctx, &key.value); + } + } + let home = super::setup::home() + .ok_or_else(|| CliError::Other(anyhow::anyhow!("no home folder to set the agents up in")))?; + // The request's time comes out of the whole run's, so the reply still comes in time. + let limits = super::setup::Limits { + total: super::setup::LIMITS.total.saturating_sub(started.elapsed()), + ..super::setup::LIMITS + }; + let outcomes = super::setup::run(&super::setup::found_here(&home), &home, limits); + let mut say: Vec = Vec::new(); + for outcome in &outcomes { + let mut line = format!("{}.", super::setup::result_line(outcome)); + if let Some(finish) = super::setup::finish(outcome) { + line.push_str(&format!(" To finish, {finish}.")); + } + say.push(line); + } + if outcomes.iter().any(|o| !o.ok) { + say.push(format!( + "To try again later, run `{}`.", + super::setup::later_command() + )); + } + Ok(reply::Reply::new(reply::Next::Done, say.join(" ")).detail("setup", &outcomes)) +} + +/// `done`, the end of the flow, or, when this project's sign-in asked for it +/// and a coding agent can take the plugin (or none of the three is there and +/// the skills can be copied), the question that offers `setup`. Made once. +fn or_offer_setup(project: &project::Project, done: reply::Reply) -> reply::Reply { + let Ok(session) = session::Session::for_project(&project.dir) else { + return done; + }; + if !session.setup_offer_due() { + return done; + } + let Some(home) = super::setup::home() else { + return done; + }; + match super::setup::question(&super::setup::found_here(&home)) { + Some(question) => { + session.setup_offer_made(); + done.offer_setup(question, &project.next("setup")) + } + None => done, + } +} + /// `signin/` with telemetry on, so a request after a failed sign-in /// says how it ended, even when the developer then adds a key by hand. fn sign_in_tag(sign_in: Option<&session::LastSignIn>) -> Option { @@ -144,6 +206,14 @@ fn onboard_command() -> clap::Command { .arg(env_file_arg()) .arg(no_browser_arg()), ) + .subcommand( + clap::Command::new("setup") + // Run only when the flow offers it and the developer says yes. + .hide(true) + .about("Install ElevenLabs in the coding agents on this machine, as `elevenlabs setup --yes` does") + .arg(env_file_arg()) + .arg(no_browser_arg()), + ) } /// The environment as the shell gave it, from before the runtime loads a @@ -151,7 +221,7 @@ fn onboard_command() -> clap::Command { static SHELL_ENV: OnceLock> = OnceLock::new(); /// The shell's own environment, once `register` has run (always, but in unit tests). -fn shell_env() -> Option<&'static HashMap> { +pub(super) fn shell_env() -> Option<&'static HashMap> { SHELL_ENV.get() } diff --git a/cli/elevenlabs/workflow/onboard/reply.rs b/cli/elevenlabs/workflow/onboard/reply.rs index 8b69954e..0c7795a7 100644 --- a/cli/elevenlabs/workflow/onboard/reply.rs +++ b/cli/elevenlabs/workflow/onboard/reply.rs @@ -18,6 +18,7 @@ pub(super) enum Next { WriteCode, LoadKey, OfferTest, + OfferSetup, Done, } @@ -68,6 +69,11 @@ impl Next { there: until they answer, don't run the app or its start script, or make speech to try \ it. Run `run` only if they say yes." } + Next::OfferSetup => { + "Tell the user `say`, then anything they still have to do themselves, like installing the \ + packages you added, in one or two concrete steps. Then end your reply with the question in \ + `details.question`, as it is, and stop there. Run `run` only if they say yes." + } Next::Done => { "Tell the user `say`, then anything they still have to do themselves, like installing the \ packages you added, in one or two concrete steps. There's nothing left to run." @@ -126,6 +132,14 @@ impl Reply { self } + /// This `done` reply, ending instead on the question that offers to set + /// ElevenLabs up in the developer's coding agents, which `run` does. + pub fn offer_setup(mut self, question: String, run: &str) -> Self { + self.next = Next::OfferSetup; + self.instruction = Next::OfferSetup.instruction(); + self.run(run).detail("question", question) + } + pub fn with_rules(mut self) -> Self { self.rules = RULES; self @@ -140,12 +154,7 @@ impl Reply { /// started this process (it sets `npm_command=exec`), so the agent keeps /// running the same, latest CLI; otherwise the installed `elevenlabs`. pub(super) fn command(args: &str) -> String { - let cli = if std::env::var("npm_command").is_ok_and(|c| c == "exec") { - "npx -y @elevenlabs/cli@latest" - } else { - "elevenlabs" - }; - format!("{cli} onboard {args}") + format!("{} onboard {args}", super::super::setup::cli_prefix()) } /// Print a reply (or one a background sign-in saved). Always JSON, whatever diff --git a/cli/elevenlabs/workflow/onboard/session.rs b/cli/elevenlabs/workflow/onboard/session.rs index 63cc4d5a..e4a44060 100644 --- a/cli/elevenlabs/workflow/onboard/session.rs +++ b/cli/elevenlabs/workflow/onboard/session.rs @@ -46,6 +46,17 @@ pub(super) const LAUNCH_FLAG: &str = "launch-worker"; const REPORT_FOR: Duration = Duration::from_secs(24 * 60 * 60); /// Where [`Session::start`] keeps how the sign-in before the new one ended. const PREVIOUS: &str = "previous.json"; +/// Left by a sign-in whose approval page asked the CLI to offer `setup` at +/// the end of the flow; removed when the offer is made. +const SETUP_OFFER: &str = "setup-offer"; +/// What a new sign-in clears of the last one's. +const STALE: &[&str] = &[ + "result.json", + "worker.log", + "worker.pid", + "signed-in", + SETUP_OFFER, +]; /// Every label a sign-in's outcome can have (see [`outcome_label`]): fixed /// words, so nothing the developer typed or the machine holds is reported. @@ -197,9 +208,7 @@ impl Session { restarted = true; } self.keep_last_sign_in(restarted); - for stale in ["result.json", "worker.log", "worker.pid", "signed-in"] { - let _ = std::fs::remove_file(self.path(stale)); - } + self.clear_stale(); std::fs::write(self.path("request"), request).map_err(io(&self.path("request")))?; self.stop_others(); keep_stdio_from_children(); @@ -300,6 +309,28 @@ impl Session { let _ = std::fs::write(self.path("signed-in"), ""); } + /// The approval page asked for the `setup` offer at the end of the flow. + pub fn offer_setup(&self) { + let _ = std::fs::write(self.path(SETUP_OFFER), ""); + } + + /// The `setup` offer is still to be made for this sign-in. + pub fn setup_offer_due(&self) -> bool { + self.path(SETUP_OFFER).is_file() + } + + /// The offer was made: once per sign-in. + pub fn setup_offer_made(&self) { + let _ = std::fs::remove_file(self.path(SETUP_OFFER)); + } + + /// Clear the last sign-in's files, for a new one. + pub(super) fn clear_stale(&self) { + for stale in STALE { + let _ = std::fs::remove_file(self.path(stale)); + } + } + /// The worker's last act: its reply, for whoever is waiting. pub fn finish(&self, _lock: &WorkerLock, reply: &Value) -> Result<(), CliError> { env_file::write(&self.path("result.json"), &reply.to_string()) diff --git a/cli/elevenlabs/workflow/onboard/test_request.rs b/cli/elevenlabs/workflow/onboard/test_request.rs index 709a8252..27f5a40f 100644 --- a/cli/elevenlabs/workflow/onboard/test_request.rs +++ b/cli/elevenlabs/workflow/onboard/test_request.rs @@ -66,10 +66,11 @@ pub(super) fn handle(matches: &clap::ArgMatches, ctx: &AppContext) -> Result &'static str { + match self { + Agent::ClaudeCode(_) => "Claude Code", + Agent::Codex(_) => "Codex", + Agent::Cursor => "Cursor", + } + } + + /// The commands that install the plugin, in order, each with whether its + /// result decides the agent's. Refreshing the catalog fails harmlessly + /// before the first install, so it never does. + fn steps(&self) -> &'static [(&'static [&'static str], bool)] { + match self { + Agent::ClaudeCode(_) => &[ + (&["plugin", "marketplace", "update", "elevenlabs"], false), + ( + &[ + "plugin", + "install", + "elevenlabs", + "--marketplace", + "elevenlabs/plugin", + "--scope", + "user", + ], + true, + ), + (&["plugin", "update", "elevenlabs@elevenlabs"], true), + ], + Agent::Codex(_) => &[ + (&["plugin", "marketplace", "add", "elevenlabs/plugin"], true), + (&["plugin", "marketplace", "upgrade", "elevenlabs"], false), + (&["plugin", "add", "elevenlabs@elevenlabs"], true), + ], + Agent::Cursor => &[], + } + } +} + +/// How one agent, or the skills copy, came out. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(super) struct Outcome { + pub agent: &'static str, + pub ok: bool, + pub message: String, +} + +/// How long one agent command, and the whole run, may take. The whole run +/// stays under the two minutes coding agents give a shell command, so the +/// agent that ran `onboard setup` always gets its reply. +#[derive(Debug, Clone, Copy)] +pub(super) struct Limits { + pub command: Duration, + pub total: Duration, +} + +pub(super) const LIMITS: Limits = Limits { + command: Duration::from_secs(30), + total: Duration::from_secs(100), +}; + +/// The agents on this machine. `env` reads a variable, `on_path` finds a +/// program on PATH, `home` is the home folder. +pub(super) fn found( + env: &dyn Fn(&str) -> Option, + on_path: &dyn Fn(&str) -> Option, + home: &Path, +) -> Vec { + let set = |name: &str| env(name).is_some_and(|v| !v.is_empty()); + let mut agents = Vec::new(); + // Inside Claude Code, including the desktop app, which may not put `claude` on PATH. + let claude = env("CLAUDE_CODE_EXECPATH") + .map(PathBuf::from) + .filter(|p| p.is_file()) + .or_else(|| on_path("claude")); + if let Some(claude) = claude { + agents.push(Agent::ClaudeCode(claude)); + } + if let Some(codex) = on_path("codex") { + agents.push(Agent::Codex(codex)); + } + if home.join(".cursor").is_dir() || set("CURSOR_AGENT") || set("CURSOR_TRACE_ID") { + agents.push(Agent::Cursor); + } + agents +} + +/// [`found`] for this machine, from the shell's environment. +pub(super) fn found_here(home: &Path) -> Vec { + let path = shell_var("PATH").map(OsString::from); + found( + &shell_var, + &|name| on_path(name, path.as_deref(), cfg!(windows)), + home, + ) +} + +/// The home folder: `USERPROFILE` on Windows, `HOME` elsewhere. +pub(super) fn home_for(windows: bool, env: &dyn Fn(&str) -> Option) -> Option { + env(if windows { "USERPROFILE" } else { "HOME" }) + .filter(|h| !h.is_empty()) + .map(PathBuf::from) +} + +pub(super) fn home() -> Option { + home_for(cfg!(windows), &shell_var) +} + +/// A variable as the shell set it, not as a project's `.env` (which the +/// runtime loads) may have changed it: a project must never choose the +/// program this starts. +fn shell_var(name: &str) -> Option { + match super::onboard::shell_env() { + Some(env) => env + .get(OsStr::new(name)) + .and_then(|v| v.to_str()) + .map(str::to_string), + None => std::env::var(name).ok(), + } +} + +/// The first `name` on `path` that is a program (on Windows, with one of +/// the extensions npm and installers use). +pub(super) fn on_path(name: &str, path: Option<&OsStr>, windows: bool) -> Option { + let names: Vec = if windows { + [".exe", ".cmd", ".bat"] + .iter() + .map(|ext| format!("{name}{ext}")) + .collect() + } else { + vec![name.to_string()] + }; + std::env::split_paths(path?) + .flat_map(|dir| names.iter().map(move |n| dir.join(n)).collect::>()) + .find(|p| if windows { p.is_file() } else { is_program(p) }) +} + +#[cfg(unix)] +fn is_program(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_program(path: &Path) -> bool { + path.is_file() +} + +/// The program and arguments that run `program args`. Never through a shell +/// on Unix; on Windows through `cmd /C`, since npm installs agents as `.cmd` +/// shims that can't be started by name. +pub(super) fn argv(program: &Path, args: &[&str], windows: bool) -> (OsString, Vec) { + let args = args.iter().map(OsString::from); + if windows { + let mut all = vec![OsString::from("/C"), program.as_os_str().to_owned()]; + all.extend(args); + (OsString::from("cmd"), all) + } else { + (program.as_os_str().to_owned(), args.collect()) + } +} + +/// Run `program args` with stdin closed, stopping it after `limit`. `Err` is +/// why it failed: its last line of output, or that it ran out of time. +pub(super) fn run_program(program: &Path, args: &[&str], limit: Duration) -> Result<(), String> { + let (exe, argv) = argv(program, args, cfg!(windows)); + let (out, err) = match (tempfile::tempfile(), tempfile::tempfile()) { + (Ok(out), Ok(err)) => (out, err), + (Err(e), _) | (_, Err(e)) => return Err(format!("couldn't start it: {e}")), + }; + let mut command = Command::new(exe); + command + .args(argv) + .stdin(Stdio::null()) + .stdout(out.try_clone().map_err(|e| e.to_string())?) + .stderr(err.try_clone().map_err(|e| e.to_string())?); + if let Some(env) = super::onboard::shell_env() { + command.env_clear().envs(env); + } + let mut child = command + .spawn() + .map_err(|e| format!("couldn't start it: {e}"))?; + let deadline = Instant::now() + limit; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "ran out of time after {} and was stopped", + seconds(limit) + )); + } + } + }; + if status.success() { + return Ok(()); + } + let code = status + .code() + .map_or("a signal".to_string(), |c| c.to_string()); + let line = last_line(err).or_else(|| last_line(out)); + Err(match line { + Some(line) => format!("exited with {code}: {line}"), + None => format!("exited with {code}"), + }) +} + +fn seconds(d: Duration) -> String { + if d >= Duration::from_secs(1) { + format!("{} seconds", d.as_secs()) + } else { + format!("{} ms", d.as_millis()) + } +} + +/// The last line with text in a program's captured output. +fn last_line(mut file: std::fs::File) -> Option { + use std::io::{Read, Seek}; + file.seek(std::io::SeekFrom::Start(0)).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + String::from_utf8_lossy(&bytes) + .lines() + .map(str::trim) + .rfind(|l| !l.is_empty()) + .map(str::to_string) +} + +/// Install the plugin into each agent with `run`, which gets the program, +/// its arguments and how long it may take. An agent reached after the whole +/// run's time is up counts as failed. +pub(super) fn install( + agents: &[Agent], + limits: Limits, + run: &dyn Fn(&Path, &[&str], Duration) -> Result<(), String>, +) -> Vec { + let started = Instant::now(); + agents + .iter() + .map(|agent| { + let program = match agent { + Agent::ClaudeCode(p) | Agent::Codex(p) => p, + Agent::Cursor => { + return Outcome { + agent: agent.name(), + ok: true, + message: format!("open {CURSOR_PAGE} and select Install"), + } + } + }; + let failed = |message: String| Outcome { + agent: agent.name(), + ok: false, + message, + }; + for (args, decides) in agent.steps() { + let left = limits.total.saturating_sub(started.elapsed()); + if left.is_zero() { + return failed(format!( + "ran out of time: setup stops after {}", + seconds(limits.total) + )); + } + let result = run(program, args, limits.command.min(left)); + if let (Err(why), true) = (result, decides) { + let name = program + .file_stem() + .unwrap_or(program.as_os_str()) + .to_string_lossy(); + return failed(format!("`{name} {}` {why}", args.join(" "))); + } + } + Outcome { + agent: agent.name(), + ok: true, + message: "installed the ElevenLabs plugin (skills and the hosted MCP server)" + .into(), + } + }) + .collect() +} + +/// Everything `setup --yes` does: install into the agents found, or, with +/// none, copy the skills. +pub(super) fn run(agents: &[Agent], home: &Path, limits: Limits) -> Vec { + if agents.is_empty() { + return vec![skills(home)]; + } + install(agents, limits, &run_program) +} + +/// What `setup` does for each agent, before it does it. +fn plan(agents: &[Agent], home: &Path) -> Vec { + if agents.is_empty() { + return vec![format!( + "No Claude Code, Codex or Cursor found: copy the ElevenLabs skills into {}", + skills_root(home).display() + )]; + } + agents + .iter() + .map(|agent| match agent { + Agent::ClaudeCode(p) | Agent::Codex(p) => format!( + "{} ({}): install the ElevenLabs plugin (skills and the hosted MCP server)", + agent.name(), + p.display() + ), + Agent::Cursor => format!("Cursor: open {CURSOR_PAGE} and select Install"), + }) + .collect() +} + +/// One line per outcome. +pub(super) fn result_line(outcome: &Outcome) -> String { + if outcome.ok { + format!("{}: {}", outcome.agent, outcome.message) + } else { + format!("{}: failed: {}", outcome.agent, outcome.message) + } +} + +/// How to finish for an agent the plugin went into: its MCP server signs in +/// in the agent itself. +pub(super) fn finish(outcome: &Outcome) -> Option { + let how = match outcome.agent { + "Claude Code" => "run /mcp and sign in to the ElevenLabs MCP server", + "Codex" => "run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", + _ => return None, + }; + outcome + .ok + .then(|| format!("restart {}, then {how}", outcome.agent)) +} + +/// The installed CLI, or npx when npx started this process (it sets +/// `npm_command=exec`), so the next command runs the same, latest CLI. +pub(super) fn cli_prefix() -> &'static str { + cli_prefix_for(std::env::var("npm_command").ok().as_deref()) +} + +pub(super) fn cli_prefix_for(npm_command: Option<&str>) -> &'static str { + if npm_command == Some("exec") { + "npx -y @elevenlabs/cli@latest" + } else { + "elevenlabs" + } +} + +/// The command to run `setup` again by hand. +pub(super) fn later_command() -> String { + format!("{} setup --yes", cli_prefix()) +} + +/// The question that offers `setup` at the end of onboarding, or `None` +/// when only Cursor was found: it has no install command to run. +pub(super) fn question(agents: &[Agent]) -> Option { + let claude = agents.iter().any(|a| matches!(a, Agent::ClaudeCode(_))); + let codex = agents.iter().any(|a| matches!(a, Agent::Codex(_))); + let names = match (claude, codex) { + (true, true) => "Claude Code and Codex", + (true, false) => "Claude Code", + (false, true) => "Codex", + (false, false) if agents.is_empty() => { + return Some("Want me to install the ElevenLabs skills for your coding agents?".into()) + } + (false, false) => return None, + }; + Some(format!( + "Want me to set ElevenLabs up in {names} too? It installs the ElevenLabs plugin (skills and \ + the hosted MCP server), so new sessions can use ElevenLabs right away." + )) +} + +fn skills_root(home: &Path) -> PathBuf { + home.join(".agents").join("skills") +} + +/// Download the skills and copy them into `~/.agents/skills`. +fn skills(home: &Path) -> Outcome { + let result = download_skills().and_then(|dir| copy_skills(&dir.path().join("skills"), home)); + let root = skills_root(home); + match result { + Ok(copied) => { + let mut message = format!( + "copied {} ElevenLabs skills into {}", + copied.written.len(), + root.display() + ); + if !copied.kept.is_empty() { + message.push_str(&format!( + "; left alone, since setup didn't write them: {}", + copied.kept.join(", ") + )); + } + Outcome { + agent: "Skills", + ok: true, + message, + } + } + Err(why) => Outcome { + agent: "Skills", + ok: false, + message: why, + }, + } +} + +/// A new temp folder, removed when dropped, with the skills archive unpacked +/// into its `skills` folder. +fn download_skills() -> Result { + let dir = tempfile::tempdir().map_err(|e| format!("couldn't make a temp folder: {e}"))?; + let archive = dir.path().join("skills.tar.gz"); + let unpacked = dir.path().join("skills"); + std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?; + // Not the API client: it refuses the redirect GitHub answers with, to + // keep a key from following it. Nothing secret goes to GitHub. + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .build() + .map_err(|e| e.to_string())?; + let body = super::say::run_async(async { + let response = client + .get(SKILLS_ARCHIVE) + .send() + .await? + .error_for_status()?; + response.bytes().await + }) + .map_err(|e| format!("couldn't download the skills from {SKILLS_ARCHIVE}: {e}"))?; + std::fs::write(&archive, &body).map_err(|e| e.to_string())?; + let out = Command::new("tar") + .arg("-xzf") + .arg(&archive) + .arg("-C") + .arg(&unpacked) + .arg("--strip-components=1") + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("couldn't run tar: {e}"))?; + if !out.status.success() { + return Err(format!( + "tar couldn't unpack the skills: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(dir) +} + +/// The skill folders written, and the ones left as they were. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct Copied { + pub written: Vec, + pub kept: Vec, +} + +/// Copy each top-level folder of `unpacked` that holds a `SKILL.md` into +/// `/.agents/skills/`, marking each one written. A folder already +/// there is replaced only when it has the mark: anything else is the +/// developer's, and is left as it was. +pub(super) fn copy_skills(unpacked: &Path, home: &Path) -> Result { + let root = skills_root(home); + std::fs::create_dir_all(&root).map_err(|e| format!("{}: {e}", root.display()))?; + let mut names: Vec = std::fs::read_dir(unpacked) + .map_err(|e| e.to_string())? + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.path().join("SKILL.md").is_file()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + let mut copied = Copied::default(); + for name in names { + let target = root.join(&name); + if let Ok(meta) = std::fs::symlink_metadata(&target) { + let ours = meta.is_dir() && target.join(MARKER).is_file(); + if !ours { + copied.kept.push(name); + continue; + } + std::fs::remove_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?; + } + copy_dir(&unpacked.join(&name), &target) + .map_err(|e| format!("{}: {e}", target.display()))?; + std::fs::write(target.join(MARKER), "").map_err(|e| e.to_string())?; + copied.written.push(name); + } + Ok(copied) +} + +/// Files and folders only: a link in the archive is not followed. +fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let kind = entry.file_type()?; + let dest = to.join(entry.file_name()); + if kind.is_dir() { + copy_dir(&entry.path(), &dest)?; + } else if kind.is_file() { + std::fs::copy(entry.path(), dest)?; + } + } + Ok(()) +} + +fn handle(matches: &clap::ArgMatches) -> Result<(), CliError> { + let home = home() + .ok_or_else(|| CliError::Validation("No home folder found (HOME is not set).".into()))?; + let agents = found_here(&home); + if !matches.get_flag("yes") { + for line in plan(&agents, &home) { + println!("{line}"); + } + println!("Nothing was changed. Run `{}` to do it.", later_command()); + return Ok(()); + } + let outcomes = run(&agents, &home, LIMITS); + for outcome in &outcomes { + println!("{}", result_line(outcome)); + } + let finish: Vec = outcomes.iter().filter_map(finish).collect(); + if !finish.is_empty() { + println!("\nTo finish:"); + for step in finish { + println!("- {step}"); + } + } + if outcomes.iter().any(|o| !o.ok) { + std::process::exit(1); + } + Ok(()) +} + +pub fn register(app: CliApp) -> CliApp { + app.command( + clap::Command::new("setup") + .about("Install the ElevenLabs plugin (skills and the hosted MCP server) into your coding agents") + .arg( + clap::Arg::new("yes") + .long("yes") + .action(clap::ArgAction::SetTrue) + .help("Install; without it, setup only says what it would do"), + ), + Box::new(|matches, _ctx| handle(matches)), + ) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::*; + + fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let vars: HashMap = vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |name| vars.get(name).cloned() + } + + fn path_with(programs: &[&str]) -> impl Fn(&str) -> Option { + let programs: Vec = programs.iter().map(|p| p.to_string()).collect(); + move |name| { + programs + .iter() + .any(|p| p == name) + .then(|| PathBuf::from(format!("/bin/{name}"))) + } + } + + #[test] + fn agents_are_found_by_program_variable_and_folder() { + let home = tempfile::tempdir().unwrap(); + let none = env_of(&[]); + assert_eq!(found(&none, &path_with(&[]), home.path()), vec![]); + assert_eq!( + found(&none, &path_with(&["claude", "codex"]), home.path()), + vec![ + Agent::ClaudeCode("/bin/claude".into()), + Agent::Codex("/bin/codex".into()) + ] + ); + assert_eq!( + found(&none, &path_with(&["codex"]), home.path()), + vec![Agent::Codex("/bin/codex".into())] + ); + for var in ["CURSOR_AGENT", "CURSOR_TRACE_ID"] { + assert_eq!( + found(&env_of(&[(var, "1")]), &path_with(&[]), home.path()), + vec![Agent::Cursor], + "{var}" + ); + } + assert_eq!( + found( + &env_of(&[("CURSOR_AGENT", "")]), + &path_with(&[]), + home.path() + ), + vec![] + ); + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + assert_eq!( + found(&none, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor] + ); + } + + #[test] + fn claude_code_is_the_program_it_names_when_that_exists() { + let home = tempfile::tempdir().unwrap(); + let program = home.path().join("claude-desktop-bundled"); + std::fs::write(&program, "").unwrap(); + let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); + assert_eq!( + found(&inside, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode(program.clone())] + ); + assert_eq!( + found(&inside, &path_with(&[]), home.path()), + vec![Agent::ClaudeCode(program)] + ); + let gone = env_of(&[("CLAUDE_CODE_EXECPATH", "/nowhere/claude")]); + assert_eq!(found(&gone, &path_with(&[]), home.path()), vec![]); + assert_eq!( + found(&gone, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode("/bin/claude".into())] + ); + } + + #[cfg(unix)] + #[test] + fn a_program_on_path_is_an_executable_file() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let empty = tempfile::tempdir().unwrap(); + let program = dir.path().join("codex"); + std::fs::write(&program, "").unwrap(); + let path = std::env::join_paths([empty.path(), dir.path()]).unwrap(); + assert_eq!(on_path("codex", Some(&path), false), None, "not executable"); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(on_path("codex", Some(&path), false), Some(program)); + assert_eq!(on_path("claude", Some(&path), false), None); + assert_eq!(on_path("codex", None, false), None); + } + + #[test] + fn on_windows_a_program_on_path_is_an_exe_or_a_cmd_shim() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("codex.cmd"), "").unwrap(); + let path = std::env::join_paths([dir.path()]).unwrap(); + assert_eq!( + on_path("codex", Some(&path), true), + Some(dir.path().join("codex.cmd")) + ); + } + + #[test] + fn the_home_folder_is_userprofile_on_windows_and_home_elsewhere() { + let env = env_of(&[("HOME", "/home/dev"), ("USERPROFILE", "C:\\Users\\dev")]); + assert_eq!(home_for(false, &env), Some(PathBuf::from("/home/dev"))); + assert_eq!(home_for(true, &env), Some(PathBuf::from("C:\\Users\\dev"))); + assert_eq!(home_for(true, &env_of(&[("HOME", "/home/dev")])), None); + assert_eq!(home_for(false, &env_of(&[("HOME", "")])), None); + } + + #[test] + fn agents_start_directly_on_unix_and_through_cmd_on_windows() { + let args = ["plugin", "add", "elevenlabs@elevenlabs"]; + let (exe, argv) = argv(Path::new("/usr/local/bin/codex"), &args, false); + assert_eq!(exe, "/usr/local/bin/codex"); + assert_eq!(argv, ["plugin", "add", "elevenlabs@elevenlabs"]); + let shim = Path::new("C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd"); + let (exe, argv) = super::argv(shim, &args, true); + assert_eq!(exe, "cmd"); + assert_eq!( + argv, + [ + "/C", + "C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd", + "plugin", + "add", + "elevenlabs@elevenlabs" + ] + ); + } + + const LIMITS_FOR_TESTS: Limits = Limits { + command: Duration::from_secs(30), + total: Duration::from_secs(100), + }; + + /// Runs nothing; records each command and fails the ones `fails` names. + struct Recorder { + ran: RefCell>, + fails: Vec<&'static str>, + } + + impl Recorder { + fn new(fails: &[&'static str]) -> Self { + Self { + ran: RefCell::new(Vec::new()), + fails: fails.to_vec(), + } + } + + fn run(&self, program: &Path, args: &[&str], limit: Duration) -> Result<(), String> { + assert!(limit <= Duration::from_secs(30)); + let line = format!("{} {}", program.display(), args.join(" ")); + self.ran.borrow_mut().push(line.clone()); + match self.fails.iter().any(|f| line.contains(f)) { + true => Err("exited with 1: boom".into()), + false => Ok(()), + } + } + + fn ran(&self) -> Vec { + self.ran.borrow().clone() + } + } + + fn all_agents() -> Vec { + vec![ + Agent::ClaudeCode("claude".into()), + Agent::Codex("codex".into()), + Agent::Cursor, + ] + } + + #[test] + fn install_runs_each_agents_own_commands_in_order() { + let recorder = Recorder::new(&[]); + let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { + recorder.run(p, a, l) + }); + assert_eq!( + recorder.ran(), + [ + "claude plugin marketplace update elevenlabs", + "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin update elevenlabs@elevenlabs", + "codex plugin marketplace add elevenlabs/plugin", + "codex plugin marketplace upgrade elevenlabs", + "codex plugin add elevenlabs@elevenlabs", + ] + ); + assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); + assert_eq!( + outcomes.iter().map(result_line).collect::>(), + [ + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)", + "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)", + "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", + ] + ); + assert_eq!( + outcomes.iter().filter_map(finish).collect::>(), + [ + "restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server", + "restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", + ] + ); + } + + #[test] + fn only_the_install_commands_decide_an_agents_result() { + let refresh_fails = Recorder::new(&["marketplace update", "marketplace upgrade"]); + let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { + refresh_fails.run(p, a, l) + }); + assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); + assert_eq!(refresh_fails.ran().len(), 6); + + for (fails, agent) in [ + ("plugin install", "Claude Code"), + ("plugin update", "Claude Code"), + ("marketplace add", "Codex"), + ("plugin add", "Codex"), + ] { + let recorder = Recorder::new(&[fails]); + let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { + recorder.run(p, a, l) + }); + let failed: Vec<_> = outcomes.iter().filter(|o| !o.ok).collect(); + assert_eq!(failed.len(), 1, "{fails}: {outcomes:?}"); + assert_eq!(failed[0].agent, agent); + assert!(failed[0].message.contains(fails), "{failed:?}"); + assert!( + result_line(failed[0]).starts_with(&format!("{agent}: failed: `")) + && result_line(failed[0]).ends_with("` exited with 1: boom"), + "{}", + result_line(failed[0]) + ); + assert_eq!( + finish(failed[0]), + None, + "no finishing steps for a failed agent" + ); + } + } + + #[test] + fn agents_not_reached_in_time_count_as_failed() { + let recorder = Recorder::new(&[]); + let slow = |p: &Path, a: &[&str], l: Duration| { + std::thread::sleep(Duration::from_millis(30)); + recorder.run(p, a, l) + }; + let limits = Limits { + command: Duration::from_millis(500), + total: Duration::from_millis(50), + }; + let outcomes = install(&all_agents(), limits, &slow); + assert_eq!(recorder.ran().len(), 2, "{:?}", recorder.ran()); + assert!( + !outcomes[0].ok && outcomes[0].message.contains("ran out of time"), + "{outcomes:?}" + ); + assert!( + !outcomes[1].ok && outcomes[1].message.contains("ran out of time"), + "{outcomes:?}" + ); + assert!(outcomes[2].ok, "Cursor runs nothing"); + } + + #[cfg(unix)] + fn script(dir: &Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[cfg(unix)] + #[test] + fn a_command_past_its_limit_is_stopped_and_fails() { + let dir = tempfile::tempdir().unwrap(); + let hangs = script(dir.path(), "claude", "exec sleep 30"); + let started = Instant::now(); + let err = + run_program(&hangs, &["plugin", "install"], Duration::from_millis(200)).unwrap_err(); + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); + assert_eq!(err, "ran out of time after 200 ms and was stopped"); + + let outcomes = install( + &[Agent::ClaudeCode(hangs), Agent::Cursor], + Limits { + command: Duration::from_millis(200), + total: Duration::from_millis(300), + }, + &run_program, + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); + assert!(!outcomes[0].ok, "{outcomes:?}"); + assert!( + outcomes[0].message.contains("ran out of time"), + "{outcomes:?}" + ); + assert!(outcomes[1].ok); + } + + #[cfg(unix)] + #[test] + fn a_failing_command_reports_its_last_line_and_stdin_is_closed() { + let dir = tempfile::tempdir().unwrap(); + let fails = script( + dir.path(), + "codex", + "echo working; echo 'first' >&2; echo 'no such plugin' >&2; exit 3", + ); + assert_eq!( + run_program(&fails, &[], Duration::from_secs(10)), + Err("exited with 3: no such plugin".into()) + ); + let reads = script(dir.path(), "reads", "read line && exit 1; exit 0"); + assert_eq!(run_program(&reads, &[], Duration::from_secs(10)), Ok(())); + } + + fn skill(dir: &Path, name: &str, text: &str) { + std::fs::create_dir_all(dir.join(name).join("references")).unwrap(); + std::fs::write(dir.join(name).join("SKILL.md"), text).unwrap(); + std::fs::write(dir.join(name).join("references").join("a.md"), "ref").unwrap(); + } + + #[test] + fn the_skills_go_into_agents_skills_marked_as_setups() { + let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + skill(unpacked.path(), "text-to-speech", "new tts"); + skill(unpacked.path(), "music", "new music"); + skill(unpacked.path(), "dubbing", "new dubbing"); + std::fs::create_dir_all(unpacked.path().join("evals").join("cases")).unwrap(); + std::fs::write(unpacked.path().join("README.md"), "readme").unwrap(); + let root = home.path().join(".agents").join("skills"); + + // One of the developer's own, and one setup wrote before. + std::fs::create_dir_all(root.join("music")).unwrap(); + std::fs::write(root.join("music").join("SKILL.md"), "mine").unwrap(); + std::fs::create_dir_all(root.join("dubbing")).unwrap(); + std::fs::write(root.join("dubbing").join("SKILL.md"), "old dubbing").unwrap(); + std::fs::write(root.join("dubbing").join("stale.md"), "old").unwrap(); + std::fs::write(root.join("dubbing").join(MARKER), "").unwrap(); + + let copied = copy_skills(unpacked.path(), home.path()).unwrap(); + assert_eq!( + copied, + Copied { + written: vec!["dubbing".into(), "text-to-speech".into()], + kept: vec!["music".into()], + } + ); + let read = |p: &[&str]| { + std::fs::read_to_string(p.iter().fold(root.clone(), |a, b| a.join(b))).unwrap() + }; + assert_eq!(read(&["text-to-speech", "SKILL.md"]), "new tts"); + assert_eq!(read(&["text-to-speech", "references", "a.md"]), "ref"); + assert_eq!(read(&["text-to-speech", MARKER]), ""); + assert_eq!(read(&["dubbing", "SKILL.md"]), "new dubbing"); + assert!( + !root.join("dubbing").join("stale.md").exists(), + "replaced whole" + ); + assert_eq!(read(&["music", "SKILL.md"]), "mine"); + assert!(!root.join("music").join(MARKER).exists()); + assert_eq!(std::fs::read_dir(root.join("music")).unwrap().count(), 1); + assert!(!root.join("evals").exists() && !root.join("README.md").exists()); + + // Again: everything setup wrote is replaced, the developer's still left. + let again = copy_skills(unpacked.path(), home.path()).unwrap(); + assert_eq!(again, copied); + } + + #[test] + fn the_offer_names_the_agents_that_can_take_the_plugin() { + let claude = Agent::ClaudeCode("claude".into()); + let codex = Agent::Codex("codex".into()); + let plugin = |names: &str| { + Some(format!( + "Want me to set ElevenLabs up in {names} too? It installs the ElevenLabs plugin (skills and the \ + hosted MCP server), so new sessions can use ElevenLabs right away." + )) + }; + assert_eq!(question(&[claude.clone()]), plugin("Claude Code")); + assert_eq!(question(&[codex.clone(), Agent::Cursor]), plugin("Codex")); + assert_eq!( + question(&[claude, codex, Agent::Cursor]), + plugin("Claude Code and Codex") + ); + assert_eq!( + question(&[]), + Some("Want me to install the ElevenLabs skills for your coding agents?".into()) + ); + assert_eq!( + question(&[Agent::Cursor]), + None, + "Cursor has nothing to run" + ); + } + + #[test] + fn the_later_command_runs_the_cli_the_way_this_run_was_started() { + assert_eq!( + cli_prefix_for(Some("exec")), + "npx -y @elevenlabs/cli@latest" + ); + assert_eq!(cli_prefix_for(Some("install")), "elevenlabs"); + assert_eq!(cli_prefix_for(None), "elevenlabs"); + assert_eq!( + format!("{} setup --yes", cli_prefix_for(Some("exec"))), + "npx -y @elevenlabs/cli@latest setup --yes" + ); + assert_eq!( + format!("{} setup --yes", cli_prefix_for(None)), + "elevenlabs setup --yes" + ); + } + + #[test] + fn the_plan_says_what_would_happen_for_each_agent() { + let home = Path::new("/home/dev"); + assert_eq!( + plan(&[Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor], home), + [ + "Claude Code (/bin/claude): install the ElevenLabs plugin (skills and the hosted MCP server)", + "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", + ] + ); + assert_eq!(plan(&[], home).len(), 1); + assert!(plan(&[], home)[0].contains(&skills_root(home).display().to_string())); + } +} diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index c2905b71..a405a1ee 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -49,6 +49,8 @@ fn cli(home: &Path, cwd: &Path, server: &str, args: &[&str]) -> Command { "REMOTE_CONTAINERS", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", + // Names the developer's own Claude Code, which `setup` would install into. + "CLAUDE_CODE_EXECPATH", "CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", @@ -1730,3 +1732,425 @@ async fn with_telemetry_off_nothing_is_said_about_the_machine_or_the_last_sign_i assert!(!agent.contains("os/"), "{agent}"); } } + +// ── setup, and the offer at the end of the flow ───────────────────── + +/// The sign-in folder `onboard` keeps for the project in `cwd`, named as +/// `Session::for_project` names it. +fn session_dir(home: &Path, cwd: &Path) -> PathBuf { + use sha2::Digest; + let canonical = cwd.canonicalize().unwrap(); + let id: String = sha2::Sha256::digest(canonical.to_string_lossy().as_bytes()) + .iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect(); + home.join(".elevenlabs").join("onboard").join(id) +} + +/// What a sign-in whose page asked for the `setup` offer leaves. +fn setup_offer_due(home: &Path, cwd: &Path) -> PathBuf { + let dir = session_dir(home, cwd); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("setup-offer"); + std::fs::write(&file, "").unwrap(); + file +} + +/// Stand-ins for the agents' programs in a folder of their own, each adding +/// the command it was given to `record`, and failing the one whose arguments +/// contain `fail`. Returns a PATH with only them and the system's tools. +#[cfg(unix)] +fn stand_ins(dir: &Path, names: &[&str], record: &Path, fail: Option<&str>) -> String { + use std::os::unix::fs::PermissionsExt; + for name in names { + let fails = match fail { + Some(args) => format!( + "case \"$*\" in *\"{args}\"*) echo 'something broke' >&2; echo 'install refused' >&2; exit 1;; esac\n" + ), + None => String::new(), + }; + let script = format!( + "#!/bin/sh\necho \"{name} $*\" >> '{}'\n{fails}exit 0\n", + record.display() + ); + let path = dir.join(name); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + format!("{}:/usr/bin:/bin", dir.display()) +} + +fn recorded(record: &Path) -> Vec { + std::fs::read_to_string(record) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() +} + +/// The same reply, apart from the clip, whose file is new each time. +fn without_clip(reply: &Value) -> String { + let clip = reply["details"]["audio_file"].as_str().unwrap(); + say(reply).replace(clip, "") +} + +const PLUGIN_QUESTION: &str = "Want me to set ElevenLabs up in {} too? It installs the ElevenLabs plugin \ + (skills and the hosted MCP server), so new sessions can use ElevenLabs right away."; + +#[cfg(unix)] +#[tokio::test] +async fn a_test_request_that_worked_offers_setup_once_when_the_sign_in_asked() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"ID3-fake-mp3".to_vec())) + .mount(&server) + .await; + let failing = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with( + ResponseTemplate::new(500).set_body_json(json!({"detail": {"message": "down"}})), + ) + .mount(&failing) + .await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude"], &record, None); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let test = |server: &str| { + reply( + cli(home.path(), cwd.path(), server, &["onboard", "test"]).env("PATH", &path), + ) + }; + + let plain = test(&server.uri()); + assert_eq!(plain["next"], "done", "no offer without the sign-in asking: {plain}"); + + let offer = setup_offer_due(home.path(), cwd.path()); + // A test request that failed ends as it always has, and the offer waits. + let r = test(&failing.uri()); + assert_eq!(r["next"], "done", "{r}"); + assert!(r["details"].get("question").is_none(), "{r}"); + assert!(offer.exists()); + + let r = test(&server.uri()); + assert_eq!(r["next"], "offer_setup", "{r}"); + assert_eq!( + r["details"]["question"], + PLUGIN_QUESTION.replace("{}", "Claude Code") + ); + assert_eq!(r["run"], "elevenlabs onboard setup --env-path .env.local"); + assert_eq!(without_clip(&r), without_clip(&plain), "the same say"); + assert_eq!(r["details"]["status"], 200); + assert!(std::path::Path::new(r["details"]["audio_file"].as_str().unwrap()).exists()); + assert!(r["instruction"].as_str().unwrap().contains("details.question"), "{r}"); + assert!(!offer.exists(), "made once"); + assert!(recorded(&record).is_empty(), "offering runs nothing"); + + let r = test(&server.uri()); + assert_eq!(r["next"], "done", "{r}"); + assert!(r["details"].get("question").is_none(), "{r}"); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_voice_agent_project_offers_setup_at_its_link() { + const MINE: &str = "agent_7101k5zvyjhmfg983brhmhkd98n6"; + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + agent_endpoint( + &server, + KEY, + MINE, + "Store helper", + json!({"enable_auth": false, "allowlist": []}), + ) + .await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let path = stand_ins(bin.path(), &["codex"], &bin.path().join("ran"), None); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + write(cwd.path(), "index.html", &embed(MINE)); + let check = || { + reply( + cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--answered", "--no-browser"], + ) + .env("PATH", &path), + ) + }; + // The first check also fixes the file, which the details say once. + check(); + let plain = check(); + assert_eq!(plain["next"], "done", "{plain}"); + + let offer = setup_offer_due(home.path(), cwd.path()); + let r = check(); + assert_eq!(r["next"], "offer_setup", "{r}"); + assert_eq!(r["details"]["question"], PLUGIN_QUESTION.replace("{}", "Codex")); + assert_eq!( + r["run"], + "elevenlabs onboard setup --env-path .env.local --no-browser" + ); + assert_eq!(r["say"], plain["say"]); + let mut details = r["details"].clone(); + details.as_object_mut().unwrap().remove("question"); + assert_eq!(details, plain["details"], "the same details, and the question"); + assert!(!offer.exists()); + assert_eq!(check()["next"], "done"); +} + +#[cfg(unix)] +#[tokio::test] +async fn with_only_cursor_there_is_no_offer() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"ID3".to_vec())) + .mount(&server) + .await; + let (home, cwd) = temp(); + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let offer = setup_offer_due(home.path(), cwd.path()); + let r = reply( + cli(home.path(), cwd.path(), &server.uri(), &["onboard", "test"]).env("PATH", "/usr/bin:/bin"), + ); + assert_eq!(r["next"], "done", "{r}"); + assert!(offer.exists(), "kept: Cursor has nothing to install with"); +} + +/// The six commands, in order, that install the plugin in Claude Code and Codex. +const INSTALLS: [&str; 6] = [ + "claude plugin marketplace update elevenlabs", + "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin update elevenlabs@elevenlabs", + "codex plugin marketplace add elevenlabs/plugin", + "codex plugin marketplace upgrade elevenlabs", + "codex plugin add elevenlabs@elevenlabs", +]; + +#[cfg(unix)] +#[tokio::test] +async fn onboard_setup_installs_in_each_agent_and_never_makes_speech() { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + Mock::given(method("POST")) + .and(path_regex("^/v1/text-to-speech")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let setup = |args: &[&str], env: &[(&str, &str)]| { + reply( + cli( + home.path(), + cwd.path(), + &server.uri(), + &[&["onboard", "setup", "--env-path", ".env.local"][..], args].concat(), + ) + .env("PATH", &path) + .envs(env.iter().copied()), + ) + }; + let user_requests = || async { + server + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|r| r.url.path() == "/v1/user") + .map(|r| r.headers["user-agent"].to_str().unwrap().to_string()) + .collect::>() + }; + + let r = setup(&["--no-browser"], &[]); + assert_eq!(r["next"], "done", "{r}"); + assert!(r.get("run").is_none(), "{r}"); + assert_eq!(recorded(&record), INSTALLS); + assert_eq!( + say(&r), + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ + Claude Code, then run /mcp and sign in to the ElevenLabs MCP server. Codex: installed the ElevenLabs \ + plugin (skills and the hosted MCP server). To finish, restart Codex, then run `codex mcp login \ + elevenlabs` to sign in to the ElevenLabs MCP server." + ); + let results = r["details"]["setup"].as_array().unwrap(); + assert_eq!(results.len(), 2, "{r}"); + assert!(results.iter().all(|a| a["ok"] == true), "{r}"); + let agents = user_requests().await; + assert_eq!(agents.len(), 1, "one account request with telemetry on: {agents:?}"); + assert!(agents[0].contains("cmd/onboard.setup"), "{}", agents[0]); + + std::fs::remove_file(&record).unwrap(); + for off in [&[("ELEVENLABS_TELEMETRY", "off")][..], &[("DO_NOT_TRACK", "1")][..]] { + let r = setup(&[], off); + assert_eq!(r["next"], "done", "{r}"); + } + assert_eq!(recorded(&record).len(), 12, "installed both times"); + assert_eq!(user_requests().await.len(), 1, "none with telemetry off"); + + // Cursor gets its link with the rest. + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + let r = setup(&[], &[]); + assert!( + say(&r).ends_with("Cursor: open https://cursor.com/marketplace/elevenlabs and select Install."), + "{r}" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_failed_install_ends_on_the_command_to_run_later() { + let server = MockServer::start().await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, Some("plugin install")); + for (npm, later) in [ + (None, "elevenlabs setup --yes"), + (Some("exec"), "npx -y @elevenlabs/cli@latest setup --yes"), + ] { + let mut cmd = cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]); + cmd.env("PATH", &path); + if let Some(npm) = npm { + cmd.env("npm_command", npm); + } + let r = reply(&mut cmd); + assert_eq!(r["next"], "done", "{r}"); + assert!( + say(&r).starts_with( + "Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` \ + exited with 1: install refused. Codex: installed" + ), + "{r}" + ); + assert!( + say(&r).ends_with(&format!("To try again later, run `{later}`.")), + "{r}" + ); + assert_eq!(r["details"]["setup"][0]["ok"], false, "{r}"); + } + // No request without a key, telemetry on or not. + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[cfg(unix)] +#[test] +fn setup_says_what_it_would_do_and_does_it_with_yes() { + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let run = |args: &[&str], path: &str| { + cli(home.path(), cwd.path(), OFFLINE, args) + .env("PATH", path) + .output() + .unwrap() + }; + + let out = run(&["setup"], &path); + assert!(out.status.success(), "{}", all_output(&out)); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert_eq!( + stdout, + format!( + "Claude Code ({0}/claude): install the ElevenLabs plugin (skills and the hosted MCP server)\n\ + Codex ({0}/codex): install the ElevenLabs plugin (skills and the hosted MCP server)\n\ + Nothing was changed. Run `elevenlabs setup --yes` to do it.\n", + bin.path().display() + ) + ); + assert!(recorded(&record).is_empty(), "nothing ran"); + + for _ in 0..2 { + let out = run(&["setup", "--yes"], &path); + assert!(out.status.success(), "{}", all_output(&out)); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("\nTo finish:\n- restart Claude Code, then run /mcp"), "{stdout}"); + } + assert_eq!(recorded(&record), [INSTALLS, INSTALLS].concat(), "again: the update commands run too"); + + let failing = tempfile::tempdir().unwrap(); + let path = stand_ins(failing.path(), &["claude"], &failing.path().join("ran"), Some("plugin update")); + let out = run(&["setup", "--yes"], &path); + assert_eq!(out.status.code(), Some(1), "{}", all_output(&out)); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + "Claude Code: failed: `claude plugin update elevenlabs@elevenlabs` exited with 1: install refused\n" + ); + + // Only Cursor: its link, and nothing written. + let (home, cwd) = temp(); + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) + .env("PATH", "/usr/bin:/bin") + .output() + .unwrap(); + assert!(out.status.success(), "{}", all_output(&out)); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install\n" + ); + let entries: Vec<_> = std::fs::read_dir(home.path()).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(entries, [".cursor"], "nothing written"); +} + +#[tokio::test] +async fn the_sign_in_asks_for_the_setup_offer_and_keeps_the_pages_answer() { + let _turn = signing_in().await; + if callback_ports_busy() { + eprintln!("skipped: another sign-in is listening on the callback ports"); + return; + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&server) + .await; + let (home, cwd) = temp(); + let authorize = format!("{}/app/oauth/authorize", server.uri()); + // Signs in with telemetry off, and the page's callback carrying `params`. + let sign_in = |params: &str| { + let first = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "connect", "--no-browser", "--authorize-url", &authorize, "--telemetry", "off"], + )); + let url = link(&first); + assert_eq!(query(&url, "setup_offer"), "1", "{url}"); + callback(&url, &format!("code=approved{params}")); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let r = connect(home.path(), cwd.path(), &["--wait", "--no-browser"]); + if r["next"] != "wait" || Instant::now() >= deadline { + assert_eq!(r["details"]["key_status"], "no_key", "{r}"); + break; + } + } + }; + let offer = session_dir(home.path(), cwd.path()).join("setup-offer"); + sign_in("&setup_offer=1"); + assert!(offer.exists(), "the page asked for the offer"); + sign_in(""); + assert!(!offer.exists(), "a new sign-in without it clears the last one's"); + sign_in("&setup_offer=yes"); + assert!(!offer.exists()); +} From 0301d97dcae1281d413ccfe51ebf12572ed2c7c4 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 18:41:28 -0500 Subject: [PATCH 02/32] fix: setup only runs a program called claude, stops what it starts, and honours --dry-run Claude Code installed by npm names node or bun in CLAUDE_CODE_EXECPATH, so setup now uses that path only when the program is called claude, and starts every agent command in the home folder, where nothing in the project can be loaded. A command past its time limit is stopped with every program it started. --dry-run installs nothing, in setup and onboard setup. On Windows a .cmd shim is started by its full path, without a hand-built cmd /C, so a folder with parentheses works. A skills copy that stops partway is replaced next time, onboard setup with no home folder ends with a reply, and a failed setup says how to try again. --- README.md | 6 +- cli/elevenlabs/workflow/onboard/mod.rs | 25 ++- cli/elevenlabs/workflow/setup.rs | 261 ++++++++++++++++++------- tests/onboard_test.rs | 56 +++++- 4 files changed, 267 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index c959870d..f1129243 100644 --- a/README.md +++ b/README.md @@ -449,16 +449,16 @@ elevenlabs components add conversation-bar `elevenlabs setup` installs the [ElevenLabs plugin](https://github.com/elevenlabs/plugin) (the ElevenLabs skills and the hosted MCP server) into the coding agents it finds, through each agent's own plugin command: ```bash -elevenlabs setup # say what it would do, and change nothing +elevenlabs setup # say what it would do, and change nothing (so does --dry-run) elevenlabs setup --yes # do it ``` -- **Claude Code** (the program in `CLAUDE_CODE_EXECPATH` when run inside it, else `claude` on `PATH`): `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user`, then `claude plugin update elevenlabs@elevenlabs`. +- **Claude Code** (when run inside it, the program in `CLAUDE_CODE_EXECPATH` if that is `claude`, else `claude` on `PATH`): `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user`, then `claude plugin update elevenlabs@elevenlabs`. - **Codex** (`codex` on `PATH`): `codex plugin marketplace add elevenlabs/plugin`, then `codex plugin add elevenlabs@elevenlabs`. - **Cursor** (a `~/.cursor` folder, or run inside Cursor) has no install command: `setup` prints the link to [its marketplace page](https://cursor.com/marketplace/elevenlabs), where you select Install. - **None of these**: `setup` copies the [ElevenLabs skills](https://github.com/elevenlabs/skills) into `~/.agents/skills`, one folder per skill. A folder there that `setup` didn't write is left as it is, and named in the output. -Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. +Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again. Agent commands run in your home folder, never in the project. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. When onboarding through a coding agent (`npx -y @elevenlabs/cli@latest onboard init`), the flow can end by offering to do this for you; saying yes runs `onboard setup`, which does what `setup --yes` does. diff --git a/cli/elevenlabs/workflow/onboard/mod.rs b/cli/elevenlabs/workflow/onboard/mod.rs index 8c26ff5b..8f25c70a 100644 --- a/cli/elevenlabs/workflow/onboard/mod.rs +++ b/cli/elevenlabs/workflow/onboard/mod.rs @@ -69,14 +69,30 @@ fn dispatch(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError /// offers accepted can be counted; its answer changes nothing. fn setup(matches: &clap::ArgMatches, ctx: &AppContext) -> Result { let started = std::time::Instant::now(); + let later = super::setup::later_command(); + if super::util::dry_run_flag(matches) { + return Ok(reply::Reply::new( + reply::Next::Done, + format!( + "Nothing was installed in the coding agents, because of --dry-run. To set them up, run `{later}`." + ), + )); + } let project = project::Project::open(matches)?; if super::telemetry::enabled() { if let Some(key) = project.key() { let _ = auth::check_key(ctx, &key.value); } } - let home = super::setup::home() - .ok_or_else(|| CliError::Other(anyhow::anyhow!("no home folder to set the agents up in")))?; + let Some(home) = super::setup::home() else { + return Ok(reply::Reply::new( + reply::Next::Done, + format!( + "ElevenLabs could not be set up in the coding agents: no home folder was found. To try again \ + later, run `{later}`." + ), + )); + }; // The request's time comes out of the whole run's, so the reply still comes in time. let limits = super::setup::Limits { total: super::setup::LIMITS.total.saturating_sub(started.elapsed()), @@ -92,10 +108,7 @@ fn setup(matches: &clap::ArgMatches, ctx: &AppContext) -> Result Vec { let set = |name: &str| env(name).is_some_and(|v| !v.is_empty()); let mut agents = Vec::new(); - // Inside Claude Code, including the desktop app, which may not put `claude` on PATH. + // Inside Claude Code, including the desktop app, which may not put `claude` + // on PATH. Claude Code installed by npm names `node` or `bun` there instead. let claude = env("CLAUDE_CODE_EXECPATH") .map(PathBuf::from) - .filter(|p| p.is_file()) + .filter(|p| p.is_file() && is_claude(p)) .or_else(|| on_path("claude")); if let Some(claude) = claude { agents.push(Agent::ClaudeCode(claude)); @@ -120,6 +121,12 @@ pub(super) fn found( agents } +/// Whether `path` is a program called `claude`, as Windows spells it or not. +fn is_claude(path: &Path) -> bool { + let name = path.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase()); + matches!(name.as_deref(), Some("claude" | "claude.exe")) +} + /// [`found`] for this machine, from the shell's environment. pub(super) fn found_here(home: &Path) -> Vec { let path = shell_var("PATH").map(OsString::from); @@ -181,37 +188,33 @@ fn is_program(path: &Path) -> bool { path.is_file() } -/// The program and arguments that run `program args`. Never through a shell -/// on Unix; on Windows through `cmd /C`, since npm installs agents as `.cmd` -/// shims that can't be started by name. -pub(super) fn argv(program: &Path, args: &[&str], windows: bool) -> (OsString, Vec) { - let args = args.iter().map(OsString::from); - if windows { - let mut all = vec![OsString::from("/C"), program.as_os_str().to_owned()]; - all.extend(args); - (OsString::from("cmd"), all) - } else { - (program.as_os_str().to_owned(), args.collect()) - } -} - -/// Run `program args` with stdin closed, stopping it after `limit`. `Err` is -/// why it failed: its last line of output, or that it ran out of time. -pub(super) fn run_program(program: &Path, args: &[&str], limit: Duration) -> Result<(), String> { - let (exe, argv) = argv(program, args, cfg!(windows)); +/// Run `program args` with stdin closed, in `home` so nothing in the project +/// can be loaded as part of it, stopping it and everything it started after +/// `limit`. `program` is a full path, so on Windows std starts a `.cmd` shim +/// through cmd with its own quoting. `Err` is why it failed: its last line of +/// output, or that it ran out of time. +pub(super) fn run_program( + program: &Path, + args: &[&str], + limit: Duration, + home: &Path, +) -> Result<(), String> { let (out, err) = match (tempfile::tempfile(), tempfile::tempfile()) { (Ok(out), Ok(err)) => (out, err), (Err(e), _) | (_, Err(e)) => return Err(format!("couldn't start it: {e}")), }; - let mut command = Command::new(exe); + let mut command = Command::new(program); command - .args(argv) + .args(args) .stdin(Stdio::null()) .stdout(out.try_clone().map_err(|e| e.to_string())?) .stderr(err.try_clone().map_err(|e| e.to_string())?); if let Some(env) = super::onboard::shell_env() { command.env_clear().envs(env); } + command.current_dir(home).env("PWD", home); + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); let mut child = command .spawn() .map_err(|e| format!("couldn't start it: {e}"))?; @@ -221,7 +224,7 @@ pub(super) fn run_program(program: &Path, args: &[&str], limit: Duration) -> Res Ok(Some(status)) => break status, Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), _ => { - let _ = child.kill(); + stop_all(&mut child); let _ = child.wait(); return Err(format!( "ran out of time after {} and was stopped", @@ -243,6 +246,24 @@ pub(super) fn run_program(program: &Path, args: &[&str], limit: Duration) -> Res }) } +/// Stop `child` and the programs it started: the npm `codex` is a node +/// script that starts the real one, and on Windows a shim runs under cmd. +fn stop_all(child: &mut std::process::Child) { + #[cfg(unix)] + if let Ok(group) = i32::try_from(child.id()) { + // The child leads its own group, so this reaches only what it started. + unsafe { libc::kill(-group, libc::SIGKILL) }; + } + #[cfg(windows)] + let _ = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let _ = child.kill(); +} + fn seconds(d: Duration) -> String { if d >= Duration::from_secs(1) { format!("{} seconds", d.as_secs()) @@ -273,6 +294,16 @@ pub(super) fn install( run: &dyn Fn(&Path, &[&str], Duration) -> Result<(), String>, ) -> Vec { let started = Instant::now(); + install_timed(agents, limits, run, &|| started.elapsed()) +} + +/// [`install`], with `elapsed` saying how much of the whole run's time is used. +fn install_timed( + agents: &[Agent], + limits: Limits, + run: &dyn Fn(&Path, &[&str], Duration) -> Result<(), String>, + elapsed: &dyn Fn() -> Duration, +) -> Vec { agents .iter() .map(|agent| { @@ -292,7 +323,7 @@ pub(super) fn install( message, }; for (args, decides) in agent.steps() { - let left = limits.total.saturating_sub(started.elapsed()); + let left = limits.total.saturating_sub(elapsed()); if left.is_zero() { return failed(format!( "ran out of time: setup stops after {}", @@ -324,7 +355,9 @@ pub(super) fn run(agents: &[Agent], home: &Path, limits: Limits) -> Vec if agents.is_empty() { return vec![skills(home)]; } - install(agents, limits, &run_program) + install(agents, limits, &|program, args, limit| { + run_program(program, args, limit, home) + }) } /// What `setup` does for each agent, before it does it. @@ -519,9 +552,11 @@ pub(super) fn copy_skills(unpacked: &Path, home: &Path) -> Result Result<(), CliError> { let home = home() .ok_or_else(|| CliError::Validation("No home folder found (HOME is not set).".into()))?; let agents = found_here(&home); - if !matches.get_flag("yes") { + if !matches.get_flag("yes") || super::util::dry_run_flag(matches) { for line in plan(&agents, &home) { println!("{line}"); } @@ -558,6 +593,10 @@ fn handle(matches: &clap::ArgMatches) -> Result<(), CliError> { for outcome in &outcomes { println!("{}", result_line(outcome)); } + let failed = outcomes.iter().any(|o| !o.ok); + if failed { + println!("To try again, run `{}`.", later_command()); + } let finish: Vec = outcomes.iter().filter_map(finish).collect(); if !finish.is_empty() { println!("\nTo finish:"); @@ -565,7 +604,7 @@ fn handle(matches: &clap::ArgMatches) -> Result<(), CliError> { println!("- {step}"); } } - if outcomes.iter().any(|o| !o.ok) { + if failed { std::process::exit(1); } Ok(()) @@ -649,19 +688,24 @@ mod tests { } #[test] - fn claude_code_is_the_program_it_names_when_that_exists() { + fn claude_code_is_the_program_it_names_when_that_is_claude() { let home = tempfile::tempdir().unwrap(); - let program = home.path().join("claude-desktop-bundled"); - std::fs::write(&program, "").unwrap(); - let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); - assert_eq!( - found(&inside, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode(program.clone())] - ); - assert_eq!( - found(&inside, &path_with(&[]), home.path()), - vec![Agent::ClaudeCode(program)] - ); + for name in ["claude", "Claude.exe", "CLAUDE"] { + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join(name); + std::fs::write(&program, "").unwrap(); + let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); + assert_eq!( + found(&inside, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode(program.clone())], + "{name}" + ); + assert_eq!( + found(&inside, &path_with(&[]), home.path()), + vec![Agent::ClaudeCode(program)], + "{name}" + ); + } let gone = env_of(&[("CLAUDE_CODE_EXECPATH", "/nowhere/claude")]); assert_eq!(found(&gone, &path_with(&[]), home.path()), vec![]); assert_eq!( @@ -670,6 +714,29 @@ mod tests { ); } + /// Claude Code installed by npm names the runtime that runs it, which + /// would load the project's own `plugin.js` given `plugin install`. + #[test] + fn a_runtime_named_by_claude_code_is_never_started() { + let home = tempfile::tempdir().unwrap(); + for name in ["node", "bun", "node.exe", "claude-desktop", "claude.cmd"] { + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join(name); + std::fs::write(&program, "").unwrap(); + let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); + assert_eq!( + found(&inside, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode("/bin/claude".into())], + "{name}: claude on PATH instead" + ); + assert_eq!( + found(&inside, &path_with(&[]), home.path()), + vec![], + "{name}" + ); + } + } + #[cfg(unix)] #[test] fn a_program_on_path_is_an_executable_file() { @@ -706,27 +773,6 @@ mod tests { assert_eq!(home_for(false, &env_of(&[("HOME", "")])), None); } - #[test] - fn agents_start_directly_on_unix_and_through_cmd_on_windows() { - let args = ["plugin", "add", "elevenlabs@elevenlabs"]; - let (exe, argv) = argv(Path::new("/usr/local/bin/codex"), &args, false); - assert_eq!(exe, "/usr/local/bin/codex"); - assert_eq!(argv, ["plugin", "add", "elevenlabs@elevenlabs"]); - let shim = Path::new("C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd"); - let (exe, argv) = super::argv(shim, &args, true); - assert_eq!(exe, "cmd"); - assert_eq!( - argv, - [ - "/C", - "C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd", - "plugin", - "add", - "elevenlabs@elevenlabs" - ] - ); - } - const LIMITS_FOR_TESTS: Limits = Limits { command: Duration::from_secs(30), total: Duration::from_secs(100), @@ -844,15 +890,17 @@ mod tests { #[test] fn agents_not_reached_in_time_count_as_failed() { let recorder = Recorder::new(&[]); + // Each command takes 30 s of a 50 s run, without waiting for it. + let used = std::cell::Cell::new(Duration::ZERO); let slow = |p: &Path, a: &[&str], l: Duration| { - std::thread::sleep(Duration::from_millis(30)); + used.set(used.get() + Duration::from_secs(30)); recorder.run(p, a, l) }; let limits = Limits { - command: Duration::from_millis(500), - total: Duration::from_millis(50), + command: Duration::from_secs(30), + total: Duration::from_secs(50), }; - let outcomes = install(&all_agents(), limits, &slow); + let outcomes = install_timed(&all_agents(), limits, &slow, &|| used.get()); assert_eq!(recorder.ran().len(), 2, "{:?}", recorder.ran()); assert!( !outcomes[0].ok && outcomes[0].message.contains("ran out of time"), @@ -880,8 +928,13 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let hangs = script(dir.path(), "claude", "exec sleep 30"); let started = Instant::now(); - let err = - run_program(&hangs, &["plugin", "install"], Duration::from_millis(200)).unwrap_err(); + let err = run_program( + &hangs, + &["plugin", "install"], + Duration::from_millis(200), + dir.path(), + ) + .unwrap_err(); assert!( started.elapsed() < Duration::from_secs(5), "{:?}", @@ -895,7 +948,7 @@ mod tests { command: Duration::from_millis(200), total: Duration::from_millis(300), }, - &run_program, + &|p, a, l| run_program(p, a, l, dir.path()), ); assert!( started.elapsed() < Duration::from_secs(5), @@ -920,11 +973,55 @@ mod tests { "echo working; echo 'first' >&2; echo 'no such plugin' >&2; exit 3", ); assert_eq!( - run_program(&fails, &[], Duration::from_secs(10)), + run_program(&fails, &[], Duration::from_secs(10), dir.path()), Err("exited with 3: no such plugin".into()) ); let reads = script(dir.path(), "reads", "read line && exit 1; exit 0"); - assert_eq!(run_program(&reads, &[], Duration::from_secs(10)), Ok(())); + assert_eq!( + run_program(&reads, &[], Duration::from_secs(10), dir.path()), + Ok(()) + ); + } + + #[cfg(unix)] + #[test] + fn a_command_past_its_limit_is_stopped_with_everything_it_started() { + let dir = tempfile::tempdir().unwrap(); + let pid = dir.path().join("pid"); + let hangs = script( + dir.path(), + "codex", + &format!("sleep 300 &\necho $! > '{}'\nwait", pid.display()), + ); + // Long enough for the stand-in to start its child on a busy machine. + let err = run_program(&hangs, &[], Duration::from_secs(2), dir.path()).unwrap_err(); + assert!(err.contains("ran out of time"), "{err}"); + let pid: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "sleep {pid} still runs"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[cfg(unix)] + #[test] + fn agents_run_in_the_home_folder_not_the_project() { + let (dir, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let wrote = dir.path().join("pwd"); + let prints = script( + dir.path(), + "claude", + &format!("pwd -P > '{0}'; echo \"$PWD\" >> '{0}'", wrote.display()), + ); + run_program(&prints, &[], Duration::from_secs(10), home.path()).unwrap(); + let home = home.path().canonicalize().unwrap(); + let lines: Vec = std::fs::read_to_string(&wrote) + .unwrap() + .lines() + .map(|l| Path::new(l).canonicalize().unwrap()) + .collect(); + assert_eq!(lines, [home.clone(), home]); } fn skill(dir: &Path, name: &str, text: &str) { @@ -980,6 +1077,28 @@ mod tests { assert_eq!(again, copied); } + #[cfg(unix)] + #[test] + fn a_copy_that_stops_partway_is_replaced_next_time() { + use std::os::unix::fs::PermissionsExt; + let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + skill(unpacked.path(), "music", "new music"); + let unreadable = unpacked.path().join("music").join("references").join("a.md"); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); + let target = skills_root(home.path()).join("music"); + + assert!(copy_skills(unpacked.path(), home.path()).is_err()); + assert!(target.join(MARKER).is_file(), "marked before the copy"); + + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).unwrap(); + let copied = copy_skills(unpacked.path(), home.path()).unwrap(); + assert_eq!(copied.written, ["music"]); + assert_eq!( + std::fs::read_to_string(target.join("references").join("a.md")).unwrap(), + "ref" + ); + } + #[test] fn the_offer_names_the_agents_that_can_take_the_plugin() { let claude = Agent::ClaudeCode("claude".into()); diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index a405a1ee..ae9ed908 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2088,7 +2088,8 @@ fn setup_says_what_it_would_do_and_does_it_with_yes() { assert_eq!(out.status.code(), Some(1), "{}", all_output(&out)); assert_eq!( String::from_utf8_lossy(&out.stdout), - "Claude Code: failed: `claude plugin update elevenlabs@elevenlabs` exited with 1: install refused\n" + "Claude Code: failed: `claude plugin update elevenlabs@elevenlabs` exited with 1: install refused\n\ + To try again, run `elevenlabs setup --yes`.\n" ); // Only Cursor: its link, and nothing written. @@ -2107,6 +2108,59 @@ fn setup_says_what_it_would_do_and_does_it_with_yes() { assert_eq!(entries, [".cursor"], "nothing written"); } +#[cfg(unix)] +#[tokio::test] +async fn with_dry_run_setup_installs_nothing() { + let server = MockServer::start().await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + + let out = cli(home.path(), cwd.path(), &server.uri(), &["setup", "--yes", "--dry-run"]) + .env("PATH", &path) + .output() + .unwrap(); + assert!(out.status.success(), "{}", all_output(&out)); + assert!( + String::from_utf8_lossy(&out.stdout) + .ends_with("Nothing was changed. Run `elevenlabs setup --yes` to do it.\n"), + "{}", + all_output(&out) + ); + + let r = reply( + cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup", "--env-path", ".env.local", "--dry-run"]) + .env("PATH", &path), + ); + assert_eq!(r["next"], "done", "{r}"); + assert_eq!( + say(&r), + "Nothing was installed in the coding agents, because of --dry-run. To set them up, run \ + `elevenlabs setup --yes`." + ); + assert!(recorded(&record).is_empty(), "nothing ran"); + assert!(server.received_requests().await.unwrap().is_empty(), "no account request"); +} + +#[test] +fn onboard_setup_without_a_home_folder_says_so_and_ends() { + let (home, cwd) = temp(); + let r = reply( + cli(home.path(), cwd.path(), OFFLINE, &["onboard", "setup"]) + .env_remove("HOME") + .env_remove("USERPROFILE") + .env("PATH", "/usr/bin:/bin"), + ); + assert_eq!(r["next"], "done", "{r}"); + assert_eq!( + say(&r), + "ElevenLabs could not be set up in the coding agents: no home folder was found. To try again \ + later, run `elevenlabs setup --yes`." + ); +} + #[tokio::test] async fn the_sign_in_asks_for_the_setup_offer_and_keeps_the_pages_answer() { let _turn = signing_in().await; From cbad4953cda13aa1be6141c9fd0bc322e1e86c31 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 18:41:28 -0500 Subject: [PATCH 03/32] ci: run setup on Windows with an agent in a folder with parentheses --- .github/workflows/windows-smoke.yml | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index f6d8fb96..d8e0ff68 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -17,6 +17,7 @@ on: paths: - cli/elevenlabs/workflow/onboard/** - cli/elevenlabs/workflow/loopback.rs + - cli/elevenlabs/workflow/setup.rs - .github/workflows/windows-smoke.yml jobs: @@ -50,6 +51,46 @@ jobs: cargo build --bin elevenlabs cygpath -w "$PWD/target/debug" >> "$GITHUB_PATH" + # npm installs agents as .cmd shims, often under a user folder such as + # `C:\Users\Ann (Work)`, whose parentheses break a hand-built `cmd /C`. + - name: Set up a coding agent installed in a folder with parentheses + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + bin="$(mktemp -d)/agents (test) dir" + mkdir -p "$bin" + log=$(cygpath -w "$bin/ran.log") + # A stand-in `claude` that logs its arguments, one line per run. + cat > "$bin/claude.cmd" <>"$log" + exit /b 0 + EOF + export PATH="$bin:$PATH" + for var in USERPROFILE HOME CLAUDE_CONFIG_DIR CODEX_HOME; do + export "$var=$(cygpath -w "$(mktemp -d)")" + done + unset CLAUDE_CODE_EXECPATH + + elevenlabs setup --yes + # Quotes std may add around an argument, and cmd's line endings, aside. + ran=$(tr -d '"\r' < "$bin/ran.log") + expected=$(printf '%s\n' \ + "plugin marketplace update elevenlabs" \ + "plugin install elevenlabs --marketplace elevenlabs/plugin --scope user" \ + "plugin update elevenlabs@elevenlabs") + if [[ "$ran" != "$expected" ]]; then + echo "::error::Expected the three Claude Code commands, got:" + echo "$ran" + exit 1 + fi + + elevenlabs setup + if [[ "$(tr -d '"\r' < "$bin/ran.log")" != "$ran" ]]; then + echo "::error::setup without --yes ran a command" + exit 1 + fi + - name: Set up Node.js uses: actions/setup-node@v6 with: From 684674336fd133c320cb9ef9d15467b53895536f Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 18:58:08 -0500 Subject: [PATCH 04/32] fix: setup finds your agents from PowerShell and cmd, and Ctrl-C stops what it started On Windows the search path arrives as Path, so setup looked for PATH, found no agent and copied the skills instead of installing the plugin. Variable names now match ignoring case on Windows, and windows-smoke runs setup from PowerShell too. Ctrl-C or SIGTERM during an agent command now stops that command and everything it started, then exits with 130 or 143. A skill folder whose marker can't be written is removed rather than left unmarked. --- .github/workflows/windows-smoke.yml | 45 +++++++++ README.md | 2 +- cli/elevenlabs/workflow/setup.rs | 138 ++++++++++++++++++++++++++-- tests/onboard_test.rs | 74 +++++++++++++++ 4 files changed, 248 insertions(+), 11 deletions(-) diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index d8e0ff68..b2529e7a 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -91,6 +91,51 @@ jobs: exit 1 fi + # PowerShell and cmd pass the search path as `Path`; Git Bash passes `PATH`. + - name: Set up a coding agent from PowerShell + if: github.event_name == 'pull_request' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + function New-TempFolder { + New-Item -ItemType Directory (Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid())) + } + $bin = (New-TempFolder).FullName + $log = Join-Path $bin 'ran.log' + # A stand-in `claude` that logs its arguments, one line per run. + Set-Content -Encoding ascii -Path (Join-Path $bin 'claude.cmd') -Value @( + '@echo off' + "echo %*>>`"$log`"" + 'exit /b 0' + ) + # Spelled `Path` whatever the runner passed. + $searchPath = "$bin;$env:Path" + Remove-Item env:Path + [Environment]::SetEnvironmentVariable('Path', $searchPath) + foreach ($var in 'USERPROFILE', 'HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME') { + Set-Item "env:$var" (New-TempFolder).FullName + } + Remove-Item env:CLAUDE_CODE_EXECPATH -ErrorAction SilentlyContinue + $name = [Environment]::GetEnvironmentVariables().Keys | Where-Object { $_ -ieq 'path' } + Write-Host "The search path variable is spelled: $name" + if ($name -cne 'Path') { throw "Expected the search path variable spelled Path, got $name" } + + elevenlabs setup --yes + if ($LASTEXITCODE -ne 0) { throw "setup --yes exited with $LASTEXITCODE" } + if (-not (Test-Path $log)) { throw 'setup --yes ran no Claude Code command' } + # Quotes std may add around an argument aside. + $ran = @(Get-Content $log | ForEach-Object { $_.Replace('"', '').Trim() }) -join "`n" + $expected = @( + 'plugin marketplace update elevenlabs' + 'plugin install elevenlabs --marketplace elevenlabs/plugin --scope user' + 'plugin update elevenlabs@elevenlabs' + ) -join "`n" + if ($ran -cne $expected) { + Write-Host "::error::Expected the three Claude Code commands, got:" + Write-Host $ran + exit 1 + } + - name: Set up Node.js uses: actions/setup-node@v6 with: diff --git a/README.md b/README.md index f1129243..2d53065d 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,7 @@ elevenlabs setup --yes # do it - **Cursor** (a `~/.cursor` folder, or run inside Cursor) has no install command: `setup` prints the link to [its marketplace page](https://cursor.com/marketplace/elevenlabs), where you select Install. - **None of these**: `setup` copies the [ElevenLabs skills](https://github.com/elevenlabs/skills) into `~/.agents/skills`, one folder per skill. A folder there that `setup` didn't write is left as it is, and named in the output. -Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again. Agent commands run in your home folder, never in the project. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. +Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again. Agent commands run in your home folder, never in the project, and pressing Ctrl-C stops the one running along with anything it started. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. When onboarding through a coding agent (`npx -y @elevenlabs/cli@latest onboard init`), the flow can end by offering to do this for you; saying yes runs `onboard setup`, which does what `setup --yes` does. diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 9add81b4..59b19c12 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -7,6 +7,7 @@ //! `onboard setup` runs the same install when the developer accepts the offer //! at the end of onboarding. +use std::collections::HashMap; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -153,14 +154,22 @@ pub(super) fn home() -> Option { /// program this starts. fn shell_var(name: &str) -> Option { match super::onboard::shell_env() { - Some(env) => env - .get(OsStr::new(name)) - .and_then(|v| v.to_str()) - .map(str::to_string), + Some(env) => env_var(env, name, cfg!(windows)), None => std::env::var(name).ok(), } } +/// `name` in `env`. Windows ignores the case of a name: PowerShell and cmd +/// pass the search path as `Path`. +fn env_var(env: &HashMap, name: &str, windows: bool) -> Option { + let value = match env.get(OsStr::new(name)) { + Some(value) => value, + None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name))?.1, + None => return None, + }; + value.to_str().map(str::to_string) +} + /// The first `name` on `path` that is a program (on Windows, with one of /// the extensions npm and installers use). pub(super) fn on_path(name: &str, path: Option<&OsStr>, windows: bool) -> Option { @@ -215,11 +224,21 @@ pub(super) fn run_program( command.current_dir(home).env("PWD", home); #[cfg(unix)] std::os::unix::process::CommandExt::process_group(&mut command, 0); + // In its own group it gets no Ctrl-C from the terminal: one sent here stops it. + #[cfg(unix)] + let interrupt = interrupt::Catch::start(); let mut child = command .spawn() .map_err(|e| format!("couldn't start it: {e}"))?; let deadline = Instant::now() + limit; let status = loop { + #[cfg(unix)] + if let Some(signal) = interrupt.caught() { + stop_all(&mut child); + let _ = child.wait(); + drop(interrupt); + std::process::exit(128 + signal); + } match child.try_wait() { Ok(Some(status)) => break status, Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), @@ -264,6 +283,62 @@ fn stop_all(child: &mut std::process::Child) { let _ = child.kill(); } +/// Ctrl-C and SIGTERM, caught while an agent command runs, so `run_program` +/// can stop it before exiting as an interrupted program does. +#[cfg(unix)] +mod interrupt { + use std::sync::atomic::{AtomicI32, Ordering}; + use std::sync::Mutex; + + const SIGNALS: [libc::c_int; 2] = [libc::SIGINT, libc::SIGTERM]; + + static CAUGHT: AtomicI32 = AtomicI32::new(0); + /// How many commands are waiting, and the handlers from before the first. + static WAITING: Mutex<(usize, [libc::sighandler_t; 2])> = Mutex::new((0, [0; 2])); + + extern "C" fn note(signal: libc::c_int) { + CAUGHT.store(signal, Ordering::SeqCst); + } + + /// Catches the signals until dropped, which puts the earlier handlers back. + pub struct Catch; + + impl Catch { + pub fn start() -> Catch { + let mut waiting = WAITING.lock().unwrap_or_else(|e| e.into_inner()); + if waiting.0 == 0 { + CAUGHT.store(0, Ordering::SeqCst); + for (signal, previous) in SIGNALS.iter().zip(waiting.1.iter_mut()) { + let handler = note as extern "C" fn(libc::c_int) as libc::sighandler_t; + *previous = unsafe { libc::signal(*signal, handler) }; + // A signal the parent chose to ignore stays ignored. + if *previous == libc::SIG_IGN { + unsafe { libc::signal(*signal, libc::SIG_IGN) }; + } + } + } + waiting.0 += 1; + Catch + } + + pub fn caught(&self) -> Option { + Some(CAUGHT.load(Ordering::SeqCst)).filter(|&s| s != 0) + } + } + + impl Drop for Catch { + fn drop(&mut self) { + let mut waiting = WAITING.lock().unwrap_or_else(|e| e.into_inner()); + waiting.0 -= 1; + if waiting.0 == 0 { + for (signal, previous) in SIGNALS.iter().zip(waiting.1) { + unsafe { libc::signal(*signal, previous) }; + } + } + } + } +} + fn seconds(d: Duration) -> String { if d >= Duration::from_secs(1) { format!("{} seconds", d.as_secs()) @@ -552,11 +627,15 @@ pub(super) fn copy_skills(unpacked: &Path, home: &Path) -> Result = [("Path", "C:\\bin"), ("HOME", ""), ("Cursor_Agent", "1")] + .iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + assert_eq!(env_var(&env, "PATH", true), Some("C:\\bin".into())); + assert_eq!(env_var(&env, "PATH", false), None); + assert_eq!(env_var(&env, "CURSOR_AGENT", true), Some("1".into())); + assert_eq!(env_var(&env, "CURSOR_AGENT", false), None); + for windows in [true, false] { + assert_eq!(env_var(&env, "Path", windows), Some("C:\\bin".into())); + assert_eq!(env_var(&env, "HOME", windows), Some(String::new()), "empty, as set"); + assert_eq!(env_var(&env, "USERPROFILE", windows), None); + } + let both: HashMap = [("Path", "lower"), ("PATH", "exact")] + .iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + assert_eq!(env_var(&both, "PATH", true), Some("exact".into())); + } + #[test] fn the_home_folder_is_userprofile_on_windows_and_home_elsewhere() { let env = env_of(&[("HOME", "/home/dev"), ("USERPROFILE", "C:\\Users\\dev")]); @@ -983,6 +1084,17 @@ mod tests { ); } + /// Kills the process when the test ends, passed or not. + #[cfg(unix)] + struct KillOnDrop(i32); + + #[cfg(unix)] + impl Drop for KillOnDrop { + fn drop(&mut self) { + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } + } + #[cfg(unix)] #[test] fn a_command_past_its_limit_is_stopped_with_everything_it_started() { @@ -994,9 +1106,11 @@ mod tests { &format!("sleep 300 &\necho $! > '{}'\nwait", pid.display()), ); // Long enough for the stand-in to start its child on a busy machine. - let err = run_program(&hangs, &[], Duration::from_secs(2), dir.path()).unwrap_err(); - assert!(err.contains("ran out of time"), "{err}"); + let result = run_program(&hangs, &[], Duration::from_secs(2), dir.path()); let pid: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); + let _cleanup = KillOnDrop(pid); + let err = result.unwrap_err(); + assert!(err.contains("ran out of time"), "{err}"); let deadline = Instant::now() + Duration::from_secs(5); while unsafe { libc::kill(pid, 0) } == 0 { assert!(Instant::now() < deadline, "sleep {pid} still runs"); @@ -1081,6 +1195,10 @@ mod tests { #[test] fn a_copy_that_stops_partway_is_replaced_next_time() { use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + // Root reads a file whatever its mode, so the copy can't be made to stop. + return; + } let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); skill(unpacked.path(), "music", "new music"); let unreadable = unpacked.path().join("music").join("references").join("a.md"); diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index ae9ed908..54bd902c 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2108,6 +2108,80 @@ fn setup_says_what_it_would_do_and_does_it_with_yes() { assert_eq!(entries, [".cursor"], "nothing written"); } +/// Kills the process when the test ends, passed or not. +#[cfg(unix)] +struct KillOnDrop(i32); + +#[cfg(unix)] +impl Drop for KillOnDrop { + fn drop(&mut self) { + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } +} + +/// Stops the child when the test ends, unless it has already exited. +#[cfg(unix)] +struct StopOnDrop(std::process::Child); + +#[cfg(unix)] +impl Drop for StopOnDrop { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(unix)] +#[test] +fn ctrl_c_stops_the_agent_command_and_what_it_started() { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::process::CommandExt; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let pid = bin.path().join("pid"); + let claude = bin.path().join("claude"); + std::fs::write( + &claude, + format!("#!/bin/sh\nsleep 300 &\necho $! > '{0}.new'\nmv '{0}.new' '{0}'\nwait\n", pid.display()), + ) + .unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut cmd = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]); + cmd.env("PATH", format!("{}:/usr/bin:/bin", bin.path().display())); + // SIGINT at its default, as from a terminal, even where these tests run with it ignored. + unsafe { + cmd.pre_exec(|| { + libc::signal(libc::SIGINT, libc::SIG_DFL); + Ok(()) + }); + } + let mut setup = StopOnDrop(cmd.spawn().unwrap()); + let setup = &mut setup.0; + + let deadline = Instant::now() + Duration::from_secs(20); + while !pid.exists() { + assert!(Instant::now() < deadline, "the stand-in never started"); + std::thread::sleep(Duration::from_millis(20)); + } + let sleep: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); + let _sleep = KillOnDrop(sleep); + + unsafe { libc::kill(setup.id() as i32, libc::SIGINT) }; + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = setup.try_wait().unwrap() { + break status; + } + assert!(Instant::now() < deadline, "setup still runs after Ctrl-C"); + std::thread::sleep(Duration::from_millis(20)); + }; + assert_eq!(status.code(), Some(130), "{status:?}"); + while unsafe { libc::kill(sleep, 0) } == 0 { + assert!(Instant::now() < deadline, "sleep {sleep} still runs"); + std::thread::sleep(Duration::from_millis(20)); + } +} + #[cfg(unix)] #[tokio::test] async fn with_dry_run_setup_installs_nothing() { From dc5d2aafda5173edfb22634b44c33eb8b9d3514f Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 19:24:01 -0500 Subject: [PATCH 05/32] feat: onboard offers setup after every sign-in The offer to set up the coding agents no longer waits for anything from the approval page: every sign-in that works leaves it due, and the flow makes it once, at the end, as before. The sign-in link and the callback handling are back to what they were. --- cli/elevenlabs/workflow/loopback.rs | 61 +++++----------------- cli/elevenlabs/workflow/onboard/auth.rs | 9 ++-- cli/elevenlabs/workflow/onboard/connect.rs | 38 ++++++-------- cli/elevenlabs/workflow/onboard/mod.rs | 7 +-- cli/elevenlabs/workflow/onboard/session.rs | 6 +-- cli/elevenlabs/workflow/onboard/tests.rs | 2 +- tests/onboard_test.rs | 30 +++++------ 7 files changed, 55 insertions(+), 98 deletions(-) diff --git a/cli/elevenlabs/workflow/loopback.rs b/cli/elevenlabs/workflow/loopback.rs index e53ed5a1..07a73315 100644 --- a/cli/elevenlabs/workflow/loopback.rs +++ b/cli/elevenlabs/workflow/loopback.rs @@ -66,9 +66,8 @@ pub(super) struct Login<'a> { } /// Sign in through the browser and store the token where `auth login` does. -/// Returns the access token, which works even when the store refused it, and -/// whether the callback asked the CLI to offer `setup` (`setup_offer=1`). -pub(super) async fn sign_in(login: Login<'_>) -> Result<(String, bool), CliError> { +/// Returns the access token, which works even when the store refused it. +pub(super) async fn sign_in(login: Login<'_>) -> Result { let verifier = random_token(); let state = random_token(); let (listeners, port) = bind(login.redirect_ports).await?; @@ -79,7 +78,7 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result<(String, bool), CliError if !login.no_browser { let _ = webbrowser::open(&url); } - let (code, setup_offer, mut browser) = + let (code, mut browser) = wait_for_callback(listeners, &state, login.error_url, login.timeout).await?; // The browser waits on the result page until the code is exchanged. let token = exchange( @@ -103,7 +102,7 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result<(String, bool), CliError eprintln!("Signed in, but the sign-in couldn't be saved for later commands: {e}"); } let _ = redirect(&mut browser, login.success_url).await; - Ok((token.access_token, setup_offer)) + Ok(token.access_token) } Err(failure) => { // Its own OAuth code, so the analytics can tell these apart. @@ -178,8 +177,7 @@ pub(super) enum Callback { Other, /// A callback this sign-in can't use: another sign-in's `state`, or none. NotOurs, - /// The code, and whether the page asked the CLI to offer `setup`. - Code { code: String, setup_offer: bool }, + Code(String), /// The page's error, e.g. `access_denied` when the developer clicked Deny. Error { error: String, @@ -194,7 +192,6 @@ pub(super) fn classify(target: &str, state: &str) -> Callback { return Callback::Other; } let (mut code, mut got_state, mut error, mut description) = (None, None, None, None); - let mut setup_offer = false; for (key, value) in form_urlencoded::parse(query.as_bytes()) { let value = Some(value.into_owned()); match key.as_ref() { @@ -202,14 +199,13 @@ pub(super) fn classify(target: &str, state: &str) -> Callback { "state" => got_state = value, "error" => error = value, "error_description" => description = value, - "setup_offer" => setup_offer = value.as_deref() == Some("1"), _ => {} } } match (got_state, code, error) { (Some(got), _, _) if got != state => Callback::NotOurs, (Some(_), _, Some(error)) => Callback::Error { error, description }, - (Some(_), Some(code), None) => Callback::Code { code, setup_offer }, + (Some(_), Some(code), None) => Callback::Code(code), // Without a `state`, nothing from ElevenLabs: the page's Deny and the // server's error redirects always carry it, so any page could have // sent this, to end the sign-in or to put words in the agent's reply. @@ -217,15 +213,14 @@ pub(super) fn classify(target: &str, state: &str) -> Callback { } } -/// Wait for this sign-in's callback. A code comes back, with whether the page -/// asked for the `setup` offer, and with the browser's connection still open, -/// so the browser is answered once the login is done. +/// Wait for this sign-in's callback. A code comes back with the browser's +/// connection still open, so the browser is answered once the login is done. pub(super) async fn wait_for_callback( listeners: Vec, state: &str, error_url: &str, timeout: Duration, -) -> Result<(String, bool, TcpStream), CliError> { +) -> Result<(String, TcpStream), CliError> { let (tx, mut rx) = mpsc::channel::<(String, TcpStream)>(16); // Dropped on return, which stops every listener. let mut accepting = JoinSet::new(); @@ -265,7 +260,7 @@ pub(super) async fn wait_for_callback( let page = error_page(error_url, "invalid_request", Some(NOT_THIS_SIGN_IN)); let _ = redirect(&mut socket, &page).await; } - Callback::Code { code, setup_offer } => return Ok((code, setup_offer, socket)), + Callback::Code(code) => return Ok((code, socket)), Callback::Error { error, description } => { let page = error_page(error_url, &error, description.as_deref()); let _ = redirect(&mut socket, &page).await; @@ -552,10 +547,7 @@ mod tests { let ours = "s1"; assert_eq!( classify("/callback?code=c&state=s1", ours), - Callback::Code { - code: "c".into(), - setup_offer: false - } + Callback::Code("c".into()) ); assert_eq!( classify( @@ -583,35 +575,6 @@ mod tests { } } - #[test] - fn a_callback_asks_for_the_setup_offer_only_with_setup_offer_1() { - let code = |setup_offer| Callback::Code { - code: "c".into(), - setup_offer, - }; - assert_eq!( - classify("/callback?code=c&state=s1&setup_offer=1", "s1"), - code(true) - ); - assert_eq!( - classify("/callback?setup_offer=1&code=c&state=s1", "s1"), - code(true) - ); - for without in [ - "/callback?code=c&state=s1", - "/callback?code=c&state=s1&setup_offer=0", - "/callback?code=c&state=s1&setup_offer=true", - "/callback?code=c&state=s1&setup_offer=", - ] { - assert_eq!(classify(without, "s1"), code(false), "{without}"); - } - // It changes nothing about whose callback it is. - assert_eq!( - classify("/callback?code=c&state=s0&setup_offer=1", "s1"), - Callback::NotOurs - ); - } - #[tokio::test(flavor = "multi_thread")] async fn the_listener_takes_both_loopback_addresses_or_moves_on() { if no_ipv6() { @@ -670,7 +633,7 @@ mod tests { let this_tab = request("::1", port, "/callback?code=new&state=current"); (favicon, old_tab, this_tab) }); - let (code, _, mut socket) = + let (code, mut socket) = wait_for_callback(listeners, "current", ERROR_PAGE, Duration::from_secs(3)) .await .unwrap(); diff --git a/cli/elevenlabs/workflow/onboard/auth.rs b/cli/elevenlabs/workflow/onboard/auth.rs index 18faa16a..f3932e46 100644 --- a/cli/elevenlabs/workflow/onboard/auth.rs +++ b/cli/elevenlabs/workflow/onboard/auth.rs @@ -89,13 +89,13 @@ fn parse_sign_in(main_rs: &str) -> Result { /// Sign in through the browser, asking the page for a key. `previous` is how /// the last sign-in for this project ended, for the page to record. Returns -/// the access token, and whether the page asked for the `setup` offer. +/// the access token. pub(super) fn sign_in( ctx: &AppContext, authorize_url: &str, no_browser: bool, previous: Option<&LastSignIn>, -) -> Result<(String, bool), CliError> { +) -> Result { let config = sign_in_config()?; // A local --base-url also moves the token endpoint, for testing against a local backend. let oauth_base = ctx @@ -104,14 +104,11 @@ pub(super) fn sign_in( .unwrap_or_else(|| config.token_url.trim_end_matches("/v1/oauth/token").into()); let token_url = format!("{oauth_base}/v1/oauth/token"); let on = telemetry::enabled(); - let mut params = authorize_params( + let params = authorize_params( on.then(|| (platform::detect(), platform::install_source())), on.then(platform::agent).flatten(), previous.filter(|_| on), ); - // Whatever telemetry says: it tells the page this CLI can offer `setup` - // at the end of the flow, and the page answers on the callback. - params.push(("setup_offer", "1".into())); let params: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); block_on(loopback::sign_in(loopback::Login { cli_name: CLI_NAME, diff --git a/cli/elevenlabs/workflow/onboard/connect.rs b/cli/elevenlabs/workflow/onboard/connect.rs index ba3475e3..dd076d9a 100644 --- a/cli/elevenlabs/workflow/onboard/connect.rs +++ b/cli/elevenlabs/workflow/onboard/connect.rs @@ -51,11 +51,9 @@ pub(super) fn handle( "onboard.connect", super::sign_in_tag(previous.as_ref()), ); - let reply = sign_in_and_store(matches, ctx, &project, previous.as_ref(), |setup_offer| { + let reply = sign_in_and_store(matches, ctx, &project, previous.as_ref(), || { session.signed_in(&lock); - if setup_offer { - session.offer_setup(); - } + session.offer_setup(); }) .unwrap_or_else(|e| failed(&project, &e.to_string(), replace)); session.finish(&lock, &reply.to_value())?; @@ -246,30 +244,28 @@ pub(super) fn outcome_of( } /// The worker: sign in, then move the key from the clipboard into the env file. -/// `on_signed_in` gets whether the page asked for the `setup` offer. fn sign_in_and_store( matches: &clap::ArgMatches, ctx: &AppContext, project: &Project, previous: Option<&LastSignIn>, - on_signed_in: impl FnOnce(bool), + on_signed_in: impl FnOnce(), ) -> Result { let client = ctx.http_config().build_client()?; - let (token, setup_offer) = - match auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser, previous) { - Ok(signed_in) => signed_in, - Err(error) => { - // The page may have copied a key before the callback failed; nothing - // can vouch for it now, so it is cleared, not stored. - Clipboard::open().discard_key(); - return Ok(failed( - project, - &error.to_string(), - matches.get_flag("replace"), - )); - } - }; - on_signed_in(setup_offer); + let token = match auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser, previous) { + Ok(token) => token, + Err(error) => { + // The page may have copied a key before the callback failed; nothing + // can vouch for it now, so it is cleared, not stored. + Clipboard::open().discard_key(); + return Ok(failed( + project, + &error.to_string(), + matches.get_flag("replace"), + )); + } + }; + on_signed_in(); let mut clipboard = Clipboard::open(); let clip = clipboard.read_handoff(); let copied = match &clip { diff --git a/cli/elevenlabs/workflow/onboard/mod.rs b/cli/elevenlabs/workflow/onboard/mod.rs index 8f25c70a..683cf96b 100644 --- a/cli/elevenlabs/workflow/onboard/mod.rs +++ b/cli/elevenlabs/workflow/onboard/mod.rs @@ -113,9 +113,10 @@ fn setup(matches: &clap::ArgMatches, ctx: &AppContext) -> Result reply::Reply { let Ok(session) = session::Session::for_project(&project.dir) else { return done; diff --git a/cli/elevenlabs/workflow/onboard/session.rs b/cli/elevenlabs/workflow/onboard/session.rs index e4a44060..6f79f69c 100644 --- a/cli/elevenlabs/workflow/onboard/session.rs +++ b/cli/elevenlabs/workflow/onboard/session.rs @@ -46,8 +46,8 @@ pub(super) const LAUNCH_FLAG: &str = "launch-worker"; const REPORT_FOR: Duration = Duration::from_secs(24 * 60 * 60); /// Where [`Session::start`] keeps how the sign-in before the new one ended. const PREVIOUS: &str = "previous.json"; -/// Left by a sign-in whose approval page asked the CLI to offer `setup` at -/// the end of the flow; removed when the offer is made. +/// Left by every sign-in, so the flow ends on the offer to set up the +/// developer's coding agents; removed when the offer is made. const SETUP_OFFER: &str = "setup-offer"; /// What a new sign-in clears of the last one's. const STALE: &[&str] = &[ @@ -309,7 +309,7 @@ impl Session { let _ = std::fs::write(self.path("signed-in"), ""); } - /// The approval page asked for the `setup` offer at the end of the flow. + /// The sign-in worked: the flow can end on the `setup` offer. pub fn offer_setup(&self) { let _ = std::fs::write(self.path(SETUP_OFFER), ""); } diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index 89ff94d0..661afa60 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -1715,7 +1715,7 @@ fn a_new_sign_in_keeps_how_the_last_one_ended() { } #[test] -fn a_sign_in_that_asked_for_the_setup_offer_leaves_it_due_until_made() { +fn the_setup_offer_a_sign_in_leaves_is_due_until_made_and_a_new_sign_in_clears_it() { use super::session::Session; let dir = tempfile::tempdir().unwrap(); let s = Session::in_dir(dir.path().to_path_buf()); diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 54bd902c..70299a39 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -1748,7 +1748,7 @@ fn session_dir(home: &Path, cwd: &Path) -> PathBuf { home.join(".elevenlabs").join("onboard").join(id) } -/// What a sign-in whose page asked for the `setup` offer leaves. +/// What a sign-in leaves: the `setup` offer is due. fn setup_offer_due(home: &Path, cwd: &Path) -> PathBuf { let dir = session_dir(home, cwd); std::fs::create_dir_all(&dir).unwrap(); @@ -1800,7 +1800,7 @@ const PLUGIN_QUESTION: &str = "Want me to set ElevenLabs up in {} too? It instal #[cfg(unix)] #[tokio::test] -async fn a_test_request_that_worked_offers_setup_once_when_the_sign_in_asked() { +async fn a_test_request_that_worked_offers_setup_once_after_a_sign_in() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path(format!("/v1/text-to-speech/{VOICE}"))) @@ -1827,7 +1827,7 @@ async fn a_test_request_that_worked_offers_setup_once_when_the_sign_in_asked() { }; let plain = test(&server.uri()); - assert_eq!(plain["next"], "done", "no offer without the sign-in asking: {plain}"); + assert_eq!(plain["next"], "done", "no offer without a sign-in: {plain}"); let offer = setup_offer_due(home.path(), cwd.path()); // A test request that failed ends as it always has, and the offer waits. @@ -2236,7 +2236,7 @@ fn onboard_setup_without_a_home_folder_says_so_and_ends() { } #[tokio::test] -async fn the_sign_in_asks_for_the_setup_offer_and_keeps_the_pages_answer() { +async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones() { let _turn = signing_in().await; if callback_ports_busy() { eprintln!("skipped: another sign-in is listening on the callback ports"); @@ -2254,8 +2254,8 @@ async fn the_sign_in_asks_for_the_setup_offer_and_keeps_the_pages_answer() { .await; let (home, cwd) = temp(); let authorize = format!("{}/app/oauth/authorize", server.uri()); - // Signs in with telemetry off, and the page's callback carrying `params`. - let sign_in = |params: &str| { + // Signs in with telemetry off, the page answering the callback with `params`. + let sign_in = |params: &str, key_status: &str| { let first = reply(&mut cli( home.path(), cwd.path(), @@ -2263,22 +2263,22 @@ async fn the_sign_in_asks_for_the_setup_offer_and_keeps_the_pages_answer() { &["onboard", "connect", "--no-browser", "--authorize-url", &authorize, "--telemetry", "off"], )); let url = link(&first); - assert_eq!(query(&url, "setup_offer"), "1", "{url}"); - callback(&url, &format!("code=approved{params}")); + assert!(!url.contains("setup_offer"), "the link is the base commit's: {url}"); + callback(&url, params); let deadline = Instant::now() + Duration::from_secs(60); loop { let r = connect(home.path(), cwd.path(), &["--wait", "--no-browser"]); if r["next"] != "wait" || Instant::now() >= deadline { - assert_eq!(r["details"]["key_status"], "no_key", "{r}"); + assert_eq!(r["details"]["key_status"], key_status, "{r}"); break; } } }; let offer = session_dir(home.path(), cwd.path()).join("setup-offer"); - sign_in("&setup_offer=1"); - assert!(offer.exists(), "the page asked for the offer"); - sign_in(""); - assert!(!offer.exists(), "a new sign-in without it clears the last one's"); - sign_in("&setup_offer=yes"); - assert!(!offer.exists()); + sign_in("code=approved", "no_key"); + assert!(offer.exists(), "a sign-in that worked leaves the offer"); + sign_in("error=access_denied&error_description=declined", "sign_in_failed"); + assert!(!offer.exists(), "a new sign-in clears the last one's, and a declined one leaves none"); + sign_in("code=approved", "no_key"); + assert!(offer.exists(), "the next sign-in that works leaves it again"); } From 8b9dfc0114f63b5535450c20406260f166480824 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 19:31:59 -0500 Subject: [PATCH 06/32] fix: setup never runs a program from a relative PATH entry, and fails when the skills download holds none An empty or relative entry on PATH names a folder in the project, so a claude or codex there could be run by setup. Those entries are now skipped. A skills download with no skill in it used to report "copied 0 ElevenLabs skills" as a success; it now fails and says so. The skills download takes its URL as a parameter, so tests serve archives from a local server. --- cli/elevenlabs/workflow/setup.rs | 599 +---------------- cli/elevenlabs/workflow/setup/tests.rs | 866 +++++++++++++++++++++++++ 2 files changed, 886 insertions(+), 579 deletions(-) create mode 100644 cli/elevenlabs/workflow/setup/tests.rs diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 59b19c12..337fda49 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -171,7 +171,8 @@ fn env_var(env: &HashMap, name: &str, windows: bool) -> Opti } /// The first `name` on `path` that is a program (on Windows, with one of -/// the extensions npm and installers use). +/// the extensions npm and installers use). An empty or relative entry is +/// skipped: it names a folder in the project, whose programs are never run. pub(super) fn on_path(name: &str, path: Option<&OsStr>, windows: bool) -> Option { let names: Vec = if windows { [".exe", ".cmd", ".bat"] @@ -182,6 +183,7 @@ pub(super) fn on_path(name: &str, path: Option<&OsStr>, windows: bool) -> Option vec![name.to_string()] }; std::env::split_paths(path?) + .filter(|dir| dir.is_absolute()) .flat_map(|dir| names.iter().map(move |n| dir.join(n)).collect::>()) .find(|p| if windows { p.is_file() } else { is_program(p) }) } @@ -523,9 +525,19 @@ fn skills_root(home: &Path) -> PathBuf { /// Download the skills and copy them into `~/.agents/skills`. fn skills(home: &Path) -> Outcome { - let result = download_skills().and_then(|dir| copy_skills(&dir.path().join("skills"), home)); + skills_from(SKILLS_ARCHIVE, home) +} + +/// [`skills`], downloading the archive from `url`. +fn skills_from(url: &str, home: &Path) -> Outcome { + let result = download_skills(url).and_then(|dir| copy_skills(&dir.path().join("skills"), home)); let root = skills_root(home); match result { + Ok(copied) if copied.written.is_empty() && copied.kept.is_empty() => Outcome { + agent: "Skills", + ok: false, + message: format!("the download from {url} held no skills"), + }, Ok(copied) => { let mut message = format!( "copied {} ElevenLabs skills into {}", @@ -552,9 +564,9 @@ fn skills(home: &Path) -> Outcome { } } -/// A new temp folder, removed when dropped, with the skills archive unpacked -/// into its `skills` folder. -fn download_skills() -> Result { +/// A new temp folder, removed when dropped, with the skills archive from +/// `url` unpacked into its `skills` folder. +fn download_skills(url: &str) -> Result { let dir = tempfile::tempdir().map_err(|e| format!("couldn't make a temp folder: {e}"))?; let archive = dir.path().join("skills.tar.gz"); let unpacked = dir.path().join("skills"); @@ -568,13 +580,13 @@ fn download_skills() -> Result { .map_err(|e| e.to_string())?; let body = super::say::run_async(async { let response = client - .get(SKILLS_ARCHIVE) + .get(url) .send() .await? .error_for_status()?; response.bytes().await }) - .map_err(|e| format!("couldn't download the skills from {SKILLS_ARCHIVE}: {e}"))?; + .map_err(|e| format!("couldn't download the skills from {url}: {e}"))?; std::fs::write(&archive, &body).map_err(|e| e.to_string())?; let out = Command::new("tar") .arg("-xzf") @@ -704,575 +716,4 @@ pub fn register(app: CliApp) -> CliApp { } #[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::HashMap; - - use super::*; - - fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option { - let vars: HashMap = vars - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - move |name| vars.get(name).cloned() - } - - fn path_with(programs: &[&str]) -> impl Fn(&str) -> Option { - let programs: Vec = programs.iter().map(|p| p.to_string()).collect(); - move |name| { - programs - .iter() - .any(|p| p == name) - .then(|| PathBuf::from(format!("/bin/{name}"))) - } - } - - #[test] - fn agents_are_found_by_program_variable_and_folder() { - let home = tempfile::tempdir().unwrap(); - let none = env_of(&[]); - assert_eq!(found(&none, &path_with(&[]), home.path()), vec![]); - assert_eq!( - found(&none, &path_with(&["claude", "codex"]), home.path()), - vec![ - Agent::ClaudeCode("/bin/claude".into()), - Agent::Codex("/bin/codex".into()) - ] - ); - assert_eq!( - found(&none, &path_with(&["codex"]), home.path()), - vec![Agent::Codex("/bin/codex".into())] - ); - for var in ["CURSOR_AGENT", "CURSOR_TRACE_ID"] { - assert_eq!( - found(&env_of(&[(var, "1")]), &path_with(&[]), home.path()), - vec![Agent::Cursor], - "{var}" - ); - } - assert_eq!( - found( - &env_of(&[("CURSOR_AGENT", "")]), - &path_with(&[]), - home.path() - ), - vec![] - ); - std::fs::create_dir(home.path().join(".cursor")).unwrap(); - assert_eq!( - found(&none, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor] - ); - } - - #[test] - fn claude_code_is_the_program_it_names_when_that_is_claude() { - let home = tempfile::tempdir().unwrap(); - for name in ["claude", "Claude.exe", "CLAUDE"] { - let dir = tempfile::tempdir().unwrap(); - let program = dir.path().join(name); - std::fs::write(&program, "").unwrap(); - let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); - assert_eq!( - found(&inside, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode(program.clone())], - "{name}" - ); - assert_eq!( - found(&inside, &path_with(&[]), home.path()), - vec![Agent::ClaudeCode(program)], - "{name}" - ); - } - let gone = env_of(&[("CLAUDE_CODE_EXECPATH", "/nowhere/claude")]); - assert_eq!(found(&gone, &path_with(&[]), home.path()), vec![]); - assert_eq!( - found(&gone, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode("/bin/claude".into())] - ); - } - - /// Claude Code installed by npm names the runtime that runs it, which - /// would load the project's own `plugin.js` given `plugin install`. - #[test] - fn a_runtime_named_by_claude_code_is_never_started() { - let home = tempfile::tempdir().unwrap(); - for name in ["node", "bun", "node.exe", "claude-desktop", "claude.cmd"] { - let dir = tempfile::tempdir().unwrap(); - let program = dir.path().join(name); - std::fs::write(&program, "").unwrap(); - let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); - assert_eq!( - found(&inside, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode("/bin/claude".into())], - "{name}: claude on PATH instead" - ); - assert_eq!( - found(&inside, &path_with(&[]), home.path()), - vec![], - "{name}" - ); - } - } - - #[cfg(unix)] - #[test] - fn a_program_on_path_is_an_executable_file() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - let empty = tempfile::tempdir().unwrap(); - let program = dir.path().join("codex"); - std::fs::write(&program, "").unwrap(); - let path = std::env::join_paths([empty.path(), dir.path()]).unwrap(); - assert_eq!(on_path("codex", Some(&path), false), None, "not executable"); - std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert_eq!(on_path("codex", Some(&path), false), Some(program)); - assert_eq!(on_path("claude", Some(&path), false), None); - assert_eq!(on_path("codex", None, false), None); - } - - #[test] - fn on_windows_a_program_on_path_is_an_exe_or_a_cmd_shim() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("codex.cmd"), "").unwrap(); - let path = std::env::join_paths([dir.path()]).unwrap(); - assert_eq!( - on_path("codex", Some(&path), true), - Some(dir.path().join("codex.cmd")) - ); - } - - #[test] - fn on_windows_a_variable_is_found_whatever_the_case_of_its_name() { - let env: HashMap = [("Path", "C:\\bin"), ("HOME", ""), ("Cursor_Agent", "1")] - .iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(); - assert_eq!(env_var(&env, "PATH", true), Some("C:\\bin".into())); - assert_eq!(env_var(&env, "PATH", false), None); - assert_eq!(env_var(&env, "CURSOR_AGENT", true), Some("1".into())); - assert_eq!(env_var(&env, "CURSOR_AGENT", false), None); - for windows in [true, false] { - assert_eq!(env_var(&env, "Path", windows), Some("C:\\bin".into())); - assert_eq!(env_var(&env, "HOME", windows), Some(String::new()), "empty, as set"); - assert_eq!(env_var(&env, "USERPROFILE", windows), None); - } - let both: HashMap = [("Path", "lower"), ("PATH", "exact")] - .iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(); - assert_eq!(env_var(&both, "PATH", true), Some("exact".into())); - } - - #[test] - fn the_home_folder_is_userprofile_on_windows_and_home_elsewhere() { - let env = env_of(&[("HOME", "/home/dev"), ("USERPROFILE", "C:\\Users\\dev")]); - assert_eq!(home_for(false, &env), Some(PathBuf::from("/home/dev"))); - assert_eq!(home_for(true, &env), Some(PathBuf::from("C:\\Users\\dev"))); - assert_eq!(home_for(true, &env_of(&[("HOME", "/home/dev")])), None); - assert_eq!(home_for(false, &env_of(&[("HOME", "")])), None); - } - - const LIMITS_FOR_TESTS: Limits = Limits { - command: Duration::from_secs(30), - total: Duration::from_secs(100), - }; - - /// Runs nothing; records each command and fails the ones `fails` names. - struct Recorder { - ran: RefCell>, - fails: Vec<&'static str>, - } - - impl Recorder { - fn new(fails: &[&'static str]) -> Self { - Self { - ran: RefCell::new(Vec::new()), - fails: fails.to_vec(), - } - } - - fn run(&self, program: &Path, args: &[&str], limit: Duration) -> Result<(), String> { - assert!(limit <= Duration::from_secs(30)); - let line = format!("{} {}", program.display(), args.join(" ")); - self.ran.borrow_mut().push(line.clone()); - match self.fails.iter().any(|f| line.contains(f)) { - true => Err("exited with 1: boom".into()), - false => Ok(()), - } - } - - fn ran(&self) -> Vec { - self.ran.borrow().clone() - } - } - - fn all_agents() -> Vec { - vec![ - Agent::ClaudeCode("claude".into()), - Agent::Codex("codex".into()), - Agent::Cursor, - ] - } - - #[test] - fn install_runs_each_agents_own_commands_in_order() { - let recorder = Recorder::new(&[]); - let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { - recorder.run(p, a, l) - }); - assert_eq!( - recorder.ran(), - [ - "claude plugin marketplace update elevenlabs", - "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", - "claude plugin update elevenlabs@elevenlabs", - "codex plugin marketplace add elevenlabs/plugin", - "codex plugin marketplace upgrade elevenlabs", - "codex plugin add elevenlabs@elevenlabs", - ] - ); - assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); - assert_eq!( - outcomes.iter().map(result_line).collect::>(), - [ - "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)", - "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)", - "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", - ] - ); - assert_eq!( - outcomes.iter().filter_map(finish).collect::>(), - [ - "restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server", - "restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", - ] - ); - } - - #[test] - fn only_the_install_commands_decide_an_agents_result() { - let refresh_fails = Recorder::new(&["marketplace update", "marketplace upgrade"]); - let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { - refresh_fails.run(p, a, l) - }); - assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); - assert_eq!(refresh_fails.ran().len(), 6); - - for (fails, agent) in [ - ("plugin install", "Claude Code"), - ("plugin update", "Claude Code"), - ("marketplace add", "Codex"), - ("plugin add", "Codex"), - ] { - let recorder = Recorder::new(&[fails]); - let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { - recorder.run(p, a, l) - }); - let failed: Vec<_> = outcomes.iter().filter(|o| !o.ok).collect(); - assert_eq!(failed.len(), 1, "{fails}: {outcomes:?}"); - assert_eq!(failed[0].agent, agent); - assert!(failed[0].message.contains(fails), "{failed:?}"); - assert!( - result_line(failed[0]).starts_with(&format!("{agent}: failed: `")) - && result_line(failed[0]).ends_with("` exited with 1: boom"), - "{}", - result_line(failed[0]) - ); - assert_eq!( - finish(failed[0]), - None, - "no finishing steps for a failed agent" - ); - } - } - - #[test] - fn agents_not_reached_in_time_count_as_failed() { - let recorder = Recorder::new(&[]); - // Each command takes 30 s of a 50 s run, without waiting for it. - let used = std::cell::Cell::new(Duration::ZERO); - let slow = |p: &Path, a: &[&str], l: Duration| { - used.set(used.get() + Duration::from_secs(30)); - recorder.run(p, a, l) - }; - let limits = Limits { - command: Duration::from_secs(30), - total: Duration::from_secs(50), - }; - let outcomes = install_timed(&all_agents(), limits, &slow, &|| used.get()); - assert_eq!(recorder.ran().len(), 2, "{:?}", recorder.ran()); - assert!( - !outcomes[0].ok && outcomes[0].message.contains("ran out of time"), - "{outcomes:?}" - ); - assert!( - !outcomes[1].ok && outcomes[1].message.contains("ran out of time"), - "{outcomes:?}" - ); - assert!(outcomes[2].ok, "Cursor runs nothing"); - } - - #[cfg(unix)] - fn script(dir: &Path, name: &str, body: &str) -> PathBuf { - use std::os::unix::fs::PermissionsExt; - let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - path - } - - #[cfg(unix)] - #[test] - fn a_command_past_its_limit_is_stopped_and_fails() { - let dir = tempfile::tempdir().unwrap(); - let hangs = script(dir.path(), "claude", "exec sleep 30"); - let started = Instant::now(); - let err = run_program( - &hangs, - &["plugin", "install"], - Duration::from_millis(200), - dir.path(), - ) - .unwrap_err(); - assert!( - started.elapsed() < Duration::from_secs(5), - "{:?}", - started.elapsed() - ); - assert_eq!(err, "ran out of time after 200 ms and was stopped"); - - let outcomes = install( - &[Agent::ClaudeCode(hangs), Agent::Cursor], - Limits { - command: Duration::from_millis(200), - total: Duration::from_millis(300), - }, - &|p, a, l| run_program(p, a, l, dir.path()), - ); - assert!( - started.elapsed() < Duration::from_secs(5), - "{:?}", - started.elapsed() - ); - assert!(!outcomes[0].ok, "{outcomes:?}"); - assert!( - outcomes[0].message.contains("ran out of time"), - "{outcomes:?}" - ); - assert!(outcomes[1].ok); - } - - #[cfg(unix)] - #[test] - fn a_failing_command_reports_its_last_line_and_stdin_is_closed() { - let dir = tempfile::tempdir().unwrap(); - let fails = script( - dir.path(), - "codex", - "echo working; echo 'first' >&2; echo 'no such plugin' >&2; exit 3", - ); - assert_eq!( - run_program(&fails, &[], Duration::from_secs(10), dir.path()), - Err("exited with 3: no such plugin".into()) - ); - let reads = script(dir.path(), "reads", "read line && exit 1; exit 0"); - assert_eq!( - run_program(&reads, &[], Duration::from_secs(10), dir.path()), - Ok(()) - ); - } - - /// Kills the process when the test ends, passed or not. - #[cfg(unix)] - struct KillOnDrop(i32); - - #[cfg(unix)] - impl Drop for KillOnDrop { - fn drop(&mut self) { - unsafe { libc::kill(self.0, libc::SIGKILL) }; - } - } - - #[cfg(unix)] - #[test] - fn a_command_past_its_limit_is_stopped_with_everything_it_started() { - let dir = tempfile::tempdir().unwrap(); - let pid = dir.path().join("pid"); - let hangs = script( - dir.path(), - "codex", - &format!("sleep 300 &\necho $! > '{}'\nwait", pid.display()), - ); - // Long enough for the stand-in to start its child on a busy machine. - let result = run_program(&hangs, &[], Duration::from_secs(2), dir.path()); - let pid: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); - let _cleanup = KillOnDrop(pid); - let err = result.unwrap_err(); - assert!(err.contains("ran out of time"), "{err}"); - let deadline = Instant::now() + Duration::from_secs(5); - while unsafe { libc::kill(pid, 0) } == 0 { - assert!(Instant::now() < deadline, "sleep {pid} still runs"); - std::thread::sleep(Duration::from_millis(20)); - } - } - - #[cfg(unix)] - #[test] - fn agents_run_in_the_home_folder_not_the_project() { - let (dir, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); - let wrote = dir.path().join("pwd"); - let prints = script( - dir.path(), - "claude", - &format!("pwd -P > '{0}'; echo \"$PWD\" >> '{0}'", wrote.display()), - ); - run_program(&prints, &[], Duration::from_secs(10), home.path()).unwrap(); - let home = home.path().canonicalize().unwrap(); - let lines: Vec = std::fs::read_to_string(&wrote) - .unwrap() - .lines() - .map(|l| Path::new(l).canonicalize().unwrap()) - .collect(); - assert_eq!(lines, [home.clone(), home]); - } - - fn skill(dir: &Path, name: &str, text: &str) { - std::fs::create_dir_all(dir.join(name).join("references")).unwrap(); - std::fs::write(dir.join(name).join("SKILL.md"), text).unwrap(); - std::fs::write(dir.join(name).join("references").join("a.md"), "ref").unwrap(); - } - - #[test] - fn the_skills_go_into_agents_skills_marked_as_setups() { - let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); - skill(unpacked.path(), "text-to-speech", "new tts"); - skill(unpacked.path(), "music", "new music"); - skill(unpacked.path(), "dubbing", "new dubbing"); - std::fs::create_dir_all(unpacked.path().join("evals").join("cases")).unwrap(); - std::fs::write(unpacked.path().join("README.md"), "readme").unwrap(); - let root = home.path().join(".agents").join("skills"); - - // One of the developer's own, and one setup wrote before. - std::fs::create_dir_all(root.join("music")).unwrap(); - std::fs::write(root.join("music").join("SKILL.md"), "mine").unwrap(); - std::fs::create_dir_all(root.join("dubbing")).unwrap(); - std::fs::write(root.join("dubbing").join("SKILL.md"), "old dubbing").unwrap(); - std::fs::write(root.join("dubbing").join("stale.md"), "old").unwrap(); - std::fs::write(root.join("dubbing").join(MARKER), "").unwrap(); - - let copied = copy_skills(unpacked.path(), home.path()).unwrap(); - assert_eq!( - copied, - Copied { - written: vec!["dubbing".into(), "text-to-speech".into()], - kept: vec!["music".into()], - } - ); - let read = |p: &[&str]| { - std::fs::read_to_string(p.iter().fold(root.clone(), |a, b| a.join(b))).unwrap() - }; - assert_eq!(read(&["text-to-speech", "SKILL.md"]), "new tts"); - assert_eq!(read(&["text-to-speech", "references", "a.md"]), "ref"); - assert_eq!(read(&["text-to-speech", MARKER]), ""); - assert_eq!(read(&["dubbing", "SKILL.md"]), "new dubbing"); - assert!( - !root.join("dubbing").join("stale.md").exists(), - "replaced whole" - ); - assert_eq!(read(&["music", "SKILL.md"]), "mine"); - assert!(!root.join("music").join(MARKER).exists()); - assert_eq!(std::fs::read_dir(root.join("music")).unwrap().count(), 1); - assert!(!root.join("evals").exists() && !root.join("README.md").exists()); - - // Again: everything setup wrote is replaced, the developer's still left. - let again = copy_skills(unpacked.path(), home.path()).unwrap(); - assert_eq!(again, copied); - } - - #[cfg(unix)] - #[test] - fn a_copy_that_stops_partway_is_replaced_next_time() { - use std::os::unix::fs::PermissionsExt; - if unsafe { libc::geteuid() } == 0 { - // Root reads a file whatever its mode, so the copy can't be made to stop. - return; - } - let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); - skill(unpacked.path(), "music", "new music"); - let unreadable = unpacked.path().join("music").join("references").join("a.md"); - std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); - let target = skills_root(home.path()).join("music"); - - assert!(copy_skills(unpacked.path(), home.path()).is_err()); - assert!(target.join(MARKER).is_file(), "marked before the copy"); - - std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).unwrap(); - let copied = copy_skills(unpacked.path(), home.path()).unwrap(); - assert_eq!(copied.written, ["music"]); - assert_eq!( - std::fs::read_to_string(target.join("references").join("a.md")).unwrap(), - "ref" - ); - } - - #[test] - fn the_offer_names_the_agents_that_can_take_the_plugin() { - let claude = Agent::ClaudeCode("claude".into()); - let codex = Agent::Codex("codex".into()); - let plugin = |names: &str| { - Some(format!( - "Want me to set ElevenLabs up in {names} too? It installs the ElevenLabs plugin (skills and the \ - hosted MCP server), so new sessions can use ElevenLabs right away." - )) - }; - assert_eq!(question(&[claude.clone()]), plugin("Claude Code")); - assert_eq!(question(&[codex.clone(), Agent::Cursor]), plugin("Codex")); - assert_eq!( - question(&[claude, codex, Agent::Cursor]), - plugin("Claude Code and Codex") - ); - assert_eq!( - question(&[]), - Some("Want me to install the ElevenLabs skills for your coding agents?".into()) - ); - assert_eq!( - question(&[Agent::Cursor]), - None, - "Cursor has nothing to run" - ); - } - - #[test] - fn the_later_command_runs_the_cli_the_way_this_run_was_started() { - assert_eq!( - cli_prefix_for(Some("exec")), - "npx -y @elevenlabs/cli@latest" - ); - assert_eq!(cli_prefix_for(Some("install")), "elevenlabs"); - assert_eq!(cli_prefix_for(None), "elevenlabs"); - assert_eq!( - format!("{} setup --yes", cli_prefix_for(Some("exec"))), - "npx -y @elevenlabs/cli@latest setup --yes" - ); - assert_eq!( - format!("{} setup --yes", cli_prefix_for(None)), - "elevenlabs setup --yes" - ); - } - - #[test] - fn the_plan_says_what_would_happen_for_each_agent() { - let home = Path::new("/home/dev"); - assert_eq!( - plan(&[Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor], home), - [ - "Claude Code (/bin/claude): install the ElevenLabs plugin (skills and the hosted MCP server)", - "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", - ] - ); - assert_eq!(plan(&[], home).len(), 1); - assert!(plan(&[], home)[0].contains(&skills_root(home).display().to_string())); - } -} +mod tests; diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs new file mode 100644 index 00000000..7fd36efd --- /dev/null +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -0,0 +1,866 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use super::*; + +fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let vars: HashMap = vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |name| vars.get(name).cloned() +} + +fn path_with(programs: &[&str]) -> impl Fn(&str) -> Option { + let programs: Vec = programs.iter().map(|p| p.to_string()).collect(); + move |name| { + programs + .iter() + .any(|p| p == name) + .then(|| PathBuf::from(format!("/bin/{name}"))) + } +} + +#[test] +fn agents_are_found_by_program_variable_and_folder() { + let home = tempfile::tempdir().unwrap(); + let none = env_of(&[]); + assert_eq!(found(&none, &path_with(&[]), home.path()), vec![]); + assert_eq!( + found(&none, &path_with(&["claude", "codex"]), home.path()), + vec![ + Agent::ClaudeCode("/bin/claude".into()), + Agent::Codex("/bin/codex".into()) + ] + ); + assert_eq!( + found(&none, &path_with(&["codex"]), home.path()), + vec![Agent::Codex("/bin/codex".into())] + ); + for var in ["CURSOR_AGENT", "CURSOR_TRACE_ID"] { + assert_eq!( + found(&env_of(&[(var, "1")]), &path_with(&[]), home.path()), + vec![Agent::Cursor], + "{var}" + ); + } + assert_eq!( + found( + &env_of(&[("CURSOR_AGENT", "")]), + &path_with(&[]), + home.path() + ), + vec![] + ); + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + assert_eq!( + found(&none, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor] + ); +} + +#[test] +fn claude_code_is_the_program_it_names_when_that_is_claude() { + let home = tempfile::tempdir().unwrap(); + for name in ["claude", "Claude.exe", "CLAUDE"] { + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join(name); + std::fs::write(&program, "").unwrap(); + let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); + assert_eq!( + found(&inside, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode(program.clone())], + "{name}" + ); + assert_eq!( + found(&inside, &path_with(&[]), home.path()), + vec![Agent::ClaudeCode(program)], + "{name}" + ); + } + let gone = env_of(&[("CLAUDE_CODE_EXECPATH", "/nowhere/claude")]); + assert_eq!(found(&gone, &path_with(&[]), home.path()), vec![]); + assert_eq!( + found(&gone, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode("/bin/claude".into())] + ); +} + +/// Claude Code installed by npm names the runtime that runs it, which +/// would load the project's own `plugin.js` given `plugin install`. +#[test] +fn a_runtime_named_by_claude_code_is_never_started() { + let home = tempfile::tempdir().unwrap(); + for name in ["node", "bun", "node.exe", "claude-desktop", "claude.cmd"] { + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join(name); + std::fs::write(&program, "").unwrap(); + let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); + assert_eq!( + found(&inside, &path_with(&["claude"]), home.path()), + vec![Agent::ClaudeCode("/bin/claude".into())], + "{name}: claude on PATH instead" + ); + assert_eq!( + found(&inside, &path_with(&[]), home.path()), + vec![], + "{name}" + ); + } +} + +#[cfg(unix)] +#[test] +fn a_program_on_path_is_an_executable_file() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let empty = tempfile::tempdir().unwrap(); + let program = dir.path().join("codex"); + std::fs::write(&program, "").unwrap(); + let path = std::env::join_paths([empty.path(), dir.path()]).unwrap(); + assert_eq!(on_path("codex", Some(&path), false), None, "not executable"); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(on_path("codex", Some(&path), false), Some(program)); + assert_eq!(on_path("claude", Some(&path), false), None); + assert_eq!(on_path("codex", None, false), None); +} + +#[test] +fn on_windows_a_program_on_path_is_an_exe_or_a_cmd_shim() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("codex.cmd"), "").unwrap(); + let path = std::env::join_paths([dir.path()]).unwrap(); + assert_eq!( + on_path("codex", Some(&path), true), + Some(dir.path().join("codex.cmd")) + ); +} + +#[test] +fn on_windows_a_variable_is_found_whatever_the_case_of_its_name() { + let env: HashMap = [("Path", "C:\\bin"), ("HOME", ""), ("Cursor_Agent", "1")] + .iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + assert_eq!(env_var(&env, "PATH", true), Some("C:\\bin".into())); + assert_eq!(env_var(&env, "PATH", false), None); + assert_eq!(env_var(&env, "CURSOR_AGENT", true), Some("1".into())); + assert_eq!(env_var(&env, "CURSOR_AGENT", false), None); + for windows in [true, false] { + assert_eq!(env_var(&env, "Path", windows), Some("C:\\bin".into())); + assert_eq!(env_var(&env, "HOME", windows), Some(String::new()), "empty, as set"); + assert_eq!(env_var(&env, "USERPROFILE", windows), None); + } + let both: HashMap = [("Path", "lower"), ("PATH", "exact")] + .iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + assert_eq!(env_var(&both, "PATH", true), Some("exact".into())); +} + +#[test] +fn the_home_folder_is_userprofile_on_windows_and_home_elsewhere() { + let env = env_of(&[("HOME", "/home/dev"), ("USERPROFILE", "C:\\Users\\dev")]); + assert_eq!(home_for(false, &env), Some(PathBuf::from("/home/dev"))); + assert_eq!(home_for(true, &env), Some(PathBuf::from("C:\\Users\\dev"))); + assert_eq!(home_for(true, &env_of(&[("HOME", "/home/dev")])), None); + assert_eq!(home_for(false, &env_of(&[("HOME", "")])), None); +} + +const LIMITS_FOR_TESTS: Limits = Limits { + command: Duration::from_secs(30), + total: Duration::from_secs(100), +}; + +/// Runs nothing; records each command and fails the ones `fails` names, +/// or, made with [`Recorder::exactly`], only that one command. +struct Recorder { + ran: RefCell>, + fails: Vec<&'static str>, + exactly: bool, +} + +impl Recorder { + fn new(fails: &[&'static str]) -> Self { + Self { + ran: RefCell::new(Vec::new()), + fails: fails.to_vec(), + exactly: false, + } + } + + fn exactly(fails: &'static str) -> Self { + Self { + exactly: true, + ..Self::new(&[fails]) + } + } + + fn run(&self, program: &Path, args: &[&str], limit: Duration) -> Result<(), String> { + assert!(limit <= Duration::from_secs(30)); + let line = format!("{} {}", program.display(), args.join(" ")); + self.ran.borrow_mut().push(line.clone()); + let fails = |f: &&str| if self.exactly { line == *f } else { line.contains(f) }; + match self.fails.iter().any(fails) { + true => Err("exited with 1: boom".into()), + false => Ok(()), + } + } + + fn ran(&self) -> Vec { + self.ran.borrow().clone() + } +} + +fn all_agents() -> Vec { + vec![ + Agent::ClaudeCode("claude".into()), + Agent::Codex("codex".into()), + Agent::Cursor, + ] +} + +#[test] +fn install_runs_each_agents_own_commands_in_order() { + let recorder = Recorder::new(&[]); + let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { + recorder.run(p, a, l) + }); + assert_eq!( + recorder.ran(), + [ + "claude plugin marketplace update elevenlabs", + "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin update elevenlabs@elevenlabs", + "codex plugin marketplace add elevenlabs/plugin", + "codex plugin marketplace upgrade elevenlabs", + "codex plugin add elevenlabs@elevenlabs", + ] + ); + assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); + assert_eq!( + outcomes.iter().map(result_line).collect::>(), + [ + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)", + "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)", + "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", + ] + ); + assert_eq!( + outcomes.iter().filter_map(finish).collect::>(), + [ + "restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server", + "restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", + ] + ); +} + +#[test] +fn only_the_install_commands_decide_an_agents_result() { + let refresh_fails = Recorder::new(&["marketplace update", "marketplace upgrade"]); + let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { + refresh_fails.run(p, a, l) + }); + assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); + assert_eq!(refresh_fails.ran().len(), 6); + + for (fails, agent) in [ + ("plugin install", "Claude Code"), + ("plugin update", "Claude Code"), + ("marketplace add", "Codex"), + ("plugin add", "Codex"), + ] { + let recorder = Recorder::new(&[fails]); + let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { + recorder.run(p, a, l) + }); + let failed: Vec<_> = outcomes.iter().filter(|o| !o.ok).collect(); + assert_eq!(failed.len(), 1, "{fails}: {outcomes:?}"); + assert_eq!(failed[0].agent, agent); + assert!(failed[0].message.contains(fails), "{failed:?}"); + assert!( + result_line(failed[0]).starts_with(&format!("{agent}: failed: `")) + && result_line(failed[0]).ends_with("` exited with 1: boom"), + "{}", + result_line(failed[0]) + ); + assert_eq!( + finish(failed[0]), + None, + "no finishing steps for a failed agent" + ); + } +} + +#[test] +fn agents_not_reached_in_time_count_as_failed() { + let recorder = Recorder::new(&[]); + // Each command takes 30 s of a 50 s run, without waiting for it. + let used = std::cell::Cell::new(Duration::ZERO); + let slow = |p: &Path, a: &[&str], l: Duration| { + used.set(used.get() + Duration::from_secs(30)); + recorder.run(p, a, l) + }; + let limits = Limits { + command: Duration::from_secs(30), + total: Duration::from_secs(50), + }; + let outcomes = install_timed(&all_agents(), limits, &slow, &|| used.get()); + assert_eq!(recorder.ran().len(), 2, "{:?}", recorder.ran()); + assert!( + !outcomes[0].ok && outcomes[0].message.contains("ran out of time"), + "{outcomes:?}" + ); + assert!( + !outcomes[1].ok && outcomes[1].message.contains("ran out of time"), + "{outcomes:?}" + ); + assert!(outcomes[2].ok, "Cursor runs nothing"); +} + +#[cfg(unix)] +fn script(dir: &Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +#[cfg(unix)] +#[test] +fn a_command_past_its_limit_is_stopped_and_fails() { + let dir = tempfile::tempdir().unwrap(); + let hangs = script(dir.path(), "claude", "exec sleep 30"); + let started = Instant::now(); + let err = run_program( + &hangs, + &["plugin", "install"], + Duration::from_millis(200), + dir.path(), + ) + .unwrap_err(); + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); + assert_eq!(err, "ran out of time after 200 ms and was stopped"); + + let outcomes = install( + &[Agent::ClaudeCode(hangs), Agent::Cursor], + Limits { + command: Duration::from_millis(200), + total: Duration::from_millis(300), + }, + &|p, a, l| run_program(p, a, l, dir.path()), + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); + assert!(!outcomes[0].ok, "{outcomes:?}"); + assert!( + outcomes[0].message.contains("ran out of time"), + "{outcomes:?}" + ); + assert!(outcomes[1].ok); +} + +#[cfg(unix)] +#[test] +fn a_failing_command_reports_its_last_line_and_stdin_is_closed() { + let dir = tempfile::tempdir().unwrap(); + let fails = script( + dir.path(), + "codex", + "echo working; echo 'first' >&2; echo 'no such plugin' >&2; exit 3", + ); + assert_eq!( + run_program(&fails, &[], Duration::from_secs(10), dir.path()), + Err("exited with 3: no such plugin".into()) + ); + let reads = script(dir.path(), "reads", "read line && exit 1; exit 0"); + assert_eq!( + run_program(&reads, &[], Duration::from_secs(10), dir.path()), + Ok(()) + ); +} + +/// Kills the process when the test ends, passed or not. +#[cfg(unix)] +struct KillOnDrop(i32); + +#[cfg(unix)] +impl Drop for KillOnDrop { + fn drop(&mut self) { + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } +} + +#[cfg(unix)] +#[test] +fn a_command_past_its_limit_is_stopped_with_everything_it_started() { + let dir = tempfile::tempdir().unwrap(); + let pid = dir.path().join("pid"); + let hangs = script( + dir.path(), + "codex", + &format!("sleep 300 &\necho $! > '{}'\nwait", pid.display()), + ); + // Long enough for the stand-in to start its child on a busy machine. + let result = run_program(&hangs, &[], Duration::from_secs(2), dir.path()); + let pid: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); + let _cleanup = KillOnDrop(pid); + let err = result.unwrap_err(); + assert!(err.contains("ran out of time"), "{err}"); + let deadline = Instant::now() + Duration::from_secs(5); + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "sleep {pid} still runs"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(unix)] +#[test] +fn agents_run_in_the_home_folder_not_the_project() { + let (dir, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let wrote = dir.path().join("pwd"); + let prints = script( + dir.path(), + "claude", + &format!("pwd -P > '{0}'; echo \"$PWD\" >> '{0}'", wrote.display()), + ); + run_program(&prints, &[], Duration::from_secs(10), home.path()).unwrap(); + let home = home.path().canonicalize().unwrap(); + let lines: Vec = std::fs::read_to_string(&wrote) + .unwrap() + .lines() + .map(|l| Path::new(l).canonicalize().unwrap()) + .collect(); + assert_eq!(lines, [home.clone(), home]); +} + +fn skill(dir: &Path, name: &str, text: &str) { + std::fs::create_dir_all(dir.join(name).join("references")).unwrap(); + std::fs::write(dir.join(name).join("SKILL.md"), text).unwrap(); + std::fs::write(dir.join(name).join("references").join("a.md"), "ref").unwrap(); +} + +#[test] +fn the_skills_go_into_agents_skills_marked_as_setups() { + let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + skill(unpacked.path(), "text-to-speech", "new tts"); + skill(unpacked.path(), "music", "new music"); + skill(unpacked.path(), "dubbing", "new dubbing"); + std::fs::create_dir_all(unpacked.path().join("evals").join("cases")).unwrap(); + std::fs::write(unpacked.path().join("README.md"), "readme").unwrap(); + let root = home.path().join(".agents").join("skills"); + + // One of the developer's own, and one setup wrote before. + std::fs::create_dir_all(root.join("music")).unwrap(); + std::fs::write(root.join("music").join("SKILL.md"), "mine").unwrap(); + std::fs::create_dir_all(root.join("dubbing")).unwrap(); + std::fs::write(root.join("dubbing").join("SKILL.md"), "old dubbing").unwrap(); + std::fs::write(root.join("dubbing").join("stale.md"), "old").unwrap(); + std::fs::write(root.join("dubbing").join(MARKER), "").unwrap(); + + let copied = copy_skills(unpacked.path(), home.path()).unwrap(); + assert_eq!( + copied, + Copied { + written: vec!["dubbing".into(), "text-to-speech".into()], + kept: vec!["music".into()], + } + ); + let read = |p: &[&str]| { + std::fs::read_to_string(p.iter().fold(root.clone(), |a, b| a.join(b))).unwrap() + }; + assert_eq!(read(&["text-to-speech", "SKILL.md"]), "new tts"); + assert_eq!(read(&["text-to-speech", "references", "a.md"]), "ref"); + assert_eq!(read(&["text-to-speech", MARKER]), ""); + assert_eq!(read(&["dubbing", "SKILL.md"]), "new dubbing"); + assert!( + !root.join("dubbing").join("stale.md").exists(), + "replaced whole" + ); + assert_eq!(read(&["music", "SKILL.md"]), "mine"); + assert!(!root.join("music").join(MARKER).exists()); + assert_eq!(std::fs::read_dir(root.join("music")).unwrap().count(), 1); + assert!(!root.join("evals").exists() && !root.join("README.md").exists()); + + // Again: everything setup wrote is replaced, the developer's still left. + let again = copy_skills(unpacked.path(), home.path()).unwrap(); + assert_eq!(again, copied); +} + +#[cfg(unix)] +#[test] +fn a_copy_that_stops_partway_is_replaced_next_time() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + // Root reads a file whatever its mode, so the copy can't be made to stop. + return; + } + let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + skill(unpacked.path(), "music", "new music"); + let unreadable = unpacked.path().join("music").join("references").join("a.md"); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); + let target = skills_root(home.path()).join("music"); + + assert!(copy_skills(unpacked.path(), home.path()).is_err()); + assert!(target.join(MARKER).is_file(), "marked before the copy"); + + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).unwrap(); + let copied = copy_skills(unpacked.path(), home.path()).unwrap(); + assert_eq!(copied.written, ["music"]); + assert_eq!( + std::fs::read_to_string(target.join("references").join("a.md")).unwrap(), + "ref" + ); +} + +#[test] +fn the_offer_names_the_agents_that_can_take_the_plugin() { + let claude = Agent::ClaudeCode("claude".into()); + let codex = Agent::Codex("codex".into()); + let plugin = |names: &str| { + Some(format!( + "Want me to set ElevenLabs up in {names} too? It installs the ElevenLabs plugin (skills and the \ + hosted MCP server), so new sessions can use ElevenLabs right away." + )) + }; + assert_eq!(question(&[claude.clone()]), plugin("Claude Code")); + assert_eq!(question(&[codex.clone(), Agent::Cursor]), plugin("Codex")); + assert_eq!( + question(&[claude, codex, Agent::Cursor]), + plugin("Claude Code and Codex") + ); + assert_eq!( + question(&[]), + Some("Want me to install the ElevenLabs skills for your coding agents?".into()) + ); + assert_eq!( + question(&[Agent::Cursor]), + None, + "Cursor has nothing to run" + ); +} + +#[test] +fn the_later_command_runs_the_cli_the_way_this_run_was_started() { + assert_eq!( + cli_prefix_for(Some("exec")), + "npx -y @elevenlabs/cli@latest" + ); + assert_eq!(cli_prefix_for(Some("install")), "elevenlabs"); + assert_eq!(cli_prefix_for(None), "elevenlabs"); + assert_eq!( + format!("{} setup --yes", cli_prefix_for(Some("exec"))), + "npx -y @elevenlabs/cli@latest setup --yes" + ); + assert_eq!( + format!("{} setup --yes", cli_prefix_for(None)), + "elevenlabs setup --yes" + ); +} + +#[test] +fn the_plan_says_what_would_happen_for_each_agent() { + let home = Path::new("/home/dev"); + assert_eq!( + plan(&[Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor], home), + [ + "Claude Code (/bin/claude): install the ElevenLabs plugin (skills and the hosted MCP server)", + "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", + ] + ); + assert_eq!(plan(&[], home).len(), 1); + assert!(plan(&[], home)[0].contains(&skills_root(home).display().to_string())); +} + + +// ── finding agents, as a table ────────────────────────────────────── + +/// A path to `to` relative to this process's folder, as a PATH entry can be. +#[cfg(unix)] +fn relative_to_here(to: &Path) -> PathBuf { + let here = std::env::current_dir().unwrap(); + let up = here.components().count() - 1; + let mut relative: PathBuf = std::iter::repeat("..").take(up).collect(); + relative.push(to.strip_prefix("/").unwrap()); + assert!(relative.is_relative() && relative.join("x").parent().unwrap().is_dir()); + relative +} + +#[cfg(unix)] +#[test] +fn awkward_path_entries_are_skipped_and_the_first_real_program_wins() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let base = tempfile::tempdir().unwrap(); + let dir = |name: &str| { + let d = base.path().join(name); + std::fs::create_dir_all(&d).unwrap(); + d + }; + let first = dir("first"); + let real = script(&dir("real"), "claude", "exit 0"); + let program = |at: &Path| script(at, "claude", "exit 0"); + let path = |entries: &[&std::ffi::OsStr]| -> OsString { + entries.iter().map(|e| e.to_os_string()).collect::>().join(OsStr::new(":")) + }; + let real_dir = real.parent().unwrap().as_os_str(); + + // (row, PATH, what it finds) + let mut rows: Vec<(&str, OsString, Option)> = vec![ + ("empty entries", path(&["".as_ref(), "".as_ref(), real_dir, "".as_ref()]), Some(real.clone())), + ( + "a missing folder first", + path(&[base.path().join("missing").as_os_str(), real_dir]), + Some(real.clone()), + ), + ( + "a relative entry, which names a folder in the project", + path(&[relative_to_here(real.parent().unwrap()).as_os_str()]), + None, + ), + ("only an empty entry", OsString::new(), None), + ]; + let twice = program(&dir("twice")); + rows.push(( + "duplicate entries", + path(&[twice.parent().unwrap().as_os_str(), twice.parent().unwrap().as_os_str(), real_dir]), + Some(twice.clone()), + )); + let folder = dir("folder"); + std::fs::create_dir(folder.join("claude")).unwrap(); + rows.push(("a folder named claude", path(&[folder.as_os_str(), real_dir]), Some(real.clone()))); + rows.push(("only a folder named claude", path(&[folder.as_os_str()]), None)); + let plain = dir("plain"); + std::fs::write(plain.join("claude"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(plain.join("claude"), std::fs::Permissions::from_mode(0o644)).unwrap(); + rows.push(("a claude without the execute bit", path(&[plain.as_os_str(), real_dir]), Some(real.clone()))); + rows.push(("only a claude without the execute bit", path(&[plain.as_os_str()]), None)); + let linked = dir("linked"); + symlink(&real, linked.join("claude")).unwrap(); + rows.push(("a symlink to a program", path(&[linked.as_os_str()]), Some(linked.join("claude")))); + let broken = dir("broken"); + symlink(base.path().join("gone"), broken.join("claude")).unwrap(); + rows.push(("a broken symlink", path(&[broken.as_os_str(), real_dir]), Some(real.clone()))); + rows.push(("only a broken symlink", path(&[broken.as_os_str()]), None)); + let odd = program(&dir("my agents (ü 日本)")); + rows.push(( + "a folder with spaces and non-ASCII letters", + path(&[first.as_os_str(), odd.parent().unwrap().as_os_str()]), + Some(odd.clone()), + )); + + for (row, path, want) in rows { + assert_eq!(on_path("claude", Some(&path), false), want, "{row}: PATH={path:?}"); + } +} + +// ── the skills copy, against a local server standing in for GitHub ── + +/// Everything under `root` but `/.agents/skills`: each path with its +/// kind, its bytes or its link's target, and its permissions. The `.agents` +/// folder that holds it is left out too, so making it counts as no change. +#[cfg(unix)] +fn outside_the_skills(root: &Path) -> std::collections::BTreeMap { + let mut all = tree(root, &[skills_root(Path::new(""))]); + if all.get(Path::new(".agents")).is_some_and(|what| what.starts_with("folder")) { + all.remove(Path::new(".agents")); + } + all +} + +/// Every path under `root` but those under `skip`, with what it holds. +#[cfg(unix)] +fn tree(root: &Path, skip: &[PathBuf]) -> std::collections::BTreeMap { + use std::os::unix::fs::PermissionsExt; + fn walk(root: &Path, at: &Path, skip: &[PathBuf], out: &mut std::collections::BTreeMap) { + for entry in std::fs::read_dir(at).unwrap().flatten() { + let path = entry.path(); + let relative = path.strip_prefix(root).unwrap().to_path_buf(); + if skip.contains(&relative) { + continue; + } + let meta = std::fs::symlink_metadata(&path).unwrap(); + let mode = meta.permissions().mode() & 0o7777; + let what = if meta.file_type().is_symlink() { + format!("link to {}", std::fs::read_link(&path).unwrap().display()) + } else if meta.is_dir() { + format!("folder {mode:o}") + } else { + format!("file {mode:o} {:?}", std::fs::read(&path).unwrap()) + }; + out.insert(relative, what); + if meta.is_dir() && !meta.file_type().is_symlink() { + walk(root, &path, skip, out); + } + } + } + let mut out = std::collections::BTreeMap::new(); + walk(root, root, skip, &mut out); + out +} + +/// A `.tar.gz` of `files` (path and text; a path ending in `/` is a folder, +/// text starting with `->` a symlink) under `skills-main/`, made with the +/// system's tar as GitHub's archive is laid out. +#[cfg(unix)] +fn archive(files: &[(&str, &str)]) -> Vec { + let dir = tempfile::tempdir().unwrap(); + let top = dir.path().join("skills-main"); + std::fs::create_dir(&top).unwrap(); + for (path, text) in files { + let at = top.join(path); + std::fs::create_dir_all(if path.ends_with('/') { &at } else { at.parent().unwrap() }).unwrap(); + if let Some(target) = text.strip_prefix("->") { + std::os::unix::fs::symlink(target, &at).unwrap(); + } else if !path.ends_with('/') { + std::fs::write(&at, text).unwrap(); + } + } + let out = dir.path().join("skills.tar.gz"); + let status = Command::new("tar") + .arg("-czf") + .arg(&out) + .arg("-C") + .arg(dir.path()) + .arg("skills-main") + .status() + .unwrap(); + assert!(status.success()); + std::fs::read(out).unwrap() +} + +#[cfg(unix)] +const TWO_SKILLS: &[(&str, &str)] = &[ + ("music/SKILL.md", "# music"), + ("music/references/api.md", "the api"), + ("text-to-speech/SKILL.md", "# tts"), + ("README.md", "readme"), + ("evals/cases/a.json", "{}"), +]; + +/// A server standing in for GitHub that answers `GET /` with each of +/// `answers`, and the URL of the first. +#[cfg(unix)] +async fn github(answers: Vec<(&str, wiremock::ResponseTemplate)>) -> (wiremock::MockServer, String) { + let server = wiremock::MockServer::start().await; + let first = format!("{}/{}", server.uri(), answers[0].0); + for (name, answer) in answers { + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path(format!("/{name}"))) + .respond_with(answer) + .mount(&server) + .await; + } + (server, first) +} + +#[cfg(unix)] +fn body(bytes: Vec) -> wiremock::ResponseTemplate { + wiremock::ResponseTemplate::new(200).set_body_bytes(bytes) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn the_skills_download_is_copied_or_fails_with_what_went_wrong() { + let good = archive(TWO_SKILLS); + let truncated = good[..good.len() / 2].to_vec(); + let odd = archive(&[ + ("music/SKILL.md", "# music"), + ("music/hosts", "->/etc/hosts"), + ("music/up", "->../../outside"), + ("linked", "->music"), + ("notes", "a file where a skill folder would be"), + ("weird/SKILL.md/", ""), + ("weird/other.md", "no SKILL.md file"), + ]); + let none = archive(&[("README.md", "readme"), ("evals/a.json", "{}")]); + let copied = |n: usize| move |root: &Path| format!("copied {n} ElevenLabs skills into {}", root.display()); + type Want = Box (bool, String)>; + let ok = |n: usize| -> Want { Box::new(move |_, root| (true, copied(n)(root))) }; + let starts = |ok: bool, prefix: &'static str| -> Want { + Box::new(move |url, _| (ok, prefix.replace("{url}", url))) + }; + // (row, how the server answers, the outcome, the skill folders left) + let rows: Vec<(&str, Vec<(&str, wiremock::ResponseTemplate)>, Want, &[&str])> = vec![ + ("a good archive", vec![("main.tar.gz", body(good.clone()))], ok(2), &["music", "text-to-speech"]), + ( + "a redirect to it", + vec![ + ("archive", wiremock::ResponseTemplate::new(302).insert_header("Location", "/codeload.tar.gz")), + ("codeload.tar.gz", body(good.clone())), + ], + ok(2), + &["music", "text-to-speech"], + ), + ( + "HTTP 404", + vec![("main.tar.gz", wiremock::ResponseTemplate::new(404))], + starts(false, "couldn't download the skills from {url}: HTTP status client error (404 Not Found)"), + &[], + ), + ( + "HTTP 500", + vec![("main.tar.gz", wiremock::ResponseTemplate::new(500))], + starts(false, "couldn't download the skills from {url}: HTTP status server error (500 Internal Server Error)"), + &[], + ), + ( + "a body that is not gzip", + vec![("main.tar.gz", body(b"not here".to_vec()))], + starts(false, "tar couldn't unpack the skills: "), + &[], + ), + ( + "a truncated archive", + vec![("main.tar.gz", body(truncated))], + starts(false, "tar couldn't unpack the skills: "), + &[], + ), + ( + "an archive with no skills", + vec![("main.tar.gz", body(none))], + starts(false, "the download from {url} held no skills"), + &[], + ), + ( + "links, a file and a SKILL.md folder where skills would be", + vec![("main.tar.gz", body(odd))], + ok(1), + &["music"], + ), + ]; + for (row, answers, want, left) in rows { + let (_server, url) = github(answers).await; + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join(".profile"), "mine").unwrap(); + let before = outside_the_skills(home.path()); + let outcome = skills_from(&url, home.path()); + let root = skills_root(home.path()); + let (ok, message) = want(&url, &root); + assert_eq!(outcome.ok, ok, "{row}: {outcome:?}"); + assert!(outcome.message.starts_with(&message), "{row}: {:?} should start {message:?}", outcome.message); + assert_eq!(outside_the_skills(home.path()), before, "{row}: nothing else in the home folder changed"); + let mut there: Vec = std::fs::read_dir(&root) + .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect()) + .unwrap_or_default(); + there.sort(); + assert_eq!(there, left, "{row}"); + if left.contains(&"music") { + let music = tree(&root.join("music"), &[]); + let names: Vec = music.keys().map(|p| p.display().to_string()).collect(); + let want: &[&str] = if row.starts_with("links") { + &[MARKER, "SKILL.md"] + } else { + &[MARKER, "SKILL.md", "references", "references/api.md"] + }; + assert_eq!(names, want, "{row}: no link is followed or copied"); + } + } +} From 57a0e56269d1139c5d16a557bafed390675419e1 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 19:32:24 -0500 Subject: [PATCH 07/32] test: setup's agent detection, install rules, skills copy and child processes, as tables and a randomized run Every combination of Claude Code, Codex and Cursor, and the awkward PATH and CLAUDE_CODE_EXECPATH values; each agent command failing in turn, and 2,000 seeded random failure patterns against an injected clock; the skills download against a local server standing in for GitHub, and every state the home folder can be in; and real child processes for stdin, large and broken output, signals, vanished programs, limits and leftovers. --- cli/elevenlabs/workflow/setup/tests.rs | 592 ++++++++++++++++++++++++- 1 file changed, 591 insertions(+), 1 deletion(-) diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index 7fd36efd..e9ebcdb2 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -580,9 +580,75 @@ fn the_plan_says_what_would_happen_for_each_agent() { assert!(plan(&[], home)[0].contains(&skills_root(home).display().to_string())); } - // ── finding agents, as a table ────────────────────────────────────── +/// Where Claude Code is, in a row of the table below. +#[derive(Debug, Clone, Copy)] +enum ClaudeAt { + ExecPath, + OnPath, + Both, + Neither, +} + +/// What says Cursor is there, in a row of the table below. +#[derive(Debug, Clone, Copy)] +enum CursorBy { + Folder, + AgentVariable, + TraceVariable, + Nothing, +} + +#[cfg(unix)] +#[test] +fn every_combination_of_claude_code_codex_and_cursor_is_found() { + let inside = tempfile::tempdir().unwrap(); + let named = script(inside.path(), "claude", "exit 0"); + let mut rows = 0; + for claude in [ClaudeAt::ExecPath, ClaudeAt::OnPath, ClaudeAt::Both, ClaudeAt::Neither] { + for codex in [true, false] { + for cursor in [ + CursorBy::Folder, + CursorBy::AgentVariable, + CursorBy::TraceVariable, + CursorBy::Nothing, + ] { + let (home, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let mut vars = Vec::new(); + let mut want = Vec::new(); + if matches!(claude, ClaudeAt::ExecPath | ClaudeAt::Both) { + vars.push(("CLAUDE_CODE_EXECPATH", named.to_str().unwrap())); + } + let on_path_claude = matches!(claude, ClaudeAt::OnPath | ClaudeAt::Both) + .then(|| script(bin.path(), "claude", "exit 0")); + match claude { + ClaudeAt::ExecPath | ClaudeAt::Both => want.push(Agent::ClaudeCode(named.clone())), + ClaudeAt::OnPath => want.push(Agent::ClaudeCode(on_path_claude.unwrap())), + ClaudeAt::Neither => {} + } + if codex { + want.push(Agent::Codex(script(bin.path(), "codex", "exit 0"))); + } + match cursor { + CursorBy::Folder => std::fs::create_dir(home.path().join(".cursor")).unwrap(), + CursorBy::AgentVariable => vars.push(("CURSOR_AGENT", "1")), + CursorBy::TraceVariable => vars.push(("CURSOR_TRACE_ID", "a1b2")), + CursorBy::Nothing => {} + } + if !matches!(cursor, CursorBy::Nothing) { + want.push(Agent::Cursor); + } + let path = std::env::join_paths([bin.path()]).unwrap(); + let got = found(&env_of(&vars), &|n| on_path(n, Some(&path), false), home.path()); + assert_eq!(got, want, "Claude Code {claude:?}, Codex {codex}, Cursor {cursor:?}"); + rows += 1; + } + } + } + assert_eq!(rows, 32); +} + /// A path to `to` relative to this process's folder, as a PATH entry can be. #[cfg(unix)] fn relative_to_here(to: &Path) -> PathBuf { @@ -661,6 +727,228 @@ fn awkward_path_entries_are_skipped_and_the_first_real_program_wins() { } } +#[cfg(unix)] +#[test] +fn claude_code_execpath_is_used_only_when_it_names_a_claude_file() { + let home = tempfile::tempdir().unwrap(); + let base = tempfile::tempdir().unwrap(); + std::fs::create_dir(base.path().join("bin")).unwrap(); + let on_path_claude = script(&base.path().join("bin"), "claude", "exit 0"); + let bin = std::env::join_paths([on_path_claude.parent().unwrap()]).unwrap(); + let odd = base.path().join("Claude Code (ü)"); + std::fs::create_dir_all(&odd).unwrap(); + let odd = script(&odd, "claude", "exit 0"); + let named_folder = base.path().join("folder").join("claude"); + std::fs::create_dir_all(&named_folder).unwrap(); + let node = script(base.path(), "node", "exit 0"); + let bun = script(base.path(), "bun", "exit 0"); + let missing = base.path().join("gone").join("claude"); + + // (row, CLAUDE_CODE_EXECPATH, the program it names when it counts) + let rows: [(&str, &Path, Option<&Path>); 6] = [ + ("node", &node, None), + ("bun", &bun, None), + ("a missing file", &missing, None), + ("a folder named claude", &named_folder, None), + ("an empty value", Path::new(""), None), + ("a claude in a folder with spaces and non-ASCII letters", &odd, Some(&odd)), + ]; + for (row, value, named) in rows { + let env = env_of(&[("CLAUDE_CODE_EXECPATH", value.to_str().unwrap())]); + let with_path = found(&env, &|n| on_path(n, Some(&bin), false), home.path()); + let want = named.unwrap_or(&on_path_claude).to_path_buf(); + assert_eq!(with_path, vec![Agent::ClaudeCode(want)], "{row}, with claude on PATH"); + let alone = found(&env, &|_| None, home.path()); + let want: Vec = named.map(|p| Agent::ClaudeCode(p.to_path_buf())).into_iter().collect(); + assert_eq!(alone, want, "{row}, with nothing on PATH"); + } +} + +// ── installing, as a table and as a randomized run ────────────────── + +/// The six commands, in the order `install` runs them. +const COMMANDS: [&str; 6] = [ + "claude plugin marketplace update elevenlabs", + "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin update elevenlabs@elevenlabs", + "codex plugin marketplace add elevenlabs/plugin", + "codex plugin marketplace upgrade elevenlabs", + "codex plugin add elevenlabs@elevenlabs", +]; + +const INSTALLED: &str = "installed the ElevenLabs plugin (skills and the hosted MCP server)"; + +#[test] +fn each_failing_command_stops_its_own_agent_and_no_other() { + let claude = Agent::ClaudeCode("claude".into()); + let codex = Agent::Codex("codex".into()); + let both = [claude.clone(), codex.clone(), Agent::Cursor]; + // (agents, the command that fails, the commands that ran, each agent's result) + let rows: [(&[Agent], usize, &[usize], &[bool]); 12] = [ + (&[claude.clone()], 0, &[0, 1, 2], &[true]), + (&[claude.clone()], 1, &[0, 1], &[false]), + (&[claude.clone()], 2, &[0, 1, 2], &[false]), + (&[codex.clone()], 3, &[3], &[false]), + (&[codex.clone()], 4, &[3, 4, 5], &[true]), + (&[codex.clone()], 5, &[3, 4, 5], &[false]), + (&both, 0, &[0, 1, 2, 3, 4, 5], &[true, true, true]), + (&both, 1, &[0, 1, 3, 4, 5], &[false, true, true]), + (&both, 2, &[0, 1, 2, 3, 4, 5], &[false, true, true]), + (&both, 3, &[0, 1, 2, 3], &[true, false, true]), + (&both, 4, &[0, 1, 2, 3, 4, 5], &[true, true, true]), + (&both, 5, &[0, 1, 2, 3, 4, 5], &[true, false, true]), + ]; + for (agents, fails, ran, ok) in rows { + let row = format!("{:?} with `{}` failing", agents.iter().map(Agent::name).collect::>(), COMMANDS[fails]); + let recorder = Recorder::exactly(COMMANDS[fails]); + let outcomes = install(agents, LIMITS_FOR_TESTS, &|p, a, l| recorder.run(p, a, l)); + let want: Vec = ran.iter().map(|&i| COMMANDS[i].to_string()).collect(); + assert_eq!(recorder.ran(), want, "{row}"); + let want: Vec = agents + .iter() + .zip(ok) + .map(|(agent, &ok)| Outcome { + agent: agent.name(), + ok, + message: match (agent, ok) { + (Agent::Cursor, _) => format!("open {CURSOR_PAGE} and select Install"), + (_, true) => INSTALLED.into(), + (_, false) => format!("`{}` exited with 1: boom", COMMANDS[fails]), + }, + }) + .collect(); + assert_eq!(outcomes, want, "{row}"); + } +} + +/// xorshift64*: enough randomness for a test, with no crate. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +/// One command a randomized run started: which, when, and with how long. +#[derive(Debug)] +struct Call { + line: String, + at: Duration, + limit: Duration, + failed: bool, +} + +#[test] +fn install_keeps_its_rules_under_2000_random_failure_patterns() { + const SEED: u64 = 0x5EED_0F_E1E7_E2; + const PATTERNS: usize = 2000; + let mut rng = Rng(SEED); + for pattern in 0..PATTERNS { + let mut agents = vec![ + Agent::ClaudeCode("claude".into()), + Agent::Codex("codex".into()), + Agent::Cursor, + ]; + for i in (1..agents.len()).rev() { + agents.swap(i, rng.below(i as u64 + 1) as usize); + } + agents.truncate(rng.below(4) as usize); + let limits = Limits { + command: Duration::from_millis(1 + rng.below(30)), + total: Duration::from_millis(1 + rng.below(150)), + }; + let rng = RefCell::new(Rng(rng.next() | 1)); + let clock = std::cell::Cell::new(Duration::ZERO); + let calls = RefCell::new(Vec::::new()); + let outcomes = install_timed( + &agents, + limits, + &|program, args, limit| { + let mut rng = rng.borrow_mut(); + // Never longer than its limit: past it, a command is stopped. + let took = Duration::from_millis(rng.below(limit.as_millis() as u64 + 1)); + let failed = took == limit || rng.below(10) < 3; + calls.borrow_mut().push(Call { + line: format!("{} {}", program.display(), args.join(" ")), + at: clock.get(), + limit, + failed, + }); + clock.set(clock.get() + took); + if failed { + Err("failed".into()) + } else { + Ok(()) + } + }, + &|| clock.get(), + ); + let why = format!("seed {SEED:#x}, pattern {pattern}: {agents:?} {limits:?}"); + let calls = calls.into_inner(); + assert_eq!(outcomes.len(), agents.len(), "{why}"); + assert!(clock.get() <= limits.total, "{why}: the run took {:?}", clock.get()); + let mut next = 0; + for (agent, outcome) in agents.iter().zip(&outcomes) { + assert_eq!(outcome.agent, agent.name(), "{why}"); + let planned: Vec = COMMANDS + .iter() + .filter(|c| match agent { + Agent::ClaudeCode(_) => c.starts_with("claude "), + Agent::Codex(_) => c.starts_with("codex "), + Agent::Cursor => false, + }) + .map(|c| c.to_string()) + .collect(); + // This agent's commands come next, a prefix of its plan in order. + let mine: Vec<&Call> = calls[next..] + .iter() + .take_while(|c| planned.contains(&c.line)) + .collect(); + next += mine.len(); + let lines: Vec<&String> = mine.iter().map(|c| &c.line).collect(); + assert_eq!(lines, planned.iter().take(mine.len()).collect::>(), "{why}"); + for call in &mine { + assert!( + !call.limit.is_zero() + && call.limit <= limits.command + && call.at + call.limit <= limits.total, + "{why}: {call:?}" + ); + } + let decides = |line: &str| { + agent.steps().iter().any(|(args, decides)| *decides && line.ends_with(&args.join(" "))) + }; + let deciding_failure = mine.iter().position(|c| c.failed && decides(&c.line)); + if let Some(at) = deciding_failure { + assert_eq!(at + 1, mine.len(), "{why}: nothing runs after a deciding command fails"); + } + let finished = mine.len() == planned.len(); + if !finished && deciding_failure.is_none() { + assert!( + outcome.message.contains("ran out of time"), + "{why}: stopped early only when time ran out: {outcome:?}" + ); + } + let every_deciding_command_worked = agent + .steps() + .iter() + .filter(|(_, decides)| *decides) + .all(|(args, _)| mine.iter().any(|c| c.line.ends_with(&args.join(" ")) && !c.failed)); + let want_ok = matches!(agent, Agent::Cursor) || every_deciding_command_worked; + assert_eq!(outcome.ok, want_ok, "{why}: {outcome:?} after {lines:?}"); + } + assert_eq!(next, calls.len(), "{why}: every command belongs to an agent, in their order"); + } +} + // ── the skills copy, against a local server standing in for GitHub ── /// Everything under `root` but `/.agents/skills`: each path with its @@ -864,3 +1152,305 @@ async fn the_skills_download_is_copied_or_fails_with_what_went_wrong() { } } } + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn the_skills_land_whatever_the_home_folder_already_holds() { + use std::os::unix::fs::PermissionsExt; + let (_server, url) = github(vec![("main.tar.gz", body(archive(TWO_SKILLS)))]).await; + type Prepare = fn(&Path); + // (row, the home folder before, ok, what the message says after the root) + let rows: [(&str, Prepare, bool, &str); 7] = [ + ("a fresh home folder", |_| {}, true, ""), + ( + "an .agents folder holding other things", + |h| { + std::fs::create_dir_all(h.join(".agents/other")).unwrap(); + std::fs::write(h.join(".agents/other/keep.md"), "keep").unwrap(); + }, + true, + "", + ), + ( + "a skill folder setup wrote", + |h| { + let music = skills_root(h).join("music"); + std::fs::create_dir_all(&music).unwrap(); + std::fs::write(music.join(MARKER), "").unwrap(); + std::fs::write(music.join("stale.md"), "old").unwrap(); + }, + true, + "", + ), + ( + "a skill folder of the developer's own", + |h| { + let music = skills_root(h).join("music"); + std::fs::create_dir_all(&music).unwrap(); + std::fs::write(music.join("SKILL.md"), "mine").unwrap(); + }, + true, + "; left alone, since setup didn't write them: music", + ), + ( + "a skill folder that is a symlink to a marked folder", + |h| { + let elsewhere = h.join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(elsewhere.join(MARKER), "").unwrap(); + std::fs::create_dir_all(skills_root(h)).unwrap(); + std::os::unix::fs::symlink(&elsewhere, skills_root(h).join("music")).unwrap(); + }, + true, + "; left alone, since setup didn't write them: music", + ), + ( + "an .agents that is a file", + |h| std::fs::write(h.join(".agents"), "not a folder").unwrap(), + false, + "", + ), + ( + "a read-only skill folder setup wrote", + |h| { + let music = skills_root(h).join("music"); + std::fs::create_dir_all(&music).unwrap(); + std::fs::write(music.join(MARKER), "").unwrap(); + std::fs::write(music.join("SKILL.md"), "old").unwrap(); + std::fs::set_permissions(&music, std::fs::Permissions::from_mode(0o555)).unwrap(); + }, + false, + "", + ), + ]; + for (row, prepare, ok, kept) in rows { + let home = tempfile::tempdir().unwrap(); + prepare(home.path()); + let root = skills_root(home.path()); + let before = outside_the_skills(home.path()); + let skills_before = root.is_dir().then(|| tree(&root, &[])); + let outcome = skills_from(&url, home.path()); + assert_eq!(outcome.ok, ok, "{row}: {outcome:?}"); + assert_eq!(outside_the_skills(home.path()), before, "{row}: nothing outside the skills folder changed"); + if ok { + let written = if kept.is_empty() { 2 } else { 1 }; + assert_eq!( + outcome.message, + format!("copied {written} ElevenLabs skills into {}{kept}", root.display()), + "{row}" + ); + let tts = tree(&root.join("text-to-speech"), &[]); + assert_eq!(tts.len(), 2, "{row}: {tts:?}"); + } else if row.contains("read-only") { + assert!(outcome.message.starts_with(&root.join("music").display().to_string()), "{row}: {outcome:?}"); + assert_eq!(Some(tree(&root, &[])), skills_before, "{row}: left as it was"); + std::fs::set_permissions(root.join("music"), std::fs::Permissions::from_mode(0o755)).unwrap(); + } else { + assert!(outcome.message.contains(".agents"), "{row}: {outcome:?}"); + } + if row.contains("own") || row.contains("symlink") { + assert_eq!(Some(tree(&root, &[]).into_iter().filter(|(p, _)| p.starts_with("music")).collect()), + skills_before.map(|t| t.into_iter().filter(|(p, _)| p.starts_with("music")).collect::>()), + "{row}: the folder is left as it was"); + } + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn five_runs_give_byte_identical_skills_with_nested_folders_and_odd_names() { + let files: &[(&str, &str)] = &[ + ("music/SKILL.md", "# music"), + ("music/a/b/c/deep.md", "deep"), + ("music/with space.md", "space"), + ("music/ünï 日本.md", "unicode"), + ("music/-dash.md", "dash"), + ("music/.hidden", "hidden"), + ("music/empty/", ""), + ("dubbing/SKILL.md", "# dubbing"), + ]; + let (_server, url) = github(vec![("main.tar.gz", body(archive(files)))]).await; + let home = tempfile::tempdir().unwrap(); + let before = outside_the_skills(home.path()); + let root = skills_root(home.path()); + let mut first = None; + for run in 0..5 { + let outcome = skills_from(&url, home.path()); + assert!(outcome.ok, "run {run}: {outcome:?}"); + let now = tree(&root, &[]); + match &first { + None => first = Some(now), + Some(first) => assert_eq!(&now, first, "run {run}"), + } + assert_eq!(outside_the_skills(home.path()), before, "run {run}"); + } + // Read back by name: macOS's tar may store a name in another Unicode form. + for (path, text) in files { + let at = root.join(path.trim_end_matches('/')); + if path.ends_with('/') { + assert!(at.is_dir(), "{path}"); + } else { + assert_eq!(std::fs::read_to_string(&at).unwrap(), *text, "{path}"); + } + } + assert_eq!(first.unwrap().len(), files.len() + 7, "the files, five folders and the two marks"); +} + +// ── starting a program, against real child processes ─────────────── + +/// Waits for `file` to hold a pid, polling until a deadline. +#[cfg(unix)] +fn pid_in(file: &Path) -> i32 { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(pid) = std::fs::read_to_string(file).ok().and_then(|t| t.trim().parse().ok()) { + return pid; + } + assert!(Instant::now() < deadline, "no pid in {}", file.display()); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Whether process `pid` is gone, waiting up to a few seconds for it. +#[cfg(unix)] +fn gone(pid: i32) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + while unsafe { libc::kill(pid, 0) } == 0 { + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } + true +} + +/// The processes whose command line contains `marker`. +#[cfg(unix)] +fn left_behind(marker: &str) -> Vec { + let out = Command::new("ps").args(["-axo", "pid=,command="]).output().unwrap(); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|l| l.contains(marker) && !l.contains("ps -axo")) + .map(str::to_string) + .collect() +} + +#[cfg(unix)] +#[test] +fn a_program_reports_how_it_ended_whatever_it_prints() { + let dir = tempfile::tempdir().unwrap(); + let limit = Duration::from_secs(20); + // (row, the stand-in's script, what run_program returns) + let rows: [(&str, &str, Result<(), String>); 9] = [ + ("stdin is closed", "read line && exit 1; exit 0", Ok(())), + ( + "megabytes on stdout and stderr, then success", + "head -c 4000000 /dev/zero | tr '\\0' o; head -c 4000000 /dev/zero | tr '\\0' e >&2; exit 0", + Ok(()), + ), + ( + "megabytes on stdout and stderr, then a failure", + "head -c 4000000 /dev/zero | tr '\\0' o; head -c 4000000 /dev/zero | tr '\\0' e >&2; \ + echo >&2; echo 'the real error' >&2; exit 4", + Err("exited with 4: the real error".into()), + ), + ( + "only stdout says why", + "echo 'on stdout'; exit 5", + Err("exited with 5: on stdout".into()), + ), + ("nothing said", "exit 6", Err("exited with 6".into())), + ( + "output that is not UTF-8", + "printf 'caf\\351 \\377 broke\\n' >&2; exit 2", + Err("exited with 2: caf\u{FFFD} \u{FFFD} broke".into()), + ), + ("killed by a signal", "kill -KILL $$", Err("exited with a signal".into())), + ( + "killed by a signal after a line", + "echo 'half way' >&2; kill -TERM $$", + Err("exited with a signal: half way".into()), + ), + ("blank lines after the last one", "echo 'last' >&2; echo >&2; echo ' ' >&2; exit 1", Err("exited with 1: last".into())), + ]; + for (row, body, want) in rows { + let program = script(dir.path(), "claude", body); + let started = Instant::now(); + assert_eq!(run_program(&program, &[], limit, dir.path()), want, "{row}"); + assert!(started.elapsed() < Duration::from_secs(15), "{row}: took {:?}", started.elapsed()); + } +} + +#[cfg(unix)] +#[test] +fn a_program_that_vanished_or_lost_its_execute_bit_since_it_was_found_fails_to_start() { + use std::os::unix::fs::PermissionsExt; + let (dir, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let path = std::env::join_paths([dir.path()]).unwrap(); + let program = script(dir.path(), "codex", "exit 0"); + assert_eq!(on_path("codex", Some(&path), false), Some(program.clone())); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o644)).unwrap(); + let err = run_program(&program, &[], Duration::from_secs(5), home.path()).unwrap_err(); + assert!(err.starts_with("couldn't start it: "), "lost its execute bit: {err}"); + std::fs::remove_file(&program).unwrap(); + let err = run_program(&program, &[], Duration::from_secs(5), home.path()).unwrap_err(); + assert!(err.starts_with("couldn't start it: "), "vanished: {err}"); + let outcomes = install(&[Agent::Codex(program)], LIMITS_FOR_TESTS, &|p, a, l| { + run_program(p, a, l, home.path()) + }); + assert!(!outcomes[0].ok && outcomes[0].message.contains("couldn't start it"), "{outcomes:?}"); +} + +#[cfg(unix)] +#[test] +fn a_command_past_its_limit_is_stopped_with_its_children_and_grandchildren() { + let dir = tempfile::tempdir().unwrap(); + let (child, grandchild) = (dir.path().join("child"), dir.path().join("grandchild")); + let hangs = script( + dir.path(), + "codex", + &format!( + "sh -c 'sleep 3011 & echo $! > \"{g}\"; wait' &\necho $! > '{c}'\nwait", + g = grandchild.display(), + c = child.display() + ), + ); + let started = Instant::now(); + let limit = Duration::from_millis(1500); + let result = run_program(&hangs, &[], limit, dir.path()); + let (child, grandchild) = (pid_in(&child), pid_in(&grandchild)); + let _cleanup = (KillOnDrop(child), KillOnDrop(grandchild)); + assert_eq!(result, Err("ran out of time after 1 seconds and was stopped".into())); + assert!(started.elapsed() < limit + Duration::from_secs(3), "{:?}", started.elapsed()); + assert!(gone(child), "the child {child} still runs"); + assert!(gone(grandchild), "the grandchild {grandchild} still runs"); + assert_eq!(left_behind("sleep 3011"), Vec::::new()); + assert_eq!(left_behind(&hangs.display().to_string()), Vec::::new()); +} + +#[cfg(unix)] +#[test] +fn fifty_runs_in_a_row_leave_no_process_behind() { + let dir = tempfile::tempdir().unwrap(); + let works = script(dir.path(), "works", "echo fine"); + let fails = script(dir.path(), "fails", "echo broke >&2; exit 1"); + let hangs = script(dir.path(), "hangs", "sh -c 'sleep 3012' &\nsleep 3012 &\nwait"); + for run in 0..50 { + let (program, limit, want) = match run % 3 { + 0 => (&works, Duration::from_secs(10), Ok(())), + 1 => (&fails, Duration::from_secs(10), Err("exited with 1: broke".to_string())), + _ => (&hangs, Duration::from_millis(100), Err("ran out of time after 100 ms and was stopped".to_string())), + }; + assert_eq!(run_program(program, &[], limit, dir.path()), want, "run {run}"); + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let mut left = left_behind("sleep 3012"); + left.extend(left_behind(&dir.path().display().to_string())); + if left.is_empty() { + break; + } + assert!(Instant::now() < deadline, "left behind: {left:?}"); + std::thread::sleep(Duration::from_millis(20)); + } +} From 98b1ce54940d202094a6d21eb2cfe80931e41352 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 19:38:31 -0500 Subject: [PATCH 08/32] test: setup, the onboarding offer and interrupts through the binary setup for every combination of stand-in agents and flags, a stand-in failing at each step, odd folder names, ten runs in a row and two at once, and agents started from the shell's environment, never a project's .env. Whole flows through the real loopback sign-in: the offer after a test request that works, once per sign-in, with the flags and the npx spelling in its run. With no sign-in, every reply is compared with the base commit's, kept in tests/fixtures/. SIGINT and SIGTERM during each of the six agent commands, twice in a row, with SIGINT ignored, and with no agent command running. --- .fernignore | 1 + tests/fixtures/no_sign_in_replies.json | 143 +++++ tests/onboard_test.rs | 773 ++++++++++++++++++++++++- 3 files changed, 889 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/no_sign_in_replies.json diff --git a/.fernignore b/.fernignore index 10ade350..8372a821 100644 --- a/.fernignore +++ b/.fernignore @@ -43,6 +43,7 @@ tests/e2e_smoke.rs tests/say_test.rs tests/onboard_test.rs tests/flows_wait_test.rs +tests/fixtures/ .fern/replay.lock .fern/replay.yml .gitattributes diff --git a/tests/fixtures/no_sign_in_replies.json b/tests/fixtures/no_sign_in_replies.json new file mode 100644 index 00000000..21b15e47 --- /dev/null +++ b/tests/fixtures/no_sign_in_replies.json @@ -0,0 +1,143 @@ +{ + "check_with_a_working_key_and_no_sdk": [ + { + "details": { + "api_key_source": "env_file", + "env_file": ".env.local", + "fixed": [ + "added .env.local to .gitignore", + "made .env.local readable only by you" + ], + "key_last4": "4567", + "sdk_declared": false, + "sdk_imported": false + }, + "instruction": "Tell the user `say`, then end your turn and wait for their answer to its question. Ask it even if they already said what they want, even just the SDK: their answer confirms it. Write no code before they answer. When they have, run `run` before anything else, without installing or writing anything first: its reply says how to write the code. If you already asked and they answered, don't ask again, just run `run`.", + "next": "ask_what_to_build", + "run": "elevenlabs onboard check --answered --env-path .env.local", + "say": "Your key works. What would you like to build with ElevenLabs, or should I just add the SDK for now?" + } + ], + "check_with_no_key": [ + { + "instruction": "Tell the user `say`, then run `run`.", + "next": "run", + "run": "elevenlabs onboard connect --env-path .env.local", + "say": "There's no ElevenLabs API key in this project yet, so I'll open the approval page to create one." + } + ], + "check_with_the_sdk_wired": [ + { + "details": { + "api_key_source": "env_file", + "fixed": [ + "added .env.local to .gitignore", + "made .env.local readable only by you" + ], + "key_last4": "4567", + "sdk_declared": true, + "sdk_imported": true + }, + "instruction": "Say what you set up and anything the user still has to do themselves, like installing the packages you added. Then end your reply with the question in `say`, as it is, and stop there: until they answer, don't run the app or its start script, or make speech to try it. Run `run` only if they say yes.", + "next": "offer_test", + "run": "elevenlabs onboard test --env-path .env.local", + "say": "ElevenLabs is set up: a working key ending in 4567 in .env.local, and ElevenLabs in your code. Want me to send a test request? It makes a short text-to-speech clip with your key and uses a few credits." + } + ], + "init_in_an_empty_project": [ + { + "details": { + "env_file": ".env.local" + }, + "instruction": "Tell the user `say`, then run `run`.", + "next": "run", + "rules": [ + "Never open, print or cat the env file; run `onboard check` to verify it.", + "Never ask for an API key in chat, and never accept one pasted there.", + "Never put the key in code; the SDK reads ELEVENLABS_API_KEY.", + "Never run git clean or delete untracked files.", + "Don't write or change any code until a reply's `next` is `write_code` or `load_key`.", + "Put anything you download only to read, like a package or an archive, in a temp folder, never in the project.", + "Don't run the app (or its start script) or make speech to try it until the user says yes to the test request. API calls that what they asked for needs, like creating an agent, are fine. Once the code is written, run `onboard check` before you finish: it verifies the key and the code." + ], + "run": "elevenlabs onboard connect --env-path .env.local", + "say": "Next, I'll open the ElevenLabs approval page in your browser. Sign in (or create an account) and click Authorize." + } + ], + "test_failing_with_500": [ + { + "details": { + "status": 500 + }, + "instruction": "Tell the user `say`, then anything they still have to do themselves, like installing the packages you added, in one or two concrete steps. There's nothing left to run.", + "next": "done", + "say": "The test request failed (500: down); ask me to try again later. ElevenLabs is set up: your key is in .env.local and ElevenLabs is in your code." + } + ], + "test_that_works": [ + { + "details": { + "audio_file": "/elevenlabs-hello-.mp3", + "status": 200 + }, + "instruction": "Tell the user `say`, then anything they still have to do themselves, like installing the packages you added, in one or two concrete steps. There's nothing left to run.", + "next": "done", + "say": "Your first request worked. Play /elevenlabs-hello-.mp3 to hear it. ElevenLabs is set up: your key is in .env.local and ElevenLabs is in your code." + } + ], + "test_unreachable": [ + { + "details": { + "status": null + }, + "instruction": "Tell the user `say`, then anything they still have to do themselves, like installing the packages you added, in one or two concrete steps. There's nothing left to run.", + "next": "done", + "say": "I couldn't reach ElevenLabs for the test request; ask me to try again later. ElevenLabs is set up: your key is in .env.local and ElevenLabs is in your code." + } + ], + "test_with_no_credits": [ + { + "details": { + "status": 402 + }, + "instruction": "Tell the user `say`, then anything they still have to do themselves, like installing the packages you added, in one or two concrete steps. There's nothing left to run.", + "next": "done", + "say": "The test request needs credits on your account. Add some at https://elevenlabs.io/app/subscription, then ask me to try again. ElevenLabs is set up: your key is in .env.local and ElevenLabs is in your code." + } + ], + "voice_agent_check": [ + { + "details": { + "agent_id": "agent_7101k5zvyjhmfg983brhmhkd98n6", + "agent_url": "https://elevenlabs.io/app/agents/agents/agent_7101k5zvyjhmfg983brhmhkd98n6", + "api_key_source": "env_file", + "fixed": [ + "added .env.local to .gitignore", + "made .env.local readable only by you" + ], + "key_last4": "4567", + "sdk_declared": false, + "sdk_imported": false, + "talk_to_url": "https://elevenlabs.io/app/talk-to?agent_id=agent_7101k5zvyjhmfg983brhmhkd98n6" + }, + "instruction": "Tell the user `say`, then anything they still have to do themselves, like installing the packages you added, in one or two concrete steps. There's nothing left to run.", + "next": "done", + "say": "ElevenLabs is set up: a working key ending in 4567 in .env.local, and your agent \"Store helper\" in your code. Talk to it at https://elevenlabs.io/app/talk-to?agent_id=agent_7101k5zvyjhmfg983brhmhkd98n6, and change it at https://elevenlabs.io/app/agents/agents/agent_7101k5zvyjhmfg983brhmhkd98n6." + }, + { + "details": { + "agent_id": "agent_7101k5zvyjhmfg983brhmhkd98n6", + "agent_url": "https://elevenlabs.io/app/agents/agents/agent_7101k5zvyjhmfg983brhmhkd98n6", + "api_key_source": "env_file", + "fixed": [], + "key_last4": "4567", + "sdk_declared": false, + "sdk_imported": false, + "talk_to_url": "https://elevenlabs.io/app/talk-to?agent_id=agent_7101k5zvyjhmfg983brhmhkd98n6" + }, + "instruction": "Tell the user `say`, then anything they still have to do themselves, like installing the packages you added, in one or two concrete steps. There's nothing left to run.", + "next": "done", + "say": "ElevenLabs is set up: a working key ending in 4567 in .env.local, and your agent \"Store helper\" in your code. Talk to it at https://elevenlabs.io/app/talk-to?agent_id=agent_7101k5zvyjhmfg983brhmhkd98n6, and change it at https://elevenlabs.io/app/agents/agents/agent_7101k5zvyjhmfg983brhmhkd98n6." + } + ] +} diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 70299a39..47765c34 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2235,14 +2235,8 @@ fn onboard_setup_without_a_home_folder_says_so_and_ends() { ); } -#[tokio::test] -async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones() { - let _turn = signing_in().await; - if callback_ports_busy() { - eprintln!("skipped: another sign-in is listening on the callback ports"); - return; - } - let server = MockServer::start().await; +/// A token endpoint that accepts any code, for a sign-in through the callback. +async fn token_endpoint(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -2250,29 +2244,51 @@ async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones "token_type": "Bearer", "expires_in": 3600 }))) - .mount(&server) + .mount(server) .await; +} + +/// A sign-in with telemetry off, run as the agent runs it: `connect`, the +/// browser's callback carrying `params` (the code or the page's error) to the +/// real loopback listener, then `connect --wait` until it ends with `key_status`. +fn sign_in_through_the_callback(home: &Path, cwd: &Path, server: &str, params: &str, key_status: &str) { + sign_in_with(home, cwd, server, params, key_status, &[]) +} + +/// [`sign_in_through_the_callback`], with `args` added to each `connect`. +fn sign_in_with(home: &Path, cwd: &Path, server: &str, params: &str, key_status: &str, args: &[&str]) { + let authorize = format!("{server}/app/oauth/authorize"); + let first = reply(&mut cli( + home, + cwd, + server, + &[&["onboard", "connect", "--no-browser", "--authorize-url", &authorize, "--telemetry", "off"], args].concat(), + )); + let url = link(&first); + assert!(!url.contains("setup_offer"), "the link is the base commit's: {url}"); + callback(&url, params); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let r = connect(home, cwd, &[&["--wait", "--no-browser"], args].concat()); + if r["next"] != "wait" || Instant::now() >= deadline { + assert_eq!(r["details"]["key_status"], key_status, "{r}"); + break; + } + } +} + +#[tokio::test] +async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones() { + let _turn = signing_in().await; + if callback_ports_busy() { + eprintln!("skipped: another sign-in is listening on the callback ports"); + return; + } + let server = MockServer::start().await; + token_endpoint(&server).await; let (home, cwd) = temp(); - let authorize = format!("{}/app/oauth/authorize", server.uri()); - // Signs in with telemetry off, the page answering the callback with `params`. let sign_in = |params: &str, key_status: &str| { - let first = reply(&mut cli( - home.path(), - cwd.path(), - &server.uri(), - &["onboard", "connect", "--no-browser", "--authorize-url", &authorize, "--telemetry", "off"], - )); - let url = link(&first); - assert!(!url.contains("setup_offer"), "the link is the base commit's: {url}"); - callback(&url, params); - let deadline = Instant::now() + Duration::from_secs(60); - loop { - let r = connect(home.path(), cwd.path(), &["--wait", "--no-browser"]); - if r["next"] != "wait" || Instant::now() >= deadline { - assert_eq!(r["details"]["key_status"], key_status, "{r}"); - break; - } - } + sign_in_through_the_callback(home.path(), cwd.path(), &server.uri(), params, key_status) }; let offer = session_dir(home.path(), cwd.path()).join("setup-offer"); sign_in("code=approved", "no_key"); @@ -2282,3 +2298,704 @@ async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones sign_in("code=approved", "no_key"); assert!(offer.exists(), "the next sign-in that works leaves it again"); } + +// ── setup through the binary, for every combination of agents ─────── + +const PLUGIN: &str = "install the ElevenLabs plugin (skills and the hosted MCP server)"; +const CURSOR_LINE: &str = "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install\n"; +const FINISH_CLAUDE: &str = "- restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server\n"; +const FINISH_CODEX: &str = + "- restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server\n"; + +/// What `setup` prints with `agents` found, the programs in `bin`: the plan, +/// or with `installs` what it did. +fn setup_says(agents: &[&str], bin: &Path, home: &Path, installs: bool) -> String { + let has = |a: &str| agents.contains(&a); + let mut out = String::new(); + if installs { + for (agent, name) in [("claude", "Claude Code"), ("codex", "Codex")] { + if has(agent) { + out += &format!("{name}: installed the ElevenLabs plugin (skills and the hosted MCP server)\n"); + } + } + if has("cursor") { + out += CURSOR_LINE; + } + if has("claude") || has("codex") { + out += "\nTo finish:\n"; + } + if has("claude") { + out += FINISH_CLAUDE; + } + if has("codex") { + out += FINISH_CODEX; + } + return out; + } + if agents.is_empty() { + out += &format!( + "No Claude Code, Codex or Cursor found: copy the ElevenLabs skills into {}\n", + home.join(".agents").join("skills").display() + ); + } + for (agent, name) in [("claude", "Claude Code"), ("codex", "Codex")] { + if has(agent) { + out += &format!("{name} ({}): {PLUGIN}\n", bin.join(agent).display()); + } + } + if has("cursor") { + out += CURSOR_LINE; + } + out + "Nothing was changed. Run `elevenlabs setup --yes` to do it.\n" +} + +/// The commands of `INSTALLS` that `agents` run. +fn installs_of(agents: &[&str]) -> Vec { + INSTALLS + .iter() + .filter(|c| agents.iter().any(|a| c.starts_with(&format!("{a} ")))) + .map(|c| c.to_string()) + .collect() +} + +#[cfg(unix)] +#[test] +fn setup_through_the_binary_for_every_combination_of_agents_and_flags() { + let combinations: [&[&str]; 8] = [ + &[], + &["claude"], + &["codex"], + &["claude", "codex"], + &["cursor"], + &["claude", "cursor"], + &["codex", "cursor"], + &["claude", "codex", "cursor"], + ]; + let flags: [(&[&str], bool); 4] = [ + (&[], false), + (&["--yes"], true), + (&["--yes", "--dry-run"], false), + (&["--dry-run"], false), + ]; + let mut rows = 0; + for agents in combinations { + for (args, installs) in flags { + if agents.is_empty() && installs { + // It would download the skills from GitHub: covered by the unit tests. + continue; + } + let row = format!("{agents:?} with {args:?}"); + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let programs: Vec<&str> = agents.iter().copied().filter(|a| *a != "cursor").collect(); + let path = stand_ins(bin.path(), &programs, &record, None); + if agents.contains(&"cursor") { + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + } + let out = cli(home.path(), cwd.path(), OFFLINE, &[&["setup"][..], args].concat()) + .env("PATH", &path) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0), "{row}: {}", all_output(&out)); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + setup_says(agents, bin.path(), home.path(), installs), + "{row}" + ); + let want = if installs { installs_of(agents) } else { vec![] }; + assert_eq!(recorded(&record), want, "{row}"); + let written: Vec<_> = std::fs::read_dir(home.path()).unwrap().flatten().map(|e| e.file_name()).collect(); + let cursor_only: &[&str] = if agents.contains(&"cursor") { &[".cursor"] } else { &[] }; + assert_eq!(written, cursor_only, "{row}: setup itself writes nothing in the home folder"); + rows += 1; + } + } + assert_eq!(rows, 31); +} + +#[cfg(unix)] +#[test] +fn setup_through_the_binary_with_a_stand_in_failing_at_each_step() { + let finish = format!("\nTo finish:\n{FINISH_CLAUDE}{FINISH_CODEX}"); + let all_ok = format!( + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)\n\ + Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)\n{finish}" + ); + let failed = |line: String, finish: &str| { + format!("{line}\nTo try again, run `elevenlabs setup --yes`.\n\nTo finish:\n{finish}") + }; + // (the step that fails, exit code, output, the commands that ran) + let rows: [(&str, i32, String, Vec<&str>); 6] = [ + ("marketplace update", 0, all_ok.clone(), INSTALLS.to_vec()), + ( + "plugin install", + 1, + failed( + "Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` \ + exited with 1: install refused\nCodex: installed the ElevenLabs plugin (skills and the hosted MCP server)" + .into(), + FINISH_CODEX, + ), + [&INSTALLS[..2], &INSTALLS[3..]].concat(), + ), + ( + "plugin update", + 1, + failed( + "Claude Code: failed: `claude plugin update elevenlabs@elevenlabs` exited with 1: install refused\n\ + Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)" + .into(), + FINISH_CODEX, + ), + INSTALLS.to_vec(), + ), + ( + "marketplace add", + 1, + failed( + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)\n\ + Codex: failed: `codex plugin marketplace add elevenlabs/plugin` exited with 1: install refused" + .into(), + FINISH_CLAUDE, + ), + INSTALLS[..4].to_vec(), + ), + ("marketplace upgrade", 0, all_ok.clone(), INSTALLS.to_vec()), + ( + "plugin add", + 1, + failed( + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)\n\ + Codex: failed: `codex plugin add elevenlabs@elevenlabs` exited with 1: install refused" + .into(), + FINISH_CLAUDE, + ), + INSTALLS.to_vec(), + ), + ]; + for (fails, code, says, ran) in rows { + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, Some(fails)); + let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) + .env("PATH", &path) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(code), "`{fails}` failing: {}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), says, "`{fails}` failing"); + assert_eq!(recorded(&record), ran, "`{fails}` failing"); + } +} + +#[cfg(unix)] +#[test] +fn setup_through_the_binary_from_folders_with_spaces_and_non_ascii_names() { + let base = tempfile::tempdir().unwrap(); + let (home, cwd, bin) = ( + base.path().join("home dir ü 日本"), + base.path().join("project (été)"), + base.path().join("agents dir é 日本"), + ); + for dir in [&home, &cwd, &bin] { + std::fs::create_dir(dir).unwrap(); + } + let record = bin.join("ran"); + let path = stand_ins(&bin, &["claude", "codex"], &record, None); + let run = |args: &[&str]| cli(&home, &cwd, OFFLINE, args).env("PATH", &path).output().unwrap(); + let out = run(&["setup"]); + assert!(out.status.success(), "{}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), setup_says(&["claude", "codex"], &bin, &home, false)); + let out = run(&["setup", "--yes"]); + assert!(out.status.success(), "{}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), setup_says(&["claude", "codex"], &bin, &home, true)); + assert_eq!(recorded(&record), INSTALLS); +} + +#[cfg(unix)] +#[test] +fn setup_through_the_binary_ten_runs_in_a_row_and_two_at_once() { + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let says = setup_says(&["claude", "codex"], bin.path(), home.path(), true); + let start = || { + cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) + .env("PATH", &path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap() + }; + for run in 0..10 { + let out = start().wait_with_output().unwrap(); + assert!(out.status.success(), "run {run}: {}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), says, "run {run}"); + } + assert_eq!(recorded(&record), INSTALLS.repeat(10)); + + std::fs::remove_file(&record).unwrap(); + let (first, second) = (start(), start()); + for out in [first.wait_with_output().unwrap(), second.wait_with_output().unwrap()] { + assert!(out.status.success(), "{}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), says); + } + let mut ran = recorded(&record); + ran.sort(); + let mut want: Vec = INSTALLS.repeat(2).iter().map(|c| c.to_string()).collect(); + want.sort(); + assert_eq!(ran, want, "each run ran all six commands"); +} + +#[cfg(unix)] +#[test] +fn setup_starts_agents_from_the_shells_environment_never_the_projects_env() { + use std::os::unix::fs::PermissionsExt; + let (home, cwd) = temp(); + let (bin, project_bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let record = bin.path().join("ran"); + for (dir, said) in [(bin.path(), "shell's"), (project_bin.path(), "project's")] { + let claude = dir.join("claude"); + std::fs::write( + &claude, + format!( + "#!/bin/sh\necho \"{said} claude $* [${{ELEVENLABS_SETUP_TEST:-unset}}]\" >> '{}'\n", + record.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + write( + cwd.path(), + ".env", + &format!("PATH={}:/usr/bin:/bin\nELEVENLABS_SETUP_TEST=from-the-project\n", project_bin.path().display()), + ); + let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) + .env("PATH", format!("{}:/usr/bin:/bin", bin.path().display())) + .output() + .unwrap(); + assert!(out.status.success(), "{}", all_output(&out)); + let want: Vec = INSTALLS[..3].iter().map(|c| format!("shell's {c} [unset]")).collect(); + assert_eq!(recorded(&record), want); +} + +// ── the offer, as whole flows ─────────────────────────────────────── + +/// `GET /v1/user` requests the server has had, by their User-Agent, and +/// how many text-to-speech requests. +async fn requests(server: &MockServer) -> (Vec, usize) { + let all = server.received_requests().await.unwrap(); + let users = all + .iter() + .filter(|r| r.url.path() == "/v1/user") + .map(|r| r.headers["user-agent"].to_str().unwrap().to_string()) + .collect(); + let speech = all.iter().filter(|r| r.url.path().starts_with("/v1/text-to-speech")).count(); + (users, speech) +} + +/// The arguments of a reply's `run`, without the program. +fn run_args(reply: &Value) -> Vec { + let run = reply["run"].as_str().unwrap(); + run.strip_prefix("elevenlabs ").unwrap_or(run).split(' ').map(str::to_string).collect() +} + +#[cfg(unix)] +#[tokio::test] +async fn a_whole_flow_signs_in_tests_and_sets_up_offering_once_per_sign_in() { + let _turn = signing_in().await; + if callback_ports_busy() { + eprintln!("skipped: another sign-in is listening on the callback ports"); + return; + } + let server = MockServer::start().await; + token_endpoint(&server).await; + user_endpoint_accepting(&server, KEY).await; + // The first test request fails; the next ones work. + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({"detail": {"message": "down"}}))) + .up_to_n_times(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"ID3-fake-mp3".to_vec())) + .mount(&server) + .await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let onboard = |args: &[String]| { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + reply(cli(home.path(), cwd.path(), &server.uri(), &args).env("PATH", &path)) + }; + let test = || onboard(&["onboard".into(), "test".into()]); + let offer = session_dir(home.path(), cwd.path()).join("setup-offer"); + + sign_in_through_the_callback(home.path(), cwd.path(), &server.uri(), "code=approved", "no_key"); + assert!(offer.exists()); + // The clipboard hand-off can't run here: the key arrives as the page would leave it. + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + + let r = test(); + assert_eq!((r["next"].as_str(), r["details"]["status"].as_u64()), (Some("done"), Some(500)), "{r}"); + assert!(r["details"].get("question").is_none(), "no offer after a failed test request: {r}"); + assert!(offer.exists(), "the offer waits for one that works"); + + let r = test(); + assert_eq!(r["next"], "offer_setup", "{r}"); + assert_eq!(r["details"]["question"], PLUGIN_QUESTION.replace("{}", "Claude Code and Codex")); + assert_eq!(r["run"], "elevenlabs onboard setup --env-path .env.local"); + assert!(!offer.exists()); + let accept = run_args(&r); + assert_eq!(test()["next"], "done", "made once"); + + let (users, speech) = requests(&server).await; + let r = onboard(&accept); + assert_eq!(r["next"], "done", "{r}"); + assert!(r.get("run").is_none(), "{r}"); + assert_eq!(recorded(&record), INSTALLS); + let (users_after, speech_after) = requests(&server).await; + assert_eq!(speech_after, speech, "onboard setup makes no speech"); + let new: Vec<&String> = users_after[users.len()..].iter().collect(); + assert_eq!(new.len(), 1, "one account request: {new:?}"); + assert!(new[0].contains("cmd/onboard.setup"), "{}", new[0]); + + // Signing in again in a project that has a key replaces it, as the developer asks. + sign_in_with(home.path(), cwd.path(), &server.uri(), "code=approved", "no_key", &["--replace"]); + let r = test(); + assert_eq!(r["next"], "offer_setup", "again after a second sign-in: {r}"); + assert_eq!(test()["next"], "done"); +} + +#[cfg(unix)] +#[tokio::test] +async fn with_no_agent_found_the_offer_is_the_skills_and_its_run_keeps_the_flags_and_npx() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"ID3".to_vec())) + .mount(&server) + .await; + let (home, cwd) = temp(); + write(cwd.path(), "other.env", &format!("ELEVENLABS_API_KEY={KEY}\n")); + // (row, npm_command, the run it offers) + let rows = [ + ("installed", None, "elevenlabs onboard setup --env-path other.env --no-browser"), + ( + "through npx", + Some("exec"), + "npx -y @elevenlabs/cli@latest onboard setup --env-path other.env --no-browser", + ), + ]; + for (row, npm, run) in rows { + setup_offer_due(home.path(), cwd.path()); + let mut cmd = cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "test", "--env-path", "other.env", "--no-browser"], + ); + cmd.env("PATH", "/usr/bin:/bin"); + if let Some(npm) = npm { + cmd.env("npm_command", npm); + } + let r = reply(&mut cmd); + assert_eq!(r["next"], "offer_setup", "{row}: {r}"); + assert_eq!( + r["details"]["question"], "Want me to install the ElevenLabs skills for your coding agents?", + "{row}" + ); + assert_eq!(r["run"], run, "{row}"); + } +} + +/// A reply with the paths and port of one run replaced, as the base +/// commit's replies in `tests/fixtures/no_sign_in_replies.json` are. +fn normalised(reply: &Value, home: &Path, cwd: &Path, server: &str) -> Value { + let mut text = reply.to_string(); + let mut paths: Vec<(String, &str)> = Vec::new(); + for (dir, name) in [(home, ""), (cwd, "")] { + paths.push((dir.display().to_string(), name)); + paths.push((dir.canonicalize().unwrap().display().to_string(), name)); + } + paths.sort_by_key(|(p, _)| std::cmp::Reverse(p.len())); + for (path, name) in paths { + text = text.replace(&path, name); + } + text = text.replace(server, ""); + let clip = regex_lite_clip(&text); + serde_json::from_str(&clip).unwrap() +} + +/// `elevenlabs-hello-XXXXXX.mp3`, the test request's clip, with its random part replaced. +fn regex_lite_clip(text: &str) -> String { + let mut out = String::new(); + let mut rest = text; + while let Some(at) = rest.find("elevenlabs-hello-") { + let start = at + "elevenlabs-hello-".len(); + out.push_str(&rest[..start]); + rest = &rest[start..]; + if rest.len() > 6 && rest[6..].starts_with(".mp3") { + out.push_str(""); + rest = &rest[6..]; + } + } + out + rest +} + +#[cfg(unix)] +#[tokio::test] +async fn with_no_sign_in_every_reply_is_exactly_the_base_commits() { + const MINE: &str = "agent_7101k5zvyjhmfg983brhmhkd98n6"; + let want: Value = + serde_json::from_str(include_str!("fixtures/no_sign_in_replies.json")).unwrap(); + let tts = |status: u16| async move { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + agent_endpoint(&server, KEY, MINE, "Store helper", json!({"enable_auth": false, "allowlist": []})).await; + let answer = match status { + 200 => ResponseTemplate::new(200).set_body_bytes(b"ID3-fake-mp3".to_vec()), + 402 => ResponseTemplate::new(402) + .set_body_json(json!({"detail": {"status": "payment_required", "message": "no credits"}})), + _ => ResponseTemplate::new(status).set_body_json(json!({"detail": {"message": "down"}})), + }; + Mock::given(method("POST")) + .and(path_regex("^/v1/text-to-speech/")) + .respond_with(answer) + .mount(&server) + .await; + server + }; + let (ok, no_credits, down) = (tts(200).await, tts(402).await, tts(500).await); + let with_key = |p: &Path| write(p, ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let wired = |p: &Path| { + with_key(p); + write( + p, + "package.json", + r#"{"scripts":{"start":"node --env-file=.env.local src/speak.js"},"dependencies":{"@elevenlabs/elevenlabs-js":"^2"}}"#, + ); + write(p, "src/speak.js", "import { ElevenLabsClient } from \"@elevenlabs/elevenlabs-js\";\n"); + }; + let voice_agent = |p: &Path| { + with_key(p); + write(p, "index.html", &embed(MINE)); + }; + let nothing = |_: &Path| {}; + let check: &[&str] = &["onboard", "check"]; + let agent_check: &[&str] = &["onboard", "check", "--answered", "--no-browser"]; + let test: &[&str] = &["onboard", "test"]; + // (scenario, the project, the server, the commands run in turn) + let scenarios: [(&str, &dyn Fn(&Path), String, Vec<&[&str]>); 9] = [ + ("init_in_an_empty_project", ¬hing, ok.uri(), vec![&["onboard", "init"]]), + ("check_with_no_key", ¬hing, ok.uri(), vec![check]), + ("check_with_a_working_key_and_no_sdk", &with_key, ok.uri(), vec![check]), + ("check_with_the_sdk_wired", &wired, ok.uri(), vec![check]), + ("test_that_works", &wired, ok.uri(), vec![test]), + ("test_with_no_credits", &wired, no_credits.uri(), vec![test]), + ("test_failing_with_500", &wired, down.uri(), vec![test]), + ("test_unreachable", &wired, OFFLINE.into(), vec![test]), + ("voice_agent_check", &voice_agent, ok.uri(), vec![agent_check, agent_check]), + ]; + assert_eq!(scenarios.len(), want.as_object().unwrap().len()); + for (name, project, server, commands) in scenarios { + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + // A Claude Code on PATH: with no sign-in it changes nothing. + let path = stand_ins(bin.path(), &["claude"], &record, None); + project(cwd.path()); + let got: Vec = commands + .iter() + .map(|args| { + let r = reply(cli(home.path(), cwd.path(), &server, args).env("PATH", &path)); + normalised(&r, home.path(), cwd.path(), &server) + }) + .collect(); + assert_eq!(Value::from(got), want[name], "{name}"); + assert!(recorded(&record).is_empty(), "{name}"); + } +} + +// ── interrupts through the binary ─────────────────────────────────── + +/// Stand-ins for Claude Code and Codex that run every command at once but +/// `hang_on`, which starts `sleep `, writes its pid to `pid`, and +/// waits. Returns a PATH with only them and the system's tools. +#[cfg(unix)] +fn hanging_stand_ins(dir: &Path, hang_on: &str, marker: u32) -> String { + use std::os::unix::fs::PermissionsExt; + let pid = dir.join("pid"); + for name in ["claude", "codex"] { + let script = format!( + "#!/bin/sh\nif [ \"{name} $*\" = \"{hang_on}\" ]; then\n sleep {marker} &\n echo $! > '{0}.new'\n \ + mv '{0}.new' '{0}'\n wait\nfi\nexit 0\n", + pid.display() + ); + std::fs::write(dir.join(name), script).unwrap(); + std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o755)).unwrap(); + } + format!("{}:/usr/bin:/bin", dir.display()) +} + +/// The processes whose command line contains `marker`. +#[cfg(unix)] +fn running(marker: &str) -> Vec { + let out = Command::new("ps").args(["-axo", "pid=,command="]).output().unwrap(); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|l| l.contains(marker) && !l.contains("ps -axo")) + .map(str::to_string) + .collect() +} + +/// `cmd` started with SIGINT at its default, as from a terminal (these tests +/// may run with it ignored), or ignored when `ignore_sigint`. +#[cfg(unix)] +fn start_with_sigint(mut cmd: Command, ignore_sigint: bool) -> StopOnDrop { + use std::os::unix::process::CommandExt; + let disposition = if ignore_sigint { libc::SIG_IGN } else { libc::SIG_DFL }; + unsafe { + cmd.pre_exec(move || { + libc::signal(libc::SIGINT, disposition); + Ok(()) + }); + } + StopOnDrop(cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn().unwrap()) +} + +#[cfg(unix)] +fn pid_in(file: &Path) -> i32 { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Some(pid) = std::fs::read_to_string(file).ok().and_then(|t| t.trim().parse().ok()) { + return pid; + } + assert!(Instant::now() < deadline, "the stand-in never started its child"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(unix)] +fn exit_of(child: &mut std::process::Child, within: Duration) -> std::process::ExitStatus { + let deadline = Instant::now() + within; + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "still running after {within:?}"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Nothing whose command line holds `marker` (the stand-in's child) or the +/// stand-ins' folder is left running. +#[cfg(unix)] +fn nothing_left(marker: u32, bin: &Path, row: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let mut left = running(&format!("sleep {marker}")); + left.extend(running(&bin.display().to_string())); + if left.is_empty() { + return; + } + assert!(Instant::now() < deadline, "{row}: left running: {left:?}"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(unix)] +#[test] +fn an_interrupt_during_each_agent_command_stops_it_and_exits_as_interrupted() { + let mut rows: Vec<(String, &str, &[&str], Vec, i32)> = Vec::new(); + for command in INSTALLS { + for (signal, code) in [(libc::SIGINT, 130), (libc::SIGTERM, 143)] { + rows.push((format!("signal {signal} during `{command}`"), command, &["setup", "--yes"], vec![signal], code)); + } + } + rows.push(("two SIGINTs in a row".into(), INSTALLS[1], &["setup", "--yes"], vec![libc::SIGINT; 2], 130)); + rows.push(("two SIGTERMs in a row".into(), INSTALLS[5], &["setup", "--yes"], vec![libc::SIGTERM; 2], 143)); + rows.push(("SIGINT during `onboard setup`".into(), INSTALLS[3], &["onboard", "setup"], vec![libc::SIGINT], 130)); + for (n, (row, command, args, signals, code)) in rows.into_iter().enumerate() { + let marker = 3200 + n as u32; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let path = hanging_stand_ins(bin.path(), command, marker); + let mut cmd = cli(home.path(), cwd.path(), OFFLINE, args); + cmd.env("PATH", &path); + let mut setup = start_with_sigint(cmd, false); + let sleep = pid_in(&bin.path().join("pid")); + let _sleep = KillOnDrop(sleep); + for signal in &signals { + unsafe { libc::kill(setup.0.id() as i32, *signal) }; + } + let status = exit_of(&mut setup.0, Duration::from_secs(10)); + assert_eq!(status.code(), Some(code), "{row}: {status:?}"); + nothing_left(marker, bin.path(), &row); + } +} + +#[cfg(unix)] +#[test] +fn with_sigint_ignored_an_interrupt_changes_nothing_and_sigterm_still_stops_it() { + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let marker = 3300; + let path = hanging_stand_ins(bin.path(), INSTALLS[1], marker); + let mut cmd = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]); + cmd.env("PATH", &path); + let mut setup = start_with_sigint(cmd, true); + let sleep = pid_in(&bin.path().join("pid")); + let _sleep = KillOnDrop(sleep); + unsafe { libc::kill(setup.0.id() as i32, libc::SIGINT) }; + // Ignored: for a while, both keep running. + let until = Instant::now() + Duration::from_millis(300); + while Instant::now() < until { + assert!(setup.0.try_wait().unwrap().is_none(), "setup stopped on an ignored SIGINT"); + assert_eq!(unsafe { libc::kill(sleep, 0) }, 0, "the agent command stopped on an ignored SIGINT"); + std::thread::sleep(Duration::from_millis(20)); + } + unsafe { libc::kill(setup.0.id() as i32, libc::SIGTERM) }; + let status = exit_of(&mut setup.0, Duration::from_secs(10)); + assert_eq!(status.code(), Some(143), "{status:?}"); + nothing_left(marker, bin.path(), "SIGTERM after an ignored SIGINT"); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_interrupt_with_no_agent_command_running_ends_the_cli_as_before() { + use std::os::unix::process::ExitStatusExt; + for signal in [libc::SIGINT, libc::SIGTERM] { + let server = MockServer::start().await; + // The account request `onboard setup` makes first, held open. + Mock::given(method("GET")) + .and(path("/v1/user")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30))) + .mount(&server) + .await; + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let record = bin.path().join("ran"); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let mut cmd = cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]); + cmd.env("PATH", &path); + let mut setup = start_with_sigint(cmd, false); + let deadline = Instant::now() + Duration::from_secs(20); + while server.received_requests().await.unwrap().is_empty() { + assert!(Instant::now() < deadline, "the account request never came"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + unsafe { libc::kill(setup.0.id() as i32, signal) }; + let status = exit_of(&mut setup.0, Duration::from_secs(10)); + assert_eq!(status.signal(), Some(signal), "signal {signal}: {status:?}"); + assert!(recorded(&record).is_empty(), "signal {signal}: no agent command ran"); + } +} From 174828edc5a38ad5653b620a77963406c6643ce0 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 19:38:31 -0500 Subject: [PATCH 09/32] ci: run setup on Windows against stand-in Claude Code and Codex, including a failed install Both the Git Bash and the PowerShell steps now check that setup without --yes runs nothing, that a stand-in codex.cmd gets its three commands in order, and that a claude.cmd refusing plugin install makes setup --yes exit 1 and show the error. --- .github/workflows/windows-smoke.yml | 144 ++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 38 deletions(-) diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index b2529e7a..fabda2f2 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -53,46 +53,77 @@ jobs: # npm installs agents as .cmd shims, often under a user folder such as # `C:\Users\Ann (Work)`, whose parentheses break a hand-built `cmd /C`. - - name: Set up a coding agent installed in a folder with parentheses + - name: Set up coding agents installed in a folder with parentheses if: github.event_name == 'pull_request' run: | set -euo pipefail + base_path="$PATH" bin="$(mktemp -d)/agents (test) dir" mkdir -p "$bin" - log=$(cygpath -w "$bin/ran.log") - # A stand-in `claude` that logs its arguments, one line per run. - cat > "$bin/claude.cmd" < "$1/$2.cmd" <>"$log" + echo %*>>"$(cygpath -w "$1/$2.log")" + $3 exit /b 0 EOF - export PATH="$bin:$PATH" + } + stand_in "$bin" claude "" + stand_in "$bin" codex "" + failing="$(mktemp -d)/failing agent" + mkdir -p "$failing" + stand_in "$failing" claude 'echo %* | findstr /C:"plugin install" >nul && (>&2 echo install refused& exit /b 1)' for var in USERPROFILE HOME CLAUDE_CONFIG_DIR CODEX_HOME; do export "$var=$(cygpath -w "$(mktemp -d)")" done unset CLAUDE_CODE_EXECPATH + # Quotes std may add around an argument, and cmd's line endings, aside. + ran() { tr -d '"\r' < "$1" 2>/dev/null || true; } + expect() { + if [[ "$(ran "$1")" != "$2" ]]; then + echo "::error::$3, got:" + ran "$1" + exit 1 + fi + } + + export PATH="$bin:$base_path" + elevenlabs setup + expect "$bin/claude.log" "" "setup without --yes ran a Claude Code command" + expect "$bin/codex.log" "" "setup without --yes ran a Codex command" elevenlabs setup --yes - # Quotes std may add around an argument, and cmd's line endings, aside. - ran=$(tr -d '"\r' < "$bin/ran.log") - expected=$(printf '%s\n' \ + expect "$bin/claude.log" "$(printf '%s\n' \ "plugin marketplace update elevenlabs" \ "plugin install elevenlabs --marketplace elevenlabs/plugin --scope user" \ - "plugin update elevenlabs@elevenlabs") - if [[ "$ran" != "$expected" ]]; then - echo "::error::Expected the three Claude Code commands, got:" - echo "$ran" + "plugin update elevenlabs@elevenlabs")" "Expected the three Claude Code commands" + expect "$bin/codex.log" "$(printf '%s\n' \ + "plugin marketplace add elevenlabs/plugin" \ + "plugin marketplace upgrade elevenlabs" \ + "plugin add elevenlabs@elevenlabs")" "Expected the three Codex commands" + + export PATH="$failing:$base_path" + code=0 + out=$(elevenlabs setup --yes) || code=$? + echo "$out" + if [[ "$code" != 1 ]]; then + echo "::error::setup --yes with a failing install exited with $code, not 1" exit 1 fi - - elevenlabs setup - if [[ "$(tr -d '"\r' < "$bin/ran.log")" != "$ran" ]]; then - echo "::error::setup without --yes ran a command" + refused='Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` exited with 1: install refused' + if [[ "$out" != *"$refused"* ]]; then + echo "::error::setup --yes didn't show the install's error" exit 1 fi + expect "$failing/claude.log" "$(printf '%s\n' \ + "plugin marketplace update elevenlabs" \ + "plugin install elevenlabs --marketplace elevenlabs/plugin --scope user")" \ + "Expected Claude Code to stop at the failed install" # PowerShell and cmd pass the search path as `Path`; Git Bash passes `PATH`. - - name: Set up a coding agent from PowerShell + - name: Set up coding agents from PowerShell if: github.event_name == 'pull_request' shell: pwsh run: | @@ -100,41 +131,78 @@ jobs: function New-TempFolder { New-Item -ItemType Directory (Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid())) } + # A stand-in that logs its arguments, one line per run, and runs $also first. + function New-StandIn($folder, $name, $also) { + Set-Content -Encoding ascii -Path (Join-Path $folder "$name.cmd") -Value @( + '@echo off' + "echo %*>>`"$(Join-Path $folder "$name.log")`"" + $also + 'exit /b 0' + ) + } + # What a stand-in ran, quotes std may add around an argument aside. + function Get-Ran($folder, $name) { + $log = Join-Path $folder "$name.log" + if (-not (Test-Path $log)) { return '' } + @(Get-Content $log | ForEach-Object { $_.Replace('"', '').Trim() }) -join "`n" + } + function Assert-Ran($folder, $name, $expected, $what) { + $ran = Get-Ran $folder $name + if ($ran -cne ($expected -join "`n")) { + Write-Host "::error::$what, got:" + Write-Host $ran + exit 1 + } + } $bin = (New-TempFolder).FullName - $log = Join-Path $bin 'ran.log' - # A stand-in `claude` that logs its arguments, one line per run. - Set-Content -Encoding ascii -Path (Join-Path $bin 'claude.cmd') -Value @( - '@echo off' - "echo %*>>`"$log`"" - 'exit /b 0' - ) + New-StandIn $bin 'claude' '' + New-StandIn $bin 'codex' '' + $failing = (New-TempFolder).FullName + New-StandIn $failing 'claude' 'echo %* | findstr /C:"plugin install" >nul && (>&2 echo install refused& exit /b 1)' + $basePath = $env:Path # Spelled `Path` whatever the runner passed. - $searchPath = "$bin;$env:Path" - Remove-Item env:Path - [Environment]::SetEnvironmentVariable('Path', $searchPath) + function Set-SearchPath($first) { + Remove-Item env:Path + [Environment]::SetEnvironmentVariable('Path', "$first;$basePath") + } foreach ($var in 'USERPROFILE', 'HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME') { Set-Item "env:$var" (New-TempFolder).FullName } Remove-Item env:CLAUDE_CODE_EXECPATH -ErrorAction SilentlyContinue + Set-SearchPath $bin $name = [Environment]::GetEnvironmentVariables().Keys | Where-Object { $_ -ieq 'path' } Write-Host "The search path variable is spelled: $name" if ($name -cne 'Path') { throw "Expected the search path variable spelled Path, got $name" } + elevenlabs setup + if ($LASTEXITCODE -ne 0) { throw "setup exited with $LASTEXITCODE" } + Assert-Ran $bin 'claude' @() 'setup without --yes ran a Claude Code command' + Assert-Ran $bin 'codex' @() 'setup without --yes ran a Codex command' + elevenlabs setup --yes if ($LASTEXITCODE -ne 0) { throw "setup --yes exited with $LASTEXITCODE" } - if (-not (Test-Path $log)) { throw 'setup --yes ran no Claude Code command' } - # Quotes std may add around an argument aside. - $ran = @(Get-Content $log | ForEach-Object { $_.Replace('"', '').Trim() }) -join "`n" - $expected = @( + Assert-Ran $bin 'claude' @( 'plugin marketplace update elevenlabs' 'plugin install elevenlabs --marketplace elevenlabs/plugin --scope user' 'plugin update elevenlabs@elevenlabs' - ) -join "`n" - if ($ran -cne $expected) { - Write-Host "::error::Expected the three Claude Code commands, got:" - Write-Host $ran - exit 1 - } + ) 'Expected the three Claude Code commands' + Assert-Ran $bin 'codex' @( + 'plugin marketplace add elevenlabs/plugin' + 'plugin marketplace upgrade elevenlabs' + 'plugin add elevenlabs@elevenlabs' + ) 'Expected the three Codex commands' + + Set-SearchPath $failing + $out = (elevenlabs setup --yes) -join "`n" + $code = $LASTEXITCODE + Write-Host $out + if ($code -ne 1) { throw "setup --yes with a failing install exited with $code, not 1" } + $refused = 'Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` exited with 1: install refused' + if (-not $out.Contains($refused)) { throw "setup --yes didn't show the install's error" } + Assert-Ran $failing 'claude' @( + 'plugin marketplace update elevenlabs' + 'plugin install elevenlabs --marketplace elevenlabs/plugin --scope user' + ) 'Expected Claude Code to stop at the failed install' - name: Set up Node.js uses: actions/setup-node@v6 From b4bb977f4d66ef3d81cd17b25c53dec4bbd5ca70 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 19:49:01 -0500 Subject: [PATCH 10/32] fix: a setup command stopped at its limit leaves nothing running, even one it was just starting A program being started as its group was killed could miss the signal and keep running. Once the stopped command is reaped, its group is now killed until it is empty. Found by fifty_runs_in_a_row_leave_no_process_behind, which failed in each of three full test runs. --- cli/elevenlabs/workflow/setup.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 337fda49..23ea0428 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -274,6 +274,15 @@ fn stop_all(child: &mut std::process::Child) { if let Ok(group) = i32::try_from(child.id()) { // The child leads its own group, so this reaches only what it started. unsafe { libc::kill(-group, libc::SIGKILL) }; + // A program being started as the group is killed can miss the signal, + // so once the child is reaped, the group is killed until it is empty. + let _ = child.wait(); + for _ in 0..100 { + if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } } #[cfg(windows)] let _ = Command::new("taskkill") From 715ceb93f0ac925c4095357d3aefd7b022eaddc4 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:31 -0500 Subject: [PATCH 11/32] test: setup and onboarding tests can run twice at once on one machine --- cli/elevenlabs/workflow/setup/tests.rs | 35 +++++-- tests/onboard_test.rs | 131 ++++++++++++++++++------- 2 files changed, 124 insertions(+), 42 deletions(-) diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index e9ebcdb2..366a2c84 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -318,15 +318,34 @@ fn agents_not_reached_in_time_count_as_failed() { assert!(outcomes[2].ok, "Cursor runs nothing"); } +/// A shell script at `dir/name`, written by a short-lived `sh`, never by this +/// process. Tests run as threads of one process: if one forks while another +/// holds a script open for writing, the forked child keeps that descriptor +/// until it execs, and Linux then refuses to start the script with "Text +/// file busy" (rust-lang/rust#114554). A file this process never opens can't +/// be held that way. #[cfg(unix)] fn script(dir: &Path, name: &str, body: &str) -> PathBuf { - use std::os::unix::fs::PermissionsExt; let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let status = Command::new("/bin/sh") + .args(["-c", "printf '%s' \"$1\" > \"$2\" && chmod 755 \"$2\"", "sh"]) + .arg(format!("#!/bin/sh\n{body}\n")) + .arg(&path) + .stdin(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "writing {}", path.display()); path } +/// `sleep .`: a command line no other run of +/// these tests on this machine has, for finding what a test left running. +/// The id has a fixed width, so one marker never contains another. +#[cfg(unix)] +fn sleep_marker(seconds: u32) -> String { + format!("sleep {seconds}.{:010}", std::process::id()) +} + #[cfg(unix)] #[test] fn a_command_past_its_limit_is_stopped_and_fails() { @@ -1410,7 +1429,8 @@ fn a_command_past_its_limit_is_stopped_with_its_children_and_grandchildren() { dir.path(), "codex", &format!( - "sh -c 'sleep 3011 & echo $! > \"{g}\"; wait' &\necho $! > '{c}'\nwait", + "sh -c '{sleep} & echo $! > \"{g}\"; wait' &\necho $! > '{c}'\nwait", + sleep = sleep_marker(3011), g = grandchild.display(), c = child.display() ), @@ -1424,7 +1444,7 @@ fn a_command_past_its_limit_is_stopped_with_its_children_and_grandchildren() { assert!(started.elapsed() < limit + Duration::from_secs(3), "{:?}", started.elapsed()); assert!(gone(child), "the child {child} still runs"); assert!(gone(grandchild), "the grandchild {grandchild} still runs"); - assert_eq!(left_behind("sleep 3011"), Vec::::new()); + assert_eq!(left_behind(&sleep_marker(3011)), Vec::::new()); assert_eq!(left_behind(&hangs.display().to_string()), Vec::::new()); } @@ -1434,7 +1454,8 @@ fn fifty_runs_in_a_row_leave_no_process_behind() { let dir = tempfile::tempdir().unwrap(); let works = script(dir.path(), "works", "echo fine"); let fails = script(dir.path(), "fails", "echo broke >&2; exit 1"); - let hangs = script(dir.path(), "hangs", "sh -c 'sleep 3012' &\nsleep 3012 &\nwait"); + let sleep = sleep_marker(3012); + let hangs = script(dir.path(), "hangs", &format!("sh -c '{sleep}' &\n{sleep} &\nwait")); for run in 0..50 { let (program, limit, want) = match run % 3 { 0 => (&works, Duration::from_secs(10), Ok(())), @@ -1445,7 +1466,7 @@ fn fifty_runs_in_a_row_leave_no_process_behind() { } let deadline = Instant::now() + Duration::from_secs(5); loop { - let mut left = left_behind("sleep 3012"); + let mut left = left_behind(&sleep); left.extend(left_behind(&dir.path().display().to_string())); if left.is_empty() { break; diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 47765c34..639f7ec3 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -78,7 +78,7 @@ fn all_output(output: &Output) -> String { /// Run and parse stdout as the reply, failing with both streams when the CLI did not succeed. fn reply(cmd: &mut Command) -> Value { let output = cmd.output().unwrap(); - assert!(output.status.success(), "{}", all_output(&output)); + assert!(output.status.success(), "{}{}", all_output(&output), worker_log_named_in(&output)); assert!( !all_output(&output).contains(KEY), "the key never appears in the output" @@ -94,6 +94,20 @@ fn reply(cmd: &mut Command) -> Value { serde_json::from_slice(&output.stdout).expect("stdout is JSON") } +/// The sign-in worker's log a failed command points at, since its folder is +/// gone once the test ends. +fn worker_log_named_in(output: &Output) -> String { + let text = all_output(output); + let Some(log) = text.split("see ").nth(1).and_then(|t| t.split('"').next()) else { + return String::new(); + }; + if !log.ends_with("worker.log") { + return String::new(); + } + let kept = std::fs::read_to_string(log).unwrap_or_else(|e| format!("({e})")); + format!("\n--- {log} ---\n{kept}") +} + fn say(reply: &Value) -> &str { reply["say"].as_str().unwrap_or_default() } @@ -1385,10 +1399,45 @@ fn link(reply: &Value) -> String { say[at..].trim().to_string() } -/// The tests that sign in take turns: each listens on the callback ports. -async fn signing_in() -> tokio::sync::MutexGuard<'static, ()> { +/// The tests that sign in take turns, on the whole machine: each listens on +/// the callback ports, which two runs of this suite would otherwise share. +struct SigningIn(#[allow(dead_code)] tokio::sync::MutexGuard<'static, ()>, std::fs::File); + +impl Drop for SigningIn { + fn drop(&mut self) { + let _ = self.1.unlock(); + } +} + +async fn signing_in() -> SigningIn { static PORTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - PORTS.lock().await + let turn = PORTS.lock().await; + let path = std::env::temp_dir().join("elevenlabs-cli-onboard-test-sign-in.lock"); + let file = tokio::task::spawn_blocking(move || { + let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path).unwrap(); + file.lock().unwrap(); + file + }) + .await + .unwrap(); + SigningIn(turn, file) +} + +/// Waits a while for the callback ports to be free. When they never are, it +/// fails on CI, where a skipped sign-in test would go unseen, and says so. +async fn callback_ports_still_busy() -> bool { + let deadline = Instant::now() + Duration::from_secs(30); + while callback_ports_busy() { + if Instant::now() >= deadline { + assert!( + std::env::var_os("CI").is_none(), + "the callback ports 8482 to 8484 are busy, so this sign-in test can't run" + ); + return true; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + false } /// Something already listens on the sign-in's callback ports (another sign-in @@ -1748,6 +1797,22 @@ fn session_dir(home: &Path, cwd: &Path) -> PathBuf { home.join(".elevenlabs").join("onboard").join(id) } +/// A program at `path`, written by a short-lived `sh`, never by this process. +/// Tests run as threads of one process: if one forks while another holds a +/// script open for writing, the forked child keeps that descriptor until it +/// execs, and Linux then refuses to start the script with "Text file busy" +/// (rust-lang/rust#114554). A file this process never opens can't be held. +#[cfg(unix)] +fn program(path: &Path, text: &str) { + let status = Command::new("/bin/sh") + .args(["-c", "printf '%s' \"$1\" > \"$2\" && chmod 755 \"$2\"", "sh", text]) + .arg(path) + .stdin(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "writing {}", path.display()); +} + /// What a sign-in leaves: the `setup` offer is due. fn setup_offer_due(home: &Path, cwd: &Path) -> PathBuf { let dir = session_dir(home, cwd); @@ -1762,7 +1827,6 @@ fn setup_offer_due(home: &Path, cwd: &Path) -> PathBuf { /// contain `fail`. Returns a PATH with only them and the system's tools. #[cfg(unix)] fn stand_ins(dir: &Path, names: &[&str], record: &Path, fail: Option<&str>) -> String { - use std::os::unix::fs::PermissionsExt; for name in names { let fails = match fail { Some(args) => format!( @@ -1774,9 +1838,7 @@ fn stand_ins(dir: &Path, names: &[&str], record: &Path, fail: Option<&str>) -> S "#!/bin/sh\necho \"{name} $*\" >> '{}'\n{fails}exit 0\n", record.display() ); - let path = dir.join(name); - std::fs::write(&path, script).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + program(&dir.join(name), &script); } format!("{}:/usr/bin:/bin", dir.display()) } @@ -2134,18 +2196,15 @@ impl Drop for StopOnDrop { #[cfg(unix)] #[test] fn ctrl_c_stops_the_agent_command_and_what_it_started() { - use std::os::unix::fs::PermissionsExt; use std::os::unix::process::CommandExt; let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let pid = bin.path().join("pid"); let claude = bin.path().join("claude"); - std::fs::write( + program( &claude, - format!("#!/bin/sh\nsleep 300 &\necho $! > '{0}.new'\nmv '{0}.new' '{0}'\nwait\n", pid.display()), - ) - .unwrap(); - std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + &format!("#!/bin/sh\nsleep 300 &\necho $! > '{0}.new'\nmv '{0}.new' '{0}'\nwait\n", pid.display()), + ); let mut cmd = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]); cmd.env("PATH", format!("{}:/usr/bin:/bin", bin.path().display())); // SIGINT at its default, as from a terminal, even where these tests run with it ignored. @@ -2280,7 +2339,7 @@ fn sign_in_with(home: &Path, cwd: &Path, server: &str, params: &str, key_status: #[tokio::test] async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones() { let _turn = signing_in().await; - if callback_ports_busy() { + if callback_ports_still_busy().await { eprintln!("skipped: another sign-in is listening on the callback ports"); return; } @@ -2552,21 +2611,17 @@ fn setup_through_the_binary_ten_runs_in_a_row_and_two_at_once() { #[cfg(unix)] #[test] fn setup_starts_agents_from_the_shells_environment_never_the_projects_env() { - use std::os::unix::fs::PermissionsExt; let (home, cwd) = temp(); let (bin, project_bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); let record = bin.path().join("ran"); for (dir, said) in [(bin.path(), "shell's"), (project_bin.path(), "project's")] { - let claude = dir.join("claude"); - std::fs::write( - &claude, - format!( + program( + &dir.join("claude"), + &format!( "#!/bin/sh\necho \"{said} claude $* [${{ELEVENLABS_SETUP_TEST:-unset}}]\" >> '{}'\n", record.display() ), - ) - .unwrap(); - std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + ); } write( cwd.path(), @@ -2607,7 +2662,7 @@ fn run_args(reply: &Value) -> Vec { #[tokio::test] async fn a_whole_flow_signs_in_tests_and_sets_up_offering_once_per_sign_in() { let _turn = signing_in().await; - if callback_ports_busy() { + if callback_ports_still_busy().await { eprintln!("skipped: another sign-in is listening on the callback ports"); return; } @@ -2830,8 +2885,7 @@ async fn with_no_sign_in_every_reply_is_exactly_the_base_commits() { /// `hang_on`, which starts `sleep `, writes its pid to `pid`, and /// waits. Returns a PATH with only them and the system's tools. #[cfg(unix)] -fn hanging_stand_ins(dir: &Path, hang_on: &str, marker: u32) -> String { - use std::os::unix::fs::PermissionsExt; +fn hanging_stand_ins(dir: &Path, hang_on: &str, marker: &str) -> String { let pid = dir.join("pid"); for name in ["claude", "codex"] { let script = format!( @@ -2839,8 +2893,7 @@ fn hanging_stand_ins(dir: &Path, hang_on: &str, marker: u32) -> String { mv '{0}.new' '{0}'\n wait\nfi\nexit 0\n", pid.display() ); - std::fs::write(dir.join(name), script).unwrap(); - std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o755)).unwrap(); + program(&dir.join(name), &script); } format!("{}:/usr/bin:/bin", dir.display()) } @@ -2895,10 +2948,18 @@ fn exit_of(child: &mut std::process::Child, within: Duration) -> std::process::E } } +/// A `sleep` time no other run of these tests on this machine uses, so what +/// one run leaves is never taken for another's: `.`, the id at a fixed width so one marker never contains another. +#[cfg(unix)] +fn marker(seconds: u32) -> String { + format!("{seconds}.{:010}", std::process::id()) +} + /// Nothing whose command line holds `marker` (the stand-in's child) or the /// stand-ins' folder is left running. #[cfg(unix)] -fn nothing_left(marker: u32, bin: &Path, row: &str) { +fn nothing_left(marker: &str, bin: &Path, row: &str) { let deadline = Instant::now() + Duration::from_secs(5); loop { let mut left = running(&format!("sleep {marker}")); @@ -2924,10 +2985,10 @@ fn an_interrupt_during_each_agent_command_stops_it_and_exits_as_interrupted() { rows.push(("two SIGTERMs in a row".into(), INSTALLS[5], &["setup", "--yes"], vec![libc::SIGTERM; 2], 143)); rows.push(("SIGINT during `onboard setup`".into(), INSTALLS[3], &["onboard", "setup"], vec![libc::SIGINT], 130)); for (n, (row, command, args, signals, code)) in rows.into_iter().enumerate() { - let marker = 3200 + n as u32; + let marker = marker(3200 + n as u32); let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); - let path = hanging_stand_ins(bin.path(), command, marker); + let path = hanging_stand_ins(bin.path(), command, &marker); let mut cmd = cli(home.path(), cwd.path(), OFFLINE, args); cmd.env("PATH", &path); let mut setup = start_with_sigint(cmd, false); @@ -2938,7 +2999,7 @@ fn an_interrupt_during_each_agent_command_stops_it_and_exits_as_interrupted() { } let status = exit_of(&mut setup.0, Duration::from_secs(10)); assert_eq!(status.code(), Some(code), "{row}: {status:?}"); - nothing_left(marker, bin.path(), &row); + nothing_left(&marker, bin.path(), &row); } } @@ -2947,8 +3008,8 @@ fn an_interrupt_during_each_agent_command_stops_it_and_exits_as_interrupted() { fn with_sigint_ignored_an_interrupt_changes_nothing_and_sigterm_still_stops_it() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); - let marker = 3300; - let path = hanging_stand_ins(bin.path(), INSTALLS[1], marker); + let marker = marker(3300); + let path = hanging_stand_ins(bin.path(), INSTALLS[1], &marker); let mut cmd = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]); cmd.env("PATH", &path); let mut setup = start_with_sigint(cmd, true); @@ -2965,7 +3026,7 @@ fn with_sigint_ignored_an_interrupt_changes_nothing_and_sigterm_still_stops_it() unsafe { libc::kill(setup.0.id() as i32, libc::SIGTERM) }; let status = exit_of(&mut setup.0, Duration::from_secs(10)); assert_eq!(status.code(), Some(143), "{status:?}"); - nothing_left(marker, bin.path(), "SIGTERM after an ignored SIGINT"); + nothing_left(&marker, bin.path(), "SIGTERM after an ignored SIGINT"); } #[cfg(unix)] From 2b6fdd79d8529f543185c81c96e4bf22fb026723 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:34 -0500 Subject: [PATCH 12/32] fix: setup shows a short, clean error however much a failed install printed --- cli/elevenlabs/workflow/setup.rs | 79 ++++++++++++++++++++++--- cli/elevenlabs/workflow/setup/tests.rs | 81 ++++++++++++++++++++++++++ tests/onboard_test.rs | 60 +++++++++++++++++++ 3 files changed, 211 insertions(+), 9 deletions(-) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 23ea0428..af938840 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -358,17 +358,78 @@ fn seconds(d: Duration) -> String { } } -/// The last line with text in a program's captured output. +/// How much of a program's captured output is read for its last line. +const TAIL: u64 = 64 * 1024; +/// The longest error line `setup` repeats: the coding agent reads all of it. +const LINE_MAX: usize = 300; + +/// The last line with text in a program's captured output, read from its +/// last [`TAIL`] bytes only, however much it printed. fn last_line(mut file: std::fs::File) -> Option { - use std::io::{Read, Seek}; - file.seek(std::io::SeekFrom::Start(0)).ok()?; + use std::io::{Read, Seek, SeekFrom}; + let len = file.seek(SeekFrom::End(0)).ok()?; + let from = len.saturating_sub(TAIL); + file.seek(SeekFrom::Start(from)).ok()?; let mut bytes = Vec::new(); - file.read_to_end(&mut bytes).ok()?; - String::from_utf8_lossy(&bytes) - .lines() - .map(str::trim) - .rfind(|l| !l.is_empty()) - .map(str::to_string) + file.take(TAIL).read_to_end(&mut bytes).ok()?; + // Cut mid-file, the tail can start inside a character. + let partial = if from > 0 { + bytes.iter().take(3).take_while(|b| (0x80..0xc0).contains(*b)).count() + } else { + 0 + }; + clean_last_line(&bytes[partial..]) +} + +/// The last line with text in `bytes`, where a line ends at `\n` or `\r` (a +/// progress bar redraws itself after `\r`), without terminal escapes or other +/// control characters, and at most [`LINE_MAX`] characters long. +fn clean_last_line(bytes: &[u8]) -> Option { + let text = without_escapes(&String::from_utf8_lossy(bytes)); + let line = text.split(['\n', '\r']).map(str::trim).rfind(|l| !l.is_empty())?; + Some(match line.char_indices().nth(LINE_MAX - 1) { + Some((at, _)) if line[at..].chars().nth(1).is_some() => format!("{}…", &line[..at]), + _ => line.to_string(), + }) +} + +/// `text` without CSI and OSC escape sequences (colours, titles, links) or +/// control characters other than line ends; a tab becomes a space. +fn without_escapes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + match c { + '\u{1b}' => match chars.next() { + // Parameters, then one final character from @ to ~. + Some('[') => { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + // Up to BEL or ESC \. + Some(']') => { + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' { + chars.next(); + break; + } + } + } + _ => {} + }, + '\n' | '\r' => out.push(c), + '\t' => out.push(' '), + c if c.is_control() => {} + c => out.push(c), + } + } + out } /// Install the plugin into each agent with `run`, which gets the program, diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index 366a2c84..c92ecf69 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1475,3 +1475,84 @@ fn fifty_runs_in_a_row_leave_no_process_behind() { std::thread::sleep(Duration::from_millis(20)); } } + +// ── the error line a failed command leaves ────────────────────────── + +#[test] +fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characters() { + let long = "x".repeat(5_000_000); + let cut = format!("{}…", "x".repeat(LINE_MAX - 1)); + // A two-byte character where a cut by bytes would split it. + let straddles = format!("{}éz", "a".repeat(LINE_MAX - 1)); + let straddles_cut = format!("{}…", "a".repeat(LINE_MAX - 1)); + let exactly = "b".repeat(LINE_MAX); + // (row, the output, the line) + let rows: Vec<(&str, Vec, Option)> = vec![ + ("5 MB with no newline", long.clone().into_bytes(), Some(cut.clone())), + ("megabytes, then a short last line", format!("{long}\nthe real error\n").into_bytes(), Some("the real error".into())), + ( + "a progress bar, then a coloured error", + b"downloading 10%\rdownloading 50%\rdownloading 100%\r\n\x1b[31merror:\x1b[0m \x1b[1mno such plugin\x1b[22m\r\n".to_vec(), + Some("error: no such plugin".into()), + ), + ("a progress bar redrawn last", b"done one\nstep 1/3\rstep 2/3\rstep 3/3".to_vec(), Some("step 3/3".into())), + ("a window title and a link", b"\x1b]0;claude\x07\x1b]8;;https://x.test\x1b\\link\x1b]8;;\x1b\\ failed".to_vec(), Some("link failed".into())), + ("a tab and a bell", b"one\ttwo\x07\x08three".to_vec(), Some("one twothree".into())), + ("a multi-byte character straddling the cut", straddles.into_bytes(), Some(straddles_cut)), + ("exactly 300 characters", exactly.clone().into_bytes(), Some(exactly)), + ("bytes that are not UTF-8", b"caf\xe9 \xff broke\n".to_vec(), Some("caf\u{FFFD} \u{FFFD} broke".into())), + ("empty output", Vec::new(), None), + ("only blank lines and escape codes", b"\n \r\n\x1b[0m\x1b[2K\r\x1b]0;t\x07\n\t\n".to_vec(), None), + ]; + for (row, output, want) in rows { + let got = clean_last_line(&output); + assert_eq!(got, want, "{row}"); + let got = got.unwrap_or_default(); + assert!(got.chars().count() <= LINE_MAX, "{row}: {} characters", got.chars().count()); + assert!(!got.chars().any(char::is_control), "{row}: {got:?}"); + } +} + +#[test] +fn only_the_tail_of_a_capture_is_read_and_a_character_cut_by_it_is_dropped() { + use std::io::Write; + let mut file = tempfile::tempfile().unwrap(); + // 10 MB, then a last line whose first character is cut by the tail. + file.write_all(&vec![b'z'; 10_000_000]).unwrap(); + file.write_all(b"\n").unwrap(); + let mut line = "é".to_string(); + line.push_str(&"q".repeat(TAIL as usize - 1)); + file.write_all(line.as_bytes()).unwrap(); + let got = last_line(file).unwrap(); + assert_eq!(got, format!("{}…", "q".repeat(LINE_MAX - 1))); +} + +#[cfg(unix)] +#[test] +fn a_failed_command_reports_a_short_clean_line_whatever_it_printed() { + let dir = tempfile::tempdir().unwrap(); + // (row, the stand-in's script, what run_program returns) + let rows: [(&str, &str, String); 3] = [ + ( + "5 MB on stderr with no newline", + "head -c 5000000 /dev/zero | tr '\\0' e >&2; exit 1", + format!("exited with 1: {}…", "e".repeat(LINE_MAX - 1)), + ), + ( + "megabytes, then a short last line", + "head -c 5000000 /dev/zero | tr '\\0' e >&2; printf '\\nthe real error\\n' >&2; exit 1", + "exited with 1: the real error".into(), + ), + ( + "a progress bar, then a coloured error", + "i=0; while [ $i -lt 2000 ]; do printf 'step %d\\r' $i; i=$((i+1)); done; \ + printf '\\033[31merror:\\033[0m it broke\\r\\n' >&2; exit 2", + "exited with 2: error: it broke".into(), + ), + ]; + for (row, body, want) in rows { + let program = script(dir.path(), "claude", body); + assert_eq!(run_program(&program, &[], Duration::from_secs(20), dir.path()), Err(want), "{row}"); + } +} + diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 639f7ec3..b25a5e33 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2548,6 +2548,66 @@ fn setup_through_the_binary_with_a_stand_in_failing_at_each_step() { } } +/// The longest error line `setup` repeats. +const LINE_MAX: usize = 300; + +#[cfg(unix)] +#[tokio::test] +async fn a_failed_install_shows_a_short_clean_error_however_much_it_printed() { + let server = MockServer::start().await; + // (row, what the failing `claude plugin install` prints on stderr, the error line shown) + let rows = [ + ( + "5 MB with no newline", + "head -c 5000000 /dev/zero | tr '\\0' e >&2", + format!("{}…", "e".repeat(LINE_MAX - 1)), + ), + ( + "megabytes, then a short last line", + "head -c 5000000 /dev/zero | tr '\\0' e >&2; printf '\\nthe real error\\n' >&2", + "the real error".to_string(), + ), + ( + "a progress bar, then a coloured error", + "i=0; while [ $i -lt 3000 ]; do printf 'downloading %d%%\\r' $i >&2; i=$((i+1)); done; \ + printf '\\033[31merror:\\033[0m install refused\\r\\n' >&2", + "error: install refused".to_string(), + ), + ]; + for (row, prints, line) in rows { + let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + program( + &bin.path().join("claude"), + &format!("#!/bin/sh\ncase \"$*\" in *\"plugin install\"*) {prints}; exit 1;; esac\nexit 0\n"), + ); + let path = format!("{}:/usr/bin:/bin", bin.path().display()); + let failed = format!( + "Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` \ + exited with 1: {line}" + ); + + let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]).env("PATH", &path).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{row}"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout, format!("{failed}\nTo try again, run `elevenlabs setup --yes`.\n"), "{row}"); + assert!(stdout.len() < 1000, "{row}: {} bytes", stdout.len()); + + let out = cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]) + .env("PATH", &path) + .output() + .unwrap(); + assert!(out.status.success(), "{row}: {}", all_output(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout.trim_end().lines().count(), 1, "{row}: one line of JSON"); + assert!(stdout.len() < 2000, "{row}: {} bytes", stdout.len()); + assert!(!stdout.contains('\r') && !stdout.contains("\\r"), "{row}: {stdout}"); + assert!(!stdout.contains('\u{1b}') && !stdout.contains("\\u001b"), "{row}: {stdout}"); + let r: Value = serde_json::from_str(&stdout).unwrap(); + assert!(say(&r).starts_with(&format!("{failed}.")), "{row}: {r}"); + } +} + #[cfg(unix)] #[test] fn setup_through_the_binary_from_folders_with_spaces_and_non_ascii_names() { From f3b401747496525937b76437af7481ca7afdae26 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:46 -0500 Subject: [PATCH 13/32] fix: setup takes Claude Code from CLAUDE_CODE_EXECPATH only when it is a full path --- cli/elevenlabs/workflow/setup.rs | 3 ++- cli/elevenlabs/workflow/setup/tests.rs | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index af938840..9bf9a91d 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -106,9 +106,10 @@ pub(super) fn found( let mut agents = Vec::new(); // Inside Claude Code, including the desktop app, which may not put `claude` // on PATH. Claude Code installed by npm names `node` or `bun` there instead. + // A relative path would be found from the project, so it doesn't count. let claude = env("CLAUDE_CODE_EXECPATH") .map(PathBuf::from) - .filter(|p| p.is_file() && is_claude(p)) + .filter(|p| p.is_absolute() && p.is_file() && is_claude(p)) .or_else(|| on_path("claude")); if let Some(claude) = claude { agents.push(Agent::ClaudeCode(claude)); diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index c92ecf69..b088532f 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -762,15 +762,22 @@ fn claude_code_execpath_is_used_only_when_it_names_a_claude_file() { let node = script(base.path(), "node", "exit 0"); let bun = script(base.path(), "bun", "exit 0"); let missing = base.path().join("gone").join("claude"); + // Real files, named from this process's folder: a program started from + // the home folder, or on Windows from the project, would not be them. + let relative = relative_to_here(odd.parent().unwrap()).join("claude"); + let dot_relative = Path::new(".").join(&relative); // (row, CLAUDE_CODE_EXECPATH, the program it names when it counts) - let rows: [(&str, &Path, Option<&Path>); 6] = [ + let rows: [(&str, &Path, Option<&Path>); 9] = [ ("node", &node, None), ("bun", &bun, None), ("a missing file", &missing, None), ("a folder named claude", &named_folder, None), ("an empty value", Path::new(""), None), ("a claude in a folder with spaces and non-ASCII letters", &odd, Some(&odd)), + ("a relative path to a claude", &relative, None), + ("a relative path to a claude starting with ./", &dot_relative, None), + ("a bare claude", Path::new("claude"), None), ]; for (row, value, named) in rows { let env = env_of(&[("CLAUDE_CODE_EXECPATH", value.to_str().unwrap())]); From 1662a2849b53fcda00471905041a4b429fc8276d Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:48 -0500 Subject: [PATCH 14/32] fix: setup says the home folder is missing instead of blaming the agent --- cli/elevenlabs/workflow/setup.rs | 3 +++ cli/elevenlabs/workflow/setup/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 9bf9a91d..34c342ef 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -215,6 +215,9 @@ pub(super) fn run_program( (Ok(out), Ok(err)) => (out, err), (Err(e), _) | (_, Err(e)) => return Err(format!("couldn't start it: {e}")), }; + if !home.is_dir() { + return Err(format!("couldn't start it: the home folder {} is missing", home.display())); + } let mut command = Command::new(program); command .args(args) diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index b088532f..a8bc2029 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1563,3 +1563,15 @@ fn a_failed_command_reports_a_short_clean_line_whatever_it_printed() { } } +#[cfg(unix)] +#[test] +fn a_missing_home_folder_is_named_and_the_agent_is_not_blamed() { + let dir = tempfile::tempdir().unwrap(); + let program = script(dir.path(), "claude", "exit 0"); + let home = dir.path().join("gone"); + assert_eq!( + run_program(&program, &[], Duration::from_secs(5), &home), + Err(format!("couldn't start it: the home folder {} is missing", home.display())) + ); +} + From 16599ed234dc9227a25c6ae5144d8c609a79cfaf Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:50 -0500 Subject: [PATCH 15/32] test: setup with no agent found prints what the skills copy did --- cli/elevenlabs/workflow/setup.rs | 41 +++++++++++++++----------- cli/elevenlabs/workflow/setup/tests.rs | 36 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 34c342ef..d02ec047 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -503,8 +503,13 @@ fn install_timed( /// Everything `setup --yes` does: install into the agents found, or, with /// none, copy the skills. pub(super) fn run(agents: &[Agent], home: &Path, limits: Limits) -> Vec { + run_from(agents, home, limits, SKILLS_ARCHIVE) +} + +/// [`run`], downloading the skills from `skills_url`. +fn run_from(agents: &[Agent], home: &Path, limits: Limits, skills_url: &str) -> Vec { if agents.is_empty() { - return vec![skills(home)]; + return vec![skills_from(skills_url, home)]; } install(agents, limits, &|program, args, limit| { run_program(program, args, limit, home) @@ -597,12 +602,7 @@ fn skills_root(home: &Path) -> PathBuf { home.join(".agents").join("skills") } -/// Download the skills and copy them into `~/.agents/skills`. -fn skills(home: &Path) -> Outcome { - skills_from(SKILLS_ARCHIVE, home) -} - -/// [`skills`], downloading the archive from `url`. +/// Download the skills archive from `url` and copy them into `~/.agents/skills`. fn skills_from(url: &str, home: &Path) -> Outcome { let result = download_skills(url).and_then(|dir| copy_skills(&dir.path().join("skills"), home)); let root = skills_root(home); @@ -754,25 +754,32 @@ fn handle(matches: &clap::ArgMatches) -> Result<(), CliError> { println!("Nothing was changed. Run `{}` to do it.", later_command()); return Ok(()); } - let outcomes = run(&agents, &home, LIMITS); - for outcome in &outcomes { - println!("{}", result_line(outcome)); + let (text, failed) = report(&run(&agents, &home, LIMITS)); + print!("{text}"); + if failed { + std::process::exit(1); + } + Ok(()) +} + +/// What `setup --yes` prints for `outcomes`, and whether any failed. +fn report(outcomes: &[Outcome]) -> (String, bool) { + let mut text = String::new(); + for outcome in outcomes { + text += &format!("{}\n", result_line(outcome)); } let failed = outcomes.iter().any(|o| !o.ok); if failed { - println!("To try again, run `{}`.", later_command()); + text += &format!("To try again, run `{}`.\n", later_command()); } let finish: Vec = outcomes.iter().filter_map(finish).collect(); if !finish.is_empty() { - println!("\nTo finish:"); + text += "\nTo finish:\n"; for step in finish { - println!("- {step}"); + text += &format!("- {step}\n"); } } - if failed { - std::process::exit(1); - } - Ok(()) + (text, failed) } pub fn register(app: CliApp) -> CliApp { diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index a8bc2029..e111531a 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1575,3 +1575,39 @@ fn a_missing_home_folder_is_named_and_the_agent_is_not_blamed() { ); } +// ── setup --yes with no agent found ───────────────────────────────── + +/// `setup --yes` with no agent found, the skills downloaded from a server +/// standing in for GitHub: what it prints and whether it fails. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn with_no_agent_found_setup_prints_what_the_skills_copy_did() { + let empty = archive(&[("README.md", "readme")]); + // (row, how the server answers, whether it fails, what it prints) + let rows: [(&str, wiremock::ResponseTemplate, bool, &str); 3] = [ + ("a good archive", body(archive(TWO_SKILLS)), false, "Skills: copied 2 ElevenLabs skills into {root}\n"), + ( + "an archive with no skills", + body(empty), + true, + "Skills: failed: the download from {url} held no skills\n\ + To try again, run `elevenlabs setup --yes`.\n", + ), + ( + "a server error", + wiremock::ResponseTemplate::new(500), + true, + "Skills: failed: couldn't download the skills from {url}: HTTP status server error (500 Internal \ + Server Error) for url ({url})\nTo try again, run `elevenlabs setup --yes`.\n", + ), + ]; + for (row, answer, fails, says) in rows { + let (_server, url) = github(vec![("main.tar.gz", answer)]).await; + let home = tempfile::tempdir().unwrap(); + let root = skills_root(home.path()); + let (text, failed) = report(&run_from(&[], home.path(), LIMITS_FOR_TESTS, &url)); + assert_eq!(failed, fails, "{row}"); + let says = says.replace("{root}", &root.display().to_string()).replace("{url}", &url); + assert_eq!(text, says, "{row}"); + } +} From 0329b28ad77012c136ca32face3426d1363c5faa Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:52 -0500 Subject: [PATCH 16/32] test: the replies of a project that has not signed in say where they came from --- tests/onboard_test.rs | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index b25a5e33..92d1a8ed 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2324,7 +2324,7 @@ fn sign_in_with(home: &Path, cwd: &Path, server: &str, params: &str, key_status: &[&["onboard", "connect", "--no-browser", "--authorize-url", &authorize, "--telemetry", "off"], args].concat(), )); let url = link(&first); - assert!(!url.contains("setup_offer"), "the link is the base commit's: {url}"); + assert!(!url.contains("setup_offer"), "the sign-in link is as it always was: {url}"); callback(&url, params); let deadline = Instant::now() + Duration::from_secs(60); loop { @@ -2831,8 +2831,8 @@ async fn with_no_agent_found_the_offer_is_the_skills_and_its_run_keeps_the_flags } } -/// A reply with the paths and port of one run replaced, as the base -/// commit's replies in `tests/fixtures/no_sign_in_replies.json` are. +/// A reply with the paths and port of one run replaced, as the replies in +/// `tests/fixtures/no_sign_in_replies.json` are. fn normalised(reply: &Value, home: &Path, cwd: &Path, server: &str) -> Value { let mut text = reply.to_string(); let mut paths: Vec<(String, &str)> = Vec::new(); @@ -2865,12 +2865,25 @@ fn regex_lite_clip(text: &str) -> String { out + rest } +/// Set to rewrite `tests/fixtures/no_sign_in_replies.json` from the replies +/// [`a_project_that_has_not_signed_in_gets_the_replies_it_always_has`] collects. +const REWRITE_NO_SIGN_IN_REPLIES: &str = "ELEVENLABS_TEST_REWRITE_NO_SIGN_IN_REPLIES"; + +/// The offer is made only after a sign-in, so a project that has not signed +/// in must get every onboarding reply it got before the offer existed. +/// `tests/fixtures/no_sign_in_replies.json` holds those replies: for each +/// scenario below, the reply to each command in turn, as CLI 1.10.0 (commit +/// d697e6c) gave them, with the run's paths, server and clip name replaced as +/// [`normalised`] does. To regenerate it, build the CLI whose replies are +/// wanted and run this test with `ELEVENLABS_TEST_REWRITE_NO_SIGN_IN_REPLIES=1`: +/// it writes the replies it collected into the fixture before comparing. +/// Rewrite it only on purpose, when a change to those replies is meant. #[cfg(unix)] #[tokio::test] -async fn with_no_sign_in_every_reply_is_exactly_the_base_commits() { +async fn a_project_that_has_not_signed_in_gets_the_replies_it_always_has() { const MINE: &str = "agent_7101k5zvyjhmfg983brhmhkd98n6"; - let want: Value = - serde_json::from_str(include_str!("fixtures/no_sign_in_replies.json")).unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/no_sign_in_replies.json"); + let mut collected = serde_json::Map::new(); let tts = |status: u16| async move { let server = MockServer::start().await; user_endpoint_accepting(&server, KEY).await; @@ -2919,7 +2932,6 @@ async fn with_no_sign_in_every_reply_is_exactly_the_base_commits() { ("test_unreachable", &wired, OFFLINE.into(), vec![test]), ("voice_agent_check", &voice_agent, ok.uri(), vec![agent_check, agent_check]), ]; - assert_eq!(scenarios.len(), want.as_object().unwrap().len()); for (name, project, server, commands) in scenarios { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); @@ -2934,9 +2946,19 @@ async fn with_no_sign_in_every_reply_is_exactly_the_base_commits() { normalised(&r, home.path(), cwd.path(), &server) }) .collect(); - assert_eq!(Value::from(got), want[name], "{name}"); + collected.insert(name.to_string(), Value::from(got)); assert!(recorded(&record).is_empty(), "{name}"); } + if std::env::var_os(REWRITE_NO_SIGN_IN_REPLIES).is_some() { + let text = serde_json::to_string_pretty(&collected).unwrap() + "\n"; + std::fs::write(&fixture, text).unwrap(); + } + let want: serde_json::Map = + serde_json::from_str(&std::fs::read_to_string(&fixture).unwrap()).unwrap(); + assert_eq!(collected.keys().collect::>(), want.keys().collect::>()); + for (name, got) in &collected { + assert_eq!(got, &want[name], "{name}"); + } } // ── interrupts through the binary ─────────────────────────────────── From 7a68c2c8963b5d4116199cba109d63e853399a86 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:38:53 -0500 Subject: [PATCH 17/32] ci: the PowerShell setup step passes when its checks do, and runs for setup's tests --- .github/workflows/windows-smoke.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index fabda2f2..f2c33d14 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -18,6 +18,7 @@ on: - cli/elevenlabs/workflow/onboard/** - cli/elevenlabs/workflow/loopback.rs - cli/elevenlabs/workflow/setup.rs + - cli/elevenlabs/workflow/setup/** - .github/workflows/windows-smoke.yml jobs: @@ -203,6 +204,8 @@ jobs: 'plugin marketplace update elevenlabs' 'plugin install elevenlabs --marketplace elevenlabs/plugin --scope user' ) 'Expected Claude Code to stop at the failed install' + # The failed setup above exited 1 on purpose, and pwsh would end the step with it. + exit 0 - name: Set up Node.js uses: actions/setup-node@v6 From c98d8e3f4c7bba2fb5120327aba3f7cde41fa9dc Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 20:48:25 -0500 Subject: [PATCH 18/32] test: setup leaves nothing running when a program keeps starting others as it is stopped --- cli/elevenlabs/workflow/setup/tests.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index e111531a..12390660 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1611,3 +1611,26 @@ async fn with_no_agent_found_setup_prints_what_the_skills_copy_did() { assert_eq!(text, says, "{row}"); } } + +/// A program that never stops starting new ones while its group is killed: +/// one started as the signal lands must not survive it. +#[cfg(unix)] +#[test] +fn a_program_starting_others_as_it_is_stopped_leaves_none_running() { + let dir = tempfile::tempdir().unwrap(); + let sleep = sleep_marker(3013); + let forks = script(dir.path(), "forks", &format!("while :; do {sleep} & done")); + for run in 0..20 { + let got = run_program(&forks, &[], Duration::from_millis(50), dir.path()); + assert_eq!(got, Err("ran out of time after 50 ms and was stopped".to_string()), "run {run}"); + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let left = left_behind(&sleep); + if left.is_empty() { + break; + } + assert!(Instant::now() < deadline, "left running: {} processes, first {:?}", left.len(), &left[..1]); + std::thread::sleep(Duration::from_millis(20)); + } +} From 49ac660e3b05c89ed9f907c51f6ed8992bc12aed Mon Sep 17 00:00:00 2001 From: bitpshr Date: Tue, 10 Nov 2026 21:14:43 -0500 Subject: [PATCH 19/32] fix: setup's error line drops every terminal escape, not only colours and titles --- cli/elevenlabs/workflow/setup.rs | 56 ++++++++++++++++---------- cli/elevenlabs/workflow/setup/tests.rs | 10 +++++ tests/onboard_test.rs | 5 +++ 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index d02ec047..0b7994da 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -397,38 +397,52 @@ fn clean_last_line(bytes: &[u8]) -> Option { }) } -/// `text` without CSI and OSC escape sequences (colours, titles, links) or -/// control characters other than line ends; a tab becomes a space. +/// `text` without escape sequences (colours, titles, links and the rest), +/// bidi formatting characters or control characters other than line ends; +/// a tab becomes a space. fn without_escapes(text: &str) -> String { let mut out = String::with_capacity(text.len()); - let mut chars = text.chars(); + let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { match c { - '\u{1b}' => match chars.next() { - // Parameters, then one final character from @ to ~. - Some('[') => { - for c in chars.by_ref() { - if ('@'..='~').contains(&c) { - break; + '\u{1b}' | '\u{9b}' | '\u{9d}' => { + // U+009B and U+009D are one-character forms of ESC [ and ESC ]. + let kind = match c { + '\u{9b}' => Some('['), + '\u{9d}' => Some(']'), + _ => chars.next_if(|c| matches!(c, '[' | ']' | 'P' | 'X' | '^' | '_')), + }; + match kind { + // Parameters, then one final character from @ to ~. + Some('[') => { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } } } - } - // Up to BEL or ESC \. - Some(']') => { - while let Some(c) = chars.next() { - if c == '\u{7}' { - break; - } - if c == '\u{1b}' { - chars.next(); - break; + // A string, up to BEL or ESC \. + Some(_) => { + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' { + chars.next(); + break; + } } } + // Characters from space to /, then one final character from 0 to ~. + None => { + while chars.next_if(|c| (' '..='/').contains(c)).is_some() {} + chars.next_if(|c| ('0'..='~').contains(c)); + } } - _ => {} - }, + } '\n' | '\r' => out.push(c), '\t' => out.push(' '), + '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => {} c if c.is_control() => {} c => out.push(c), } diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index 12390660..6eff83dd 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1508,6 +1508,16 @@ fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characte ("a multi-byte character straddling the cut", straddles.into_bytes(), Some(straddles_cut)), ("exactly 300 characters", exactly.clone().into_bytes(), Some(exactly)), ("bytes that are not UTF-8", b"caf\xe9 \xff broke\n".to_vec(), Some("caf\u{FFFD} \u{FFFD} broke".into())), + ("ESC ( B after a coloured error, as tput sgr0 prints", b"\x1b[31merror: sgr0 reset\x1b(B\x1b[m\n".to_vec(), Some("error: sgr0 reset".into())), + ("ESC # 8", b"error: \x1b#8aligned\n".to_vec(), Some("error: aligned".into())), + ("ESC 7, ESC = and ESC 8", b"\x1b7\x1b=saved\x1b8 error: cursor\n".to_vec(), Some("saved error: cursor".into())), + ("a DCS string", b"error: \x1bPq#0;2;0;0;0\x1b\\dcs gone\n".to_vec(), Some("error: dcs gone".into())), + ("an APC string ended by BEL", b"error: \x1b_Gf=100;AAAA\x07apc gone\n".to_vec(), Some("error: apc gone".into())), + ("a one-character CSI", "error: c1 \u{9b}31mred\n".into(), Some("error: c1 red".into())), + ("a one-character OSC title ended by BEL", "\u{9d}0;title\u{7}error: c1 title\n".into(), Some("error: c1 title".into())), + ("a plain escape cut off at the end", b"error: trailing\x1b(".to_vec(), Some("error: trailing".into())), + ("a bidi override around a word", "error: \u{202e}evil\u{202c} ok\n".into(), Some("error: evil ok".into())), + ("brackets and parentheses in plain text", b"error: failed (code 3) [retry]\n".to_vec(), Some("error: failed (code 3) [retry]".into())), ("empty output", Vec::new(), None), ("only blank lines and escape codes", b"\n \r\n\x1b[0m\x1b[2K\r\x1b]0;t\x07\n\t\n".to_vec(), None), ]; diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 92d1a8ed..080a7e5d 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2573,6 +2573,11 @@ async fn a_failed_install_shows_a_short_clean_error_however_much_it_printed() { printf '\\033[31merror:\\033[0m install refused\\r\\n' >&2", "error: install refused".to_string(), ), + ( + "a coloured error ended as tput sgr0 does, with ESC ( B", + "printf '\\033[31merror: sgr0 reset\\033(B\\033[m\\n' >&2", + "error: sgr0 reset".to_string(), + ), ]; for (row, prints, line) in rows { let (home, cwd) = temp(); From f082c8f171da5d164ef0d4bbdededdbc2427ee1c Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 08:35:15 -0500 Subject: [PATCH 20/32] refactor: a new sign-in clears the setup offer with the files it always cleared The sign-in folder's stale files are one inline list again, now with setup-offer, and onboarding's check keeps its own link to the skills archive, as on main. --- cli/elevenlabs/workflow/onboard/check.rs | 4 +++- cli/elevenlabs/workflow/onboard/session.rs | 19 +++---------------- cli/elevenlabs/workflow/onboard/tests.rs | 11 +---------- 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/cli/elevenlabs/workflow/onboard/check.rs b/cli/elevenlabs/workflow/onboard/check.rs index 0fce522b..788bde04 100644 --- a/cli/elevenlabs/workflow/onboard/check.rs +++ b/cli/elevenlabs/workflow/onboard/check.rs @@ -8,7 +8,6 @@ use serde_json::Value; use super::super::say::run_async as block_on; use super::super::settings; -use super::super::setup::SKILLS_ARCHIVE; use super::auth::{api_base, check_key, sign_in_config, KeyCheck}; use super::connect; use super::env_file::{self, API_KEY_ENV}; @@ -246,6 +245,9 @@ pub(super) fn app_origin_for(residency: &str, authorize_url: &str) -> Result Result<(), CliError> { env_file::write(&self.path("result.json"), &reply.to_string()) diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index 661afa60..86c1d91a 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -1715,7 +1715,7 @@ fn a_new_sign_in_keeps_how_the_last_one_ended() { } #[test] -fn the_setup_offer_a_sign_in_leaves_is_due_until_made_and_a_new_sign_in_clears_it() { +fn the_setup_offer_a_sign_in_leaves_is_due_until_made() { use super::session::Session; let dir = tempfile::tempdir().unwrap(); let s = Session::in_dir(dir.path().to_path_buf()); @@ -1725,15 +1725,6 @@ fn the_setup_offer_a_sign_in_leaves_is_due_until_made_and_a_new_sign_in_clears_i assert!(s.setup_offer_due()); s.setup_offer_made(); assert!(!s.setup_offer_due()); - // A new sign-in clears one the last sign-in left. - s.offer_setup(); - std::fs::write(dir.path().join("previous.json"), "{}").unwrap(); - s.clear_stale(); - assert!(!s.setup_offer_due()); - assert!( - dir.path().join("previous.json").exists(), - "kept for the new sign-in" - ); } #[test] From 96793343f2ab53c7e964e37cb81b27b140506da5 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 08:35:46 -0500 Subject: [PATCH 21/32] refactor: setup cleans the error line it repeats with anstream The hand-written escape parser goes: anstream's strip_str (already built through clap) drops every terminal escape, after the one-character CSI and OSC are turned into their two-character forms, and one filter drops control and bidi characters. An APC string ended by BEL, which does not end one, now takes the text after it. The skip of a character cut at the start of the 64 KB tail goes too: the line is cut to 300 characters anyway. --- Cargo.lock | 1 + Cargo.toml | 3 + cli/elevenlabs/workflow/setup.rs | 86 +++++++------------------- cli/elevenlabs/workflow/setup/tests.rs | 18 +----- 4 files changed, 28 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2979765a..4caa5f2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" name = "elevenlabs-cli" version = "1.11.0" dependencies = [ + "anstream", "anyhow", "arboard", "base64", diff --git a/Cargo.toml b/Cargo.toml index 3a1c5252..0b5d4d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,9 @@ mmdflux = { version = "2.6.1", default-features = false } # The terminal's width, so diagrams and `agents graph` can shorten long IDs # to fit it. terminal_size = "0.4" +# Strips terminal escapes from the error line `setup` repeats from a coding +# agent's output; clap already builds it. +anstream = "1" dotenvy = "0.15" futures-util = "0.3" httpdate = "1" diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 0b7994da..baf3da73 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -372,24 +372,33 @@ const LINE_MAX: usize = 300; fn last_line(mut file: std::fs::File) -> Option { use std::io::{Read, Seek, SeekFrom}; let len = file.seek(SeekFrom::End(0)).ok()?; - let from = len.saturating_sub(TAIL); - file.seek(SeekFrom::Start(from)).ok()?; + file.seek(SeekFrom::Start(len.saturating_sub(TAIL))).ok()?; let mut bytes = Vec::new(); file.take(TAIL).read_to_end(&mut bytes).ok()?; - // Cut mid-file, the tail can start inside a character. - let partial = if from > 0 { - bytes.iter().take(3).take_while(|b| (0x80..0xc0).contains(*b)).count() - } else { - 0 - }; - clean_last_line(&bytes[partial..]) + clean_last_line(&bytes) } /// The last line with text in `bytes`, where a line ends at `\n` or `\r` (a -/// progress bar redraws itself after `\r`), without terminal escapes or other -/// control characters, and at most [`LINE_MAX`] characters long. +/// progress bar redraws itself after `\r`), without terminal escapes, bidi +/// formatting characters or control characters (a tab becomes a space), and +/// at most [`LINE_MAX`] characters long. fn clean_last_line(bytes: &[u8]) -> Option { - let text = without_escapes(&String::from_utf8_lossy(bytes)); + // U+009B and U+009D are one-character forms of ESC [ and ESC ], which + // the parser sees only as their two-character forms. + let text = String::from_utf8_lossy(bytes) + .replace('\u{9b}', "\u{1b}[") + .replace('\u{9d}', "\u{1b}]"); + let text: String = anstream::adapter::strip_str(&text) + .to_string() + .chars() + .filter_map(|c| match c { + '\t' => Some(' '), + '\n' | '\r' => Some(c), + '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => None, + c if c.is_control() => None, + c => Some(c), + }) + .collect(); let line = text.split(['\n', '\r']).map(str::trim).rfind(|l| !l.is_empty())?; Some(match line.char_indices().nth(LINE_MAX - 1) { Some((at, _)) if line[at..].chars().nth(1).is_some() => format!("{}…", &line[..at]), @@ -397,59 +406,6 @@ fn clean_last_line(bytes: &[u8]) -> Option { }) } -/// `text` without escape sequences (colours, titles, links and the rest), -/// bidi formatting characters or control characters other than line ends; -/// a tab becomes a space. -fn without_escapes(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\u{1b}' | '\u{9b}' | '\u{9d}' => { - // U+009B and U+009D are one-character forms of ESC [ and ESC ]. - let kind = match c { - '\u{9b}' => Some('['), - '\u{9d}' => Some(']'), - _ => chars.next_if(|c| matches!(c, '[' | ']' | 'P' | 'X' | '^' | '_')), - }; - match kind { - // Parameters, then one final character from @ to ~. - Some('[') => { - for c in chars.by_ref() { - if ('@'..='~').contains(&c) { - break; - } - } - } - // A string, up to BEL or ESC \. - Some(_) => { - while let Some(c) = chars.next() { - if c == '\u{7}' { - break; - } - if c == '\u{1b}' { - chars.next(); - break; - } - } - } - // Characters from space to /, then one final character from 0 to ~. - None => { - while chars.next_if(|c| (' '..='/').contains(c)).is_some() {} - chars.next_if(|c| ('0'..='~').contains(c)); - } - } - } - '\n' | '\r' => out.push(c), - '\t' => out.push(' '), - '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => {} - c if c.is_control() => {} - c => out.push(c), - } - } - out -} - /// Install the plugin into each agent with `run`, which gets the program, /// its arguments and how long it may take. An agent reached after the whole /// run's time is up counts as failed. diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index 6eff83dd..40c0a0a6 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1512,7 +1512,9 @@ fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characte ("ESC # 8", b"error: \x1b#8aligned\n".to_vec(), Some("error: aligned".into())), ("ESC 7, ESC = and ESC 8", b"\x1b7\x1b=saved\x1b8 error: cursor\n".to_vec(), Some("saved error: cursor".into())), ("a DCS string", b"error: \x1bPq#0;2;0;0;0\x1b\\dcs gone\n".to_vec(), Some("error: dcs gone".into())), - ("an APC string ended by BEL", b"error: \x1b_Gf=100;AAAA\x07apc gone\n".to_vec(), Some("error: apc gone".into())), + // BEL doesn't end an APC string, so the rest of the text goes with it. + ("an APC string ended by BEL", b"error: \x1b_Gf=100;AAAA\x07apc gone\n".to_vec(), Some("error:".into())), + ("an APC string ended by ESC \\", b"error: \x1b_Gf=100;AAAA\x1b\\apc gone\n".to_vec(), Some("error: apc gone".into())), ("a one-character CSI", "error: c1 \u{9b}31mred\n".into(), Some("error: c1 red".into())), ("a one-character OSC title ended by BEL", "\u{9d}0;title\u{7}error: c1 title\n".into(), Some("error: c1 title".into())), ("a plain escape cut off at the end", b"error: trailing\x1b(".to_vec(), Some("error: trailing".into())), @@ -1530,20 +1532,6 @@ fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characte } } -#[test] -fn only_the_tail_of_a_capture_is_read_and_a_character_cut_by_it_is_dropped() { - use std::io::Write; - let mut file = tempfile::tempfile().unwrap(); - // 10 MB, then a last line whose first character is cut by the tail. - file.write_all(&vec![b'z'; 10_000_000]).unwrap(); - file.write_all(b"\n").unwrap(); - let mut line = "é".to_string(); - line.push_str(&"q".repeat(TAIL as usize - 1)); - file.write_all(line.as_bytes()).unwrap(); - let got = last_line(file).unwrap(); - assert_eq!(got, format!("{}…", "q".repeat(LINE_MAX - 1))); -} - #[cfg(unix)] #[test] fn a_failed_command_reports_a_short_clean_line_whatever_it_printed() { From c83c1bd983c46918c46431212024d02bfb6fce9a Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 08:36:06 -0500 Subject: [PATCH 22/32] fix: setup installs on Claude Code older than 2.1.292, and with no agent found says how to get the skills Claude Code is set up with plugin marketplace add, marketplace update, plugin install and plugin update, which work on every version; the --marketplace option exists only from 2.1.292. The marketplace commands prepare and never decide an agent's result, for Codex too; an agent that fails shows its first failed command, the cause. With none of Claude Code, Codex and Cursor found, setup prints one line naming npx skills add elevenlabs/skills, and how to ask Claude Code in the desktop app to run setup, instead of downloading and copying the skills. Onboarding makes no offer then. Also simpler: one interrupt guard for the whole run, holding the handlers from before; a limit prints as 30s; setup --yes and onboard setup build their lines from one function; the tests write out what they expect. --- cli/elevenlabs/workflow/onboard/mod.rs | 17 +- cli/elevenlabs/workflow/setup.rs | 516 +++++-------- cli/elevenlabs/workflow/setup/tests.rs | 965 ++++++------------------- tests/onboard_test.rs | 473 +++++------- 4 files changed, 565 insertions(+), 1406 deletions(-) diff --git a/cli/elevenlabs/workflow/onboard/mod.rs b/cli/elevenlabs/workflow/onboard/mod.rs index 683cf96b..c0abd145 100644 --- a/cli/elevenlabs/workflow/onboard/mod.rs +++ b/cli/elevenlabs/workflow/onboard/mod.rs @@ -99,24 +99,13 @@ fn setup(matches: &clap::ArgMatches, ctx: &AppContext) -> Result = Vec::new(); - for outcome in &outcomes { - let mut line = format!("{}.", super::setup::result_line(outcome)); - if let Some(finish) = super::setup::finish(outcome) { - line.push_str(&format!(" To finish, {finish}.")); - } - say.push(line); - } - if outcomes.iter().any(|o| !o.ok) { - say.push(format!("To try again later, run `{later}`.")); - } + let (say, _) = super::setup::report(&outcomes); Ok(reply::Reply::new(reply::Next::Done, say.join(" ")).detail("setup", &outcomes)) } /// `done`, the end of the flow, or, after a sign-in in this project, when a -/// coding agent can take the plugin (or none of the three is there and the -/// skills can be copied), the question that offers `setup`. Made once per -/// sign-in. +/// coding agent can take the plugin, the question that offers `setup`. Made +/// once per sign-in. fn or_offer_setup(project: &project::Project, done: reply::Reply) -> reply::Reply { let Ok(session) = session::Session::for_project(&project.dir) else { return done; diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index baf3da73..80ba9556 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -1,8 +1,8 @@ //! `elevenlabs setup`: install the ElevenLabs plugin (its skills and the //! hosted MCP server) into the coding agents on this machine, through each //! agent's own plugin command. Cursor has no such command, so it gets the -//! link to its marketplace page. With none of them found, the skills are -//! copied into `~/.agents/skills`, where other agents look for them. +//! link to its marketplace page. With none of them found, it says how to +//! install the skills. //! //! `onboard setup` runs the same install when the developer accepts the offer //! at the end of onboarding. @@ -17,13 +17,7 @@ use fern_cli_sdk::app::CliApp; use fern_cli_sdk::error::CliError; use serde::Serialize; -/// The ElevenLabs skills, the whole repo in one download. -pub(super) const SKILLS_ARCHIVE: &str = - "https://github.com/elevenlabs/skills/archive/refs/heads/main.tar.gz"; - const CURSOR_PAGE: &str = "https://cursor.com/marketplace/elevenlabs"; -/// Left in every skill folder `setup` writes, so a later run replaces only those. -const MARKER: &str = ".elevenlabs-setup"; /// A coding agent found on this machine, with the program that runs it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -33,6 +27,17 @@ pub(super) enum Agent { Cursor, } +/// One command that installs the plugin. +struct Step { + args: &'static [&'static str], + /// Whether its result decides the agent's. A command that prepares the + /// marketplace never does: a refresh fails while the plugin is installed + /// and working when GitHub can't be reached, and an `add` is refused when + /// a marketplace of that name came from elsewhere. Without a marketplace, + /// the install fails anyway. + decides: bool, +} + impl Agent { pub fn name(&self) -> &'static str { match self { @@ -42,43 +47,49 @@ impl Agent { } } - /// The commands that install the plugin, in order, each with whether its - /// result decides the agent's. Refreshing the catalog fails harmlessly - /// before the first install, so it never does. - fn steps(&self) -> &'static [(&'static [&'static str], bool)] { + /// The commands that install the plugin, in order. These work on every + /// version of each agent. + fn steps(&self) -> &'static [Step] { match self { Agent::ClaudeCode(_) => &[ - (&["plugin", "marketplace", "update", "elevenlabs"], false), - ( - &[ - "plugin", - "install", - "elevenlabs", - "--marketplace", - "elevenlabs/plugin", - "--scope", - "user", - ], - true, - ), - (&["plugin", "update", "elevenlabs@elevenlabs"], true), + Step { decides: false, args: &["plugin", "marketplace", "add", "elevenlabs/plugin"] }, + Step { decides: false, args: &["plugin", "marketplace", "update", "elevenlabs"] }, + Step { decides: true, args: &["plugin", "install", "elevenlabs@elevenlabs"] }, + Step { decides: true, args: &["plugin", "update", "elevenlabs@elevenlabs"] }, ], Agent::Codex(_) => &[ - (&["plugin", "marketplace", "add", "elevenlabs/plugin"], true), - (&["plugin", "marketplace", "upgrade", "elevenlabs"], false), - (&["plugin", "add", "elevenlabs@elevenlabs"], true), + Step { decides: false, args: &["plugin", "marketplace", "add", "elevenlabs/plugin"] }, + Step { decides: false, args: &["plugin", "marketplace", "upgrade", "elevenlabs"] }, + Step { decides: true, args: &["plugin", "add", "elevenlabs@elevenlabs"] }, ], Agent::Cursor => &[], } } + + /// How to finish once the plugin is in: its MCP server signs in in the + /// agent itself. + fn finish(&self) -> Option<&'static str> { + match self { + Agent::ClaudeCode(_) => { + Some("restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server") + } + Agent::Codex(_) => Some( + "restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", + ), + Agent::Cursor => None, + } + } } -/// How one agent, or the skills copy, came out. +/// How one agent came out. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(super) struct Outcome { pub agent: &'static str, pub ok: bool, pub message: String, + /// How to finish, for an agent the plugin went into. + #[serde(skip)] + pub finish: Option<&'static str>, } /// How long one agent command, and the whole run, may take. The whole run @@ -204,13 +215,16 @@ fn is_program(path: &Path) -> bool { /// can be loaded as part of it, stopping it and everything it started after /// `limit`. `program` is a full path, so on Windows std starts a `.cmd` shim /// through cmd with its own quoting. `Err` is why it failed: its last line of -/// output, or that it ran out of time. +/// output, or that it ran out of time. During [`run`], a Ctrl-C or SIGTERM +/// stops the program and exits this process as an interrupted one does. pub(super) fn run_program( program: &Path, args: &[&str], limit: Duration, home: &Path, ) -> Result<(), String> { + #[cfg(unix)] + interrupt::exit_if_caught(); let (out, err) = match (tempfile::tempfile(), tempfile::tempfile()) { (Ok(out), Ok(err)) => (out, err), (Err(e), _) | (_, Err(e)) => return Err(format!("couldn't start it: {e}")), @@ -228,21 +242,17 @@ pub(super) fn run_program( command.env_clear().envs(env); } command.current_dir(home).env("PWD", home); + // In its own group it gets no Ctrl-C from the terminal: `run` catches it. #[cfg(unix)] std::os::unix::process::CommandExt::process_group(&mut command, 0); - // In its own group it gets no Ctrl-C from the terminal: one sent here stops it. - #[cfg(unix)] - let interrupt = interrupt::Catch::start(); let mut child = command .spawn() .map_err(|e| format!("couldn't start it: {e}"))?; let deadline = Instant::now() + limit; let status = loop { #[cfg(unix)] - if let Some(signal) = interrupt.caught() { + if let Some(signal) = interrupt::caught() { stop_all(&mut child); - let _ = child.wait(); - drop(interrupt); std::process::exit(128 + signal); } match child.try_wait() { @@ -250,11 +260,7 @@ pub(super) fn run_program( Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), _ => { stop_all(&mut child); - let _ = child.wait(); - return Err(format!( - "ran out of time after {} and was stopped", - seconds(limit) - )); + return Err(format!("ran out of time after {limit:?} and was stopped")); } } }; @@ -271,22 +277,16 @@ pub(super) fn run_program( }) } -/// Stop `child` and the programs it started: the npm `codex` is a node -/// script that starts the real one, and on Windows a shim runs under cmd. +/// Stop `child` and the programs it started, and wait for it: the npm +/// `codex` is a node script that starts the real one, and on Windows a shim +/// runs under cmd. fn stop_all(child: &mut std::process::Child) { + // The child leads its own group, so this reaches only what it started. #[cfg(unix)] - if let Ok(group) = i32::try_from(child.id()) { - // The child leads its own group, so this reaches only what it started. + let group = i32::try_from(child.id()).ok(); + #[cfg(unix)] + if let Some(group) = group { unsafe { libc::kill(-group, libc::SIGKILL) }; - // A program being started as the group is killed can miss the signal, - // so once the child is reaped, the group is killed until it is empty. - let _ = child.wait(); - for _ in 0..100 { - if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 { - break; - } - std::thread::sleep(Duration::from_millis(1)); - } } #[cfg(windows)] let _ = Command::new("taskkill") @@ -296,69 +296,69 @@ fn stop_all(child: &mut std::process::Child) { .stderr(Stdio::null()) .status(); let _ = child.kill(); + let _ = child.wait(); + // A program being started as the group is killed can miss the signal, + // so once the child is reaped, the group is killed until it is empty. + #[cfg(unix)] + if let Some(group) = group { + for _ in 0..100 { + if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + } } -/// Ctrl-C and SIGTERM, caught while an agent command runs, so `run_program` -/// can stop it before exiting as an interrupted program does. +/// Ctrl-C and SIGTERM, caught while [`run`] runs the agent commands, so the +/// one running can be stopped before this process exits as an interrupted +/// program does. #[cfg(unix)] mod interrupt { use std::sync::atomic::{AtomicI32, Ordering}; - use std::sync::Mutex; const SIGNALS: [libc::c_int; 2] = [libc::SIGINT, libc::SIGTERM]; static CAUGHT: AtomicI32 = AtomicI32::new(0); - /// How many commands are waiting, and the handlers from before the first. - static WAITING: Mutex<(usize, [libc::sighandler_t; 2])> = Mutex::new((0, [0; 2])); extern "C" fn note(signal: libc::c_int) { CAUGHT.store(signal, Ordering::SeqCst); } - /// Catches the signals until dropped, which puts the earlier handlers back. - pub struct Catch; + /// Catches the signals, holding the handlers from before. + pub struct Catch([libc::sighandler_t; 2]); impl Catch { pub fn start() -> Catch { - let mut waiting = WAITING.lock().unwrap_or_else(|e| e.into_inner()); - if waiting.0 == 0 { - CAUGHT.store(0, Ordering::SeqCst); - for (signal, previous) in SIGNALS.iter().zip(waiting.1.iter_mut()) { - let handler = note as extern "C" fn(libc::c_int) as libc::sighandler_t; - *previous = unsafe { libc::signal(*signal, handler) }; - // A signal the parent chose to ignore stays ignored. - if *previous == libc::SIG_IGN { - unsafe { libc::signal(*signal, libc::SIG_IGN) }; - } + CAUGHT.store(0, Ordering::SeqCst); + let handler = note as extern "C" fn(libc::c_int) as libc::sighandler_t; + Catch(SIGNALS.map(|signal| { + let previous = unsafe { libc::signal(signal, handler) }; + // A signal the parent chose to ignore stays ignored. + if previous == libc::SIG_IGN { + unsafe { libc::signal(signal, libc::SIG_IGN) }; } - } - waiting.0 += 1; - Catch + previous + })) } - pub fn caught(&self) -> Option { - Some(CAUGHT.load(Ordering::SeqCst)).filter(|&s| s != 0) + /// Put the earlier handlers back, then exit if a signal came meanwhile. + pub fn finish(self) { + for (signal, previous) in SIGNALS.iter().zip(self.0) { + unsafe { libc::signal(*signal, previous) }; + } + exit_if_caught(); } } - impl Drop for Catch { - fn drop(&mut self) { - let mut waiting = WAITING.lock().unwrap_or_else(|e| e.into_inner()); - waiting.0 -= 1; - if waiting.0 == 0 { - for (signal, previous) in SIGNALS.iter().zip(waiting.1) { - unsafe { libc::signal(*signal, previous) }; - } - } - } + pub fn caught() -> Option { + Some(CAUGHT.load(Ordering::SeqCst)).filter(|&s| s != 0) } -} -fn seconds(d: Duration) -> String { - if d >= Duration::from_secs(1) { - format!("{} seconds", d.as_secs()) - } else { - format!("{} ms", d.as_millis()) + pub fn exit_if_caught() { + if let Some(signal) = caught() { + std::process::exit(128 + signal); + } } } @@ -407,93 +407,69 @@ fn clean_last_line(bytes: &[u8]) -> Option { } /// Install the plugin into each agent with `run`, which gets the program, -/// its arguments and how long it may take. An agent reached after the whole -/// run's time is up counts as failed. +/// its arguments and how long it may take; `elapsed` says how much of the +/// whole run's time is used. An agent reached after that time is up counts +/// as failed, and one that fails shows its first failed command. pub(super) fn install( agents: &[Agent], limits: Limits, run: &dyn Fn(&Path, &[&str], Duration) -> Result<(), String>, -) -> Vec { - let started = Instant::now(); - install_timed(agents, limits, run, &|| started.elapsed()) -} - -/// [`install`], with `elapsed` saying how much of the whole run's time is used. -fn install_timed( - agents: &[Agent], - limits: Limits, - run: &dyn Fn(&Path, &[&str], Duration) -> Result<(), String>, elapsed: &dyn Fn() -> Duration, ) -> Vec { agents .iter() .map(|agent| { - let program = match agent { - Agent::ClaudeCode(p) | Agent::Codex(p) => p, - Agent::Cursor => { - return Outcome { - agent: agent.name(), - ok: true, - message: format!("open {CURSOR_PAGE} and select Install"), - } - } - }; - let failed = |message: String| Outcome { + let outcome = |ok: bool, message: String| Outcome { agent: agent.name(), - ok: false, + ok, message, + finish: agent.finish().filter(|_| ok), + }; + let program = match agent { + Agent::ClaudeCode(p) | Agent::Codex(p) => p, + Agent::Cursor => return outcome(true, format!("open {CURSOR_PAGE} and select Install")), }; - for (args, decides) in agent.steps() { + let name = program.file_stem().unwrap_or(program.as_os_str()).to_string_lossy(); + let mut first_failed = None; + for step in agent.steps() { let left = limits.total.saturating_sub(elapsed()); if left.is_zero() { - return failed(format!( - "ran out of time: setup stops after {}", - seconds(limits.total) - )); + let message = format!("ran out of time: setup stops after {:?}", limits.total); + return outcome(false, message); } - let result = run(program, args, limits.command.min(left)); - if let (Err(why), true) = (result, decides) { - let name = program - .file_stem() - .unwrap_or(program.as_os_str()) - .to_string_lossy(); - return failed(format!("`{name} {}` {why}", args.join(" "))); + if let Err(why) = run(program, step.args, limits.command.min(left)) { + let failed = first_failed + .get_or_insert_with(|| format!("`{name} {}` {why}", step.args.join(" "))); + if step.decides { + return outcome(false, failed.clone()); + } } } - Outcome { - agent: agent.name(), - ok: true, - message: "installed the ElevenLabs plugin (skills and the hosted MCP server)" - .into(), - } + let message = "installed the ElevenLabs plugin (skills and the hosted MCP server)"; + outcome(true, message.into()) }) .collect() } -/// Everything `setup --yes` does: install into the agents found, or, with -/// none, copy the skills. +/// Everything `setup --yes` does: install into the agents found. A Ctrl-C +/// or SIGTERM meanwhile stops the command running and exits. pub(super) fn run(agents: &[Agent], home: &Path, limits: Limits) -> Vec { - run_from(agents, home, limits, SKILLS_ARCHIVE) -} - -/// [`run`], downloading the skills from `skills_url`. -fn run_from(agents: &[Agent], home: &Path, limits: Limits, skills_url: &str) -> Vec { - if agents.is_empty() { - return vec![skills_from(skills_url, home)]; - } - install(agents, limits, &|program, args, limit| { - run_program(program, args, limit, home) - }) + #[cfg(unix)] + let catch = interrupt::Catch::start(); + let started = Instant::now(); + let outcomes = install( + agents, + limits, + &|program, args, limit| run_program(program, args, limit, home), + &|| started.elapsed(), + ); + #[cfg(unix)] + catch.finish(); + outcomes } /// What `setup` does for each agent, before it does it. -fn plan(agents: &[Agent], home: &Path) -> Vec { - if agents.is_empty() { - return vec![format!( - "No Claude Code, Codex or Cursor found: copy the ElevenLabs skills into {}", - skills_root(home).display() - )]; - } +fn plan(agents: &[Agent]) -> Vec { agents .iter() .map(|agent| match agent { @@ -507,26 +483,37 @@ fn plan(agents: &[Agent], home: &Path) -> Vec { .collect() } -/// One line per outcome. -pub(super) fn result_line(outcome: &Outcome) -> String { - if outcome.ok { - format!("{}: {}", outcome.agent, outcome.message) - } else { - format!("{}: failed: {}", outcome.agent, outcome.message) +/// What `setup --yes` and `onboard setup` say for `outcomes`, a sentence or +/// two per line, and whether any failed. With no agent found, how to get the +/// skills. +pub(super) fn report(outcomes: &[Outcome]) -> (Vec, bool) { + if outcomes.is_empty() { + return ( + vec![format!( + "No Claude Code, Codex or Cursor found. If you use Claude Code in the Claude desktop app, \ + ask it to run \"{}\" for you. For other coding agents, install the ElevenLabs skills \ + with: npx skills add elevenlabs/skills", + later_command() + )], + false, + ); } -} - -/// How to finish for an agent the plugin went into: its MCP server signs in -/// in the agent itself. -pub(super) fn finish(outcome: &Outcome) -> Option { - let how = match outcome.agent { - "Claude Code" => "run /mcp and sign in to the ElevenLabs MCP server", - "Codex" => "run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", - _ => return None, - }; - outcome - .ok - .then(|| format!("restart {}, then {how}", outcome.agent)) + let mut lines: Vec = outcomes + .iter() + .map(|o| { + let failed = if o.ok { "" } else { "failed: " }; + let mut line = format!("{}: {failed}{}.", o.agent, o.message); + if let Some(finish) = o.finish { + line.push_str(&format!(" To finish, {finish}.")); + } + line + }) + .collect(); + let failed = outcomes.iter().any(|o| !o.ok); + if failed { + lines.push(format!("To try again, run `{}`.", later_command())); + } + (lines, failed) } /// The installed CLI, or npx when npx started this process (it sets @@ -549,7 +536,7 @@ pub(super) fn later_command() -> String { } /// The question that offers `setup` at the end of onboarding, or `None` -/// when only Cursor was found: it has no install command to run. +/// when neither Claude Code nor Codex was found: nothing would be installed. pub(super) fn question(agents: &[Agent]) -> Option { let claude = agents.iter().any(|a| matches!(a, Agent::ClaudeCode(_))); let codex = agents.iter().any(|a| matches!(a, Agent::Codex(_))); @@ -557,9 +544,6 @@ pub(super) fn question(agents: &[Agent]) -> Option { (true, true) => "Claude Code and Codex", (true, false) => "Claude Code", (false, true) => "Codex", - (false, false) if agents.is_empty() => { - return Some("Want me to install the ElevenLabs skills for your coding agents?".into()) - } (false, false) => return None, }; Some(format!( @@ -568,190 +552,28 @@ pub(super) fn question(agents: &[Agent]) -> Option { )) } -fn skills_root(home: &Path) -> PathBuf { - home.join(".agents").join("skills") -} - -/// Download the skills archive from `url` and copy them into `~/.agents/skills`. -fn skills_from(url: &str, home: &Path) -> Outcome { - let result = download_skills(url).and_then(|dir| copy_skills(&dir.path().join("skills"), home)); - let root = skills_root(home); - match result { - Ok(copied) if copied.written.is_empty() && copied.kept.is_empty() => Outcome { - agent: "Skills", - ok: false, - message: format!("the download from {url} held no skills"), - }, - Ok(copied) => { - let mut message = format!( - "copied {} ElevenLabs skills into {}", - copied.written.len(), - root.display() - ); - if !copied.kept.is_empty() { - message.push_str(&format!( - "; left alone, since setup didn't write them: {}", - copied.kept.join(", ") - )); - } - Outcome { - agent: "Skills", - ok: true, - message, - } - } - Err(why) => Outcome { - agent: "Skills", - ok: false, - message: why, - }, - } -} - -/// A new temp folder, removed when dropped, with the skills archive from -/// `url` unpacked into its `skills` folder. -fn download_skills(url: &str) -> Result { - let dir = tempfile::tempdir().map_err(|e| format!("couldn't make a temp folder: {e}"))?; - let archive = dir.path().join("skills.tar.gz"); - let unpacked = dir.path().join("skills"); - std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?; - // Not the API client: it refuses the redirect GitHub answers with, to - // keep a key from following it. Nothing secret goes to GitHub. - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(60)) - .build() - .map_err(|e| e.to_string())?; - let body = super::say::run_async(async { - let response = client - .get(url) - .send() - .await? - .error_for_status()?; - response.bytes().await - }) - .map_err(|e| format!("couldn't download the skills from {url}: {e}"))?; - std::fs::write(&archive, &body).map_err(|e| e.to_string())?; - let out = Command::new("tar") - .arg("-xzf") - .arg(&archive) - .arg("-C") - .arg(&unpacked) - .arg("--strip-components=1") - .stdin(Stdio::null()) - .output() - .map_err(|e| format!("couldn't run tar: {e}"))?; - if !out.status.success() { - return Err(format!( - "tar couldn't unpack the skills: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(dir) -} - -/// The skill folders written, and the ones left as they were. -#[derive(Debug, Default, PartialEq, Eq)] -pub(super) struct Copied { - pub written: Vec, - pub kept: Vec, -} - -/// Copy each top-level folder of `unpacked` that holds a `SKILL.md` into -/// `/.agents/skills/`, marking each one written. A folder already -/// there is replaced only when it has the mark: anything else is the -/// developer's, and is left as it was. -pub(super) fn copy_skills(unpacked: &Path, home: &Path) -> Result { - let root = skills_root(home); - std::fs::create_dir_all(&root).map_err(|e| format!("{}: {e}", root.display()))?; - let mut names: Vec = std::fs::read_dir(unpacked) - .map_err(|e| e.to_string())? - .flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter(|e| e.path().join("SKILL.md").is_file()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - names.sort(); - let mut copied = Copied::default(); - for name in names { - let target = root.join(&name); - if let Ok(meta) = std::fs::symlink_metadata(&target) { - let ours = meta.is_dir() && target.join(MARKER).is_file(); - if !ours { - copied.kept.push(name); - continue; - } - std::fs::remove_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?; - } - let failed = |e: std::io::Error| format!("{}: {e}", target.display()); - std::fs::create_dir_all(&target).map_err(failed)?; - // Marked first, so a copy that stops partway is still replaced next time. - if let Err(e) = std::fs::write(target.join(MARKER), "") { - // Unmarked, it would count as the developer's and never be filled. - let _ = std::fs::remove_dir(&target); - return Err(failed(e)); - } - copy_dir(&unpacked.join(&name), &target).map_err(failed)?; - copied.written.push(name); - } - Ok(copied) -} - -/// Files and folders only: a link in the archive is not followed. -fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(to)?; - for entry in std::fs::read_dir(from)? { - let entry = entry?; - let kind = entry.file_type()?; - let dest = to.join(entry.file_name()); - if kind.is_dir() { - copy_dir(&entry.path(), &dest)?; - } else if kind.is_file() { - std::fs::copy(entry.path(), dest)?; - } - } - Ok(()) -} - fn handle(matches: &clap::ArgMatches) -> Result<(), CliError> { let home = home() .ok_or_else(|| CliError::Validation("No home folder found (HOME is not set).".into()))?; let agents = found_here(&home); - if !matches.get_flag("yes") || super::util::dry_run_flag(matches) { - for line in plan(&agents, &home) { + let install = matches.get_flag("yes") && !super::util::dry_run_flag(matches); + if !install && !agents.is_empty() { + for line in plan(&agents) { println!("{line}"); } println!("Nothing was changed. Run `{}` to do it.", later_command()); return Ok(()); } - let (text, failed) = report(&run(&agents, &home, LIMITS)); - print!("{text}"); + let (lines, failed) = report(&run(&agents, &home, LIMITS)); + for line in lines { + println!("{line}"); + } if failed { std::process::exit(1); } Ok(()) } -/// What `setup --yes` prints for `outcomes`, and whether any failed. -fn report(outcomes: &[Outcome]) -> (String, bool) { - let mut text = String::new(); - for outcome in outcomes { - text += &format!("{}\n", result_line(outcome)); - } - let failed = outcomes.iter().any(|o| !o.ok); - if failed { - text += &format!("To try again, run `{}`.\n", later_command()); - } - let finish: Vec = outcomes.iter().filter_map(finish).collect(); - if !finish.is_empty() { - text += "\nTo finish:\n"; - for step in finish { - text += &format!("- {step}\n"); - } - } - (text, failed) -} - pub fn register(app: CliApp) -> CliApp { app.command( clap::Command::new("setup") diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index 40c0a0a6..7b5dd92a 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -21,94 +21,6 @@ fn path_with(programs: &[&str]) -> impl Fn(&str) -> Option { } } -#[test] -fn agents_are_found_by_program_variable_and_folder() { - let home = tempfile::tempdir().unwrap(); - let none = env_of(&[]); - assert_eq!(found(&none, &path_with(&[]), home.path()), vec![]); - assert_eq!( - found(&none, &path_with(&["claude", "codex"]), home.path()), - vec![ - Agent::ClaudeCode("/bin/claude".into()), - Agent::Codex("/bin/codex".into()) - ] - ); - assert_eq!( - found(&none, &path_with(&["codex"]), home.path()), - vec![Agent::Codex("/bin/codex".into())] - ); - for var in ["CURSOR_AGENT", "CURSOR_TRACE_ID"] { - assert_eq!( - found(&env_of(&[(var, "1")]), &path_with(&[]), home.path()), - vec![Agent::Cursor], - "{var}" - ); - } - assert_eq!( - found( - &env_of(&[("CURSOR_AGENT", "")]), - &path_with(&[]), - home.path() - ), - vec![] - ); - std::fs::create_dir(home.path().join(".cursor")).unwrap(); - assert_eq!( - found(&none, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor] - ); -} - -#[test] -fn claude_code_is_the_program_it_names_when_that_is_claude() { - let home = tempfile::tempdir().unwrap(); - for name in ["claude", "Claude.exe", "CLAUDE"] { - let dir = tempfile::tempdir().unwrap(); - let program = dir.path().join(name); - std::fs::write(&program, "").unwrap(); - let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); - assert_eq!( - found(&inside, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode(program.clone())], - "{name}" - ); - assert_eq!( - found(&inside, &path_with(&[]), home.path()), - vec![Agent::ClaudeCode(program)], - "{name}" - ); - } - let gone = env_of(&[("CLAUDE_CODE_EXECPATH", "/nowhere/claude")]); - assert_eq!(found(&gone, &path_with(&[]), home.path()), vec![]); - assert_eq!( - found(&gone, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode("/bin/claude".into())] - ); -} - -/// Claude Code installed by npm names the runtime that runs it, which -/// would load the project's own `plugin.js` given `plugin install`. -#[test] -fn a_runtime_named_by_claude_code_is_never_started() { - let home = tempfile::tempdir().unwrap(); - for name in ["node", "bun", "node.exe", "claude-desktop", "claude.cmd"] { - let dir = tempfile::tempdir().unwrap(); - let program = dir.path().join(name); - std::fs::write(&program, "").unwrap(); - let inside = env_of(&[("CLAUDE_CODE_EXECPATH", program.to_str().unwrap())]); - assert_eq!( - found(&inside, &path_with(&["claude"]), home.path()), - vec![Agent::ClaudeCode("/bin/claude".into())], - "{name}: claude on PATH instead" - ); - assert_eq!( - found(&inside, &path_with(&[]), home.path()), - vec![], - "{name}" - ); - } -} - #[cfg(unix)] #[test] fn a_program_on_path_is_an_executable_file() { @@ -172,12 +84,10 @@ const LIMITS_FOR_TESTS: Limits = Limits { total: Duration::from_secs(100), }; -/// Runs nothing; records each command and fails the ones `fails` names, -/// or, made with [`Recorder::exactly`], only that one command. +/// Runs nothing; records each command and fails those in `fails`. struct Recorder { ran: RefCell>, fails: Vec<&'static str>, - exactly: bool, } impl Recorder { @@ -185,14 +95,6 @@ impl Recorder { Self { ran: RefCell::new(Vec::new()), fails: fails.to_vec(), - exactly: false, - } - } - - fn exactly(fails: &'static str) -> Self { - Self { - exactly: true, - ..Self::new(&[fails]) } } @@ -200,8 +102,7 @@ impl Recorder { assert!(limit <= Duration::from_secs(30)); let line = format!("{} {}", program.display(), args.join(" ")); self.ran.borrow_mut().push(line.clone()); - let fails = |f: &&str| if self.exactly { line == *f } else { line.contains(f) }; - match self.fails.iter().any(fails) { + match self.fails.contains(&line.as_str()) { true => Err("exited with 1: boom".into()), false => Ok(()), } @@ -210,6 +111,11 @@ impl Recorder { fn ran(&self) -> Vec { self.ran.borrow().clone() } + + /// [`install`] with this recorder, the time used never counting. + fn install(&self, agents: &[Agent]) -> Vec { + install(agents, LIMITS_FOR_TESTS, &|p, a, l| self.run(p, a, l), &|| Duration::ZERO) + } } fn all_agents() -> Vec { @@ -221,75 +127,66 @@ fn all_agents() -> Vec { } #[test] -fn install_runs_each_agents_own_commands_in_order() { +fn install_runs_each_agents_own_commands_in_order_and_says_how_to_finish() { let recorder = Recorder::new(&[]); - let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { - recorder.run(p, a, l) - }); + let outcomes = recorder.install(&all_agents()); assert_eq!( recorder.ran(), [ + "claude plugin marketplace add elevenlabs/plugin", "claude plugin marketplace update elevenlabs", - "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin install elevenlabs@elevenlabs", "claude plugin update elevenlabs@elevenlabs", "codex plugin marketplace add elevenlabs/plugin", "codex plugin marketplace upgrade elevenlabs", "codex plugin add elevenlabs@elevenlabs", ] ); - assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); assert_eq!( - outcomes.iter().map(result_line).collect::>(), - [ - "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)", - "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)", - "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", - ] + report(&outcomes), + ( + vec![ + "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, \ + restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server." + .to_string(), + "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ + Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server." + .to_string(), + "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install.".to_string(), + ], + false + ) ); + + let recorder = Recorder::new(&["claude plugin install elevenlabs@elevenlabs"]); + let outcomes = recorder.install(&all_agents()[..2]); assert_eq!( - outcomes.iter().filter_map(finish).collect::>(), - [ - "restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server", - "restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", - ] + report(&outcomes), + ( + vec![ + "Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: boom." + .to_string(), + "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ + Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server." + .to_string(), + "To try again, run `elevenlabs setup --yes`.".to_string(), + ], + true + ), + "no finishing step for the agent that failed" + ); + assert_eq!( + report(&[]), + ( + vec![ + "No Claude Code, Codex or Cursor found. If you use Claude Code in the Claude desktop app, ask it \ + to run \"elevenlabs setup --yes\" for you. For other coding agents, install the ElevenLabs \ + skills with: npx skills add elevenlabs/skills" + .to_string() + ], + false + ) ); -} - -#[test] -fn only_the_install_commands_decide_an_agents_result() { - let refresh_fails = Recorder::new(&["marketplace update", "marketplace upgrade"]); - let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { - refresh_fails.run(p, a, l) - }); - assert!(outcomes.iter().all(|o| o.ok), "{outcomes:?}"); - assert_eq!(refresh_fails.ran().len(), 6); - - for (fails, agent) in [ - ("plugin install", "Claude Code"), - ("plugin update", "Claude Code"), - ("marketplace add", "Codex"), - ("plugin add", "Codex"), - ] { - let recorder = Recorder::new(&[fails]); - let outcomes = install(&all_agents(), LIMITS_FOR_TESTS, &|p, a, l| { - recorder.run(p, a, l) - }); - let failed: Vec<_> = outcomes.iter().filter(|o| !o.ok).collect(); - assert_eq!(failed.len(), 1, "{fails}: {outcomes:?}"); - assert_eq!(failed[0].agent, agent); - assert!(failed[0].message.contains(fails), "{failed:?}"); - assert!( - result_line(failed[0]).starts_with(&format!("{agent}: failed: `")) - && result_line(failed[0]).ends_with("` exited with 1: boom"), - "{}", - result_line(failed[0]) - ); - assert_eq!( - finish(failed[0]), - None, - "no finishing steps for a failed agent" - ); - } } #[test] @@ -305,16 +202,15 @@ fn agents_not_reached_in_time_count_as_failed() { command: Duration::from_secs(30), total: Duration::from_secs(50), }; - let outcomes = install_timed(&all_agents(), limits, &slow, &|| used.get()); + let outcomes = install(&all_agents(), limits, &slow, &|| used.get()); assert_eq!(recorder.ran().len(), 2, "{:?}", recorder.ran()); - assert!( - !outcomes[0].ok && outcomes[0].message.contains("ran out of time"), - "{outcomes:?}" - ); - assert!( - !outcomes[1].ok && outcomes[1].message.contains("ran out of time"), - "{outcomes:?}" - ); + for outcome in &outcomes[..2] { + assert_eq!( + (outcome.ok, outcome.message.as_str()), + (false, "ran out of time: setup stops after 50s"), + "{outcomes:?}" + ); + } assert!(outcomes[2].ok, "Cursor runs nothing"); } @@ -364,7 +260,7 @@ fn a_command_past_its_limit_is_stopped_and_fails() { "{:?}", started.elapsed() ); - assert_eq!(err, "ran out of time after 200 ms and was stopped"); + assert_eq!(err, "ran out of time after 200ms and was stopped"); let outcomes = install( &[Agent::ClaudeCode(hangs), Agent::Cursor], @@ -373,6 +269,7 @@ fn a_command_past_its_limit_is_stopped_and_fails() { total: Duration::from_millis(300), }, &|p, a, l| run_program(p, a, l, dir.path()), + &|| started.elapsed(), ); assert!( started.elapsed() < Duration::from_secs(5), @@ -387,26 +284,6 @@ fn a_command_past_its_limit_is_stopped_and_fails() { assert!(outcomes[1].ok); } -#[cfg(unix)] -#[test] -fn a_failing_command_reports_its_last_line_and_stdin_is_closed() { - let dir = tempfile::tempdir().unwrap(); - let fails = script( - dir.path(), - "codex", - "echo working; echo 'first' >&2; echo 'no such plugin' >&2; exit 3", - ); - assert_eq!( - run_program(&fails, &[], Duration::from_secs(10), dir.path()), - Err("exited with 3: no such plugin".into()) - ); - let reads = script(dir.path(), "reads", "read line && exit 1; exit 0"); - assert_eq!( - run_program(&reads, &[], Duration::from_secs(10), dir.path()), - Ok(()) - ); -} - /// Kills the process when the test ends, passed or not. #[cfg(unix)] struct KillOnDrop(i32); @@ -418,29 +295,6 @@ impl Drop for KillOnDrop { } } -#[cfg(unix)] -#[test] -fn a_command_past_its_limit_is_stopped_with_everything_it_started() { - let dir = tempfile::tempdir().unwrap(); - let pid = dir.path().join("pid"); - let hangs = script( - dir.path(), - "codex", - &format!("sleep 300 &\necho $! > '{}'\nwait", pid.display()), - ); - // Long enough for the stand-in to start its child on a busy machine. - let result = run_program(&hangs, &[], Duration::from_secs(2), dir.path()); - let pid: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); - let _cleanup = KillOnDrop(pid); - let err = result.unwrap_err(); - assert!(err.contains("ran out of time"), "{err}"); - let deadline = Instant::now() + Duration::from_secs(5); - while unsafe { libc::kill(pid, 0) } == 0 { - assert!(Instant::now() < deadline, "sleep {pid} still runs"); - std::thread::sleep(Duration::from_millis(20)); - } -} - #[cfg(unix)] #[test] fn agents_run_in_the_home_folder_not_the_project() { @@ -461,85 +315,6 @@ fn agents_run_in_the_home_folder_not_the_project() { assert_eq!(lines, [home.clone(), home]); } -fn skill(dir: &Path, name: &str, text: &str) { - std::fs::create_dir_all(dir.join(name).join("references")).unwrap(); - std::fs::write(dir.join(name).join("SKILL.md"), text).unwrap(); - std::fs::write(dir.join(name).join("references").join("a.md"), "ref").unwrap(); -} - -#[test] -fn the_skills_go_into_agents_skills_marked_as_setups() { - let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); - skill(unpacked.path(), "text-to-speech", "new tts"); - skill(unpacked.path(), "music", "new music"); - skill(unpacked.path(), "dubbing", "new dubbing"); - std::fs::create_dir_all(unpacked.path().join("evals").join("cases")).unwrap(); - std::fs::write(unpacked.path().join("README.md"), "readme").unwrap(); - let root = home.path().join(".agents").join("skills"); - - // One of the developer's own, and one setup wrote before. - std::fs::create_dir_all(root.join("music")).unwrap(); - std::fs::write(root.join("music").join("SKILL.md"), "mine").unwrap(); - std::fs::create_dir_all(root.join("dubbing")).unwrap(); - std::fs::write(root.join("dubbing").join("SKILL.md"), "old dubbing").unwrap(); - std::fs::write(root.join("dubbing").join("stale.md"), "old").unwrap(); - std::fs::write(root.join("dubbing").join(MARKER), "").unwrap(); - - let copied = copy_skills(unpacked.path(), home.path()).unwrap(); - assert_eq!( - copied, - Copied { - written: vec!["dubbing".into(), "text-to-speech".into()], - kept: vec!["music".into()], - } - ); - let read = |p: &[&str]| { - std::fs::read_to_string(p.iter().fold(root.clone(), |a, b| a.join(b))).unwrap() - }; - assert_eq!(read(&["text-to-speech", "SKILL.md"]), "new tts"); - assert_eq!(read(&["text-to-speech", "references", "a.md"]), "ref"); - assert_eq!(read(&["text-to-speech", MARKER]), ""); - assert_eq!(read(&["dubbing", "SKILL.md"]), "new dubbing"); - assert!( - !root.join("dubbing").join("stale.md").exists(), - "replaced whole" - ); - assert_eq!(read(&["music", "SKILL.md"]), "mine"); - assert!(!root.join("music").join(MARKER).exists()); - assert_eq!(std::fs::read_dir(root.join("music")).unwrap().count(), 1); - assert!(!root.join("evals").exists() && !root.join("README.md").exists()); - - // Again: everything setup wrote is replaced, the developer's still left. - let again = copy_skills(unpacked.path(), home.path()).unwrap(); - assert_eq!(again, copied); -} - -#[cfg(unix)] -#[test] -fn a_copy_that_stops_partway_is_replaced_next_time() { - use std::os::unix::fs::PermissionsExt; - if unsafe { libc::geteuid() } == 0 { - // Root reads a file whatever its mode, so the copy can't be made to stop. - return; - } - let (unpacked, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); - skill(unpacked.path(), "music", "new music"); - let unreadable = unpacked.path().join("music").join("references").join("a.md"); - std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); - let target = skills_root(home.path()).join("music"); - - assert!(copy_skills(unpacked.path(), home.path()).is_err()); - assert!(target.join(MARKER).is_file(), "marked before the copy"); - - std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).unwrap(); - let copied = copy_skills(unpacked.path(), home.path()).unwrap(); - assert_eq!(copied.written, ["music"]); - assert_eq!( - std::fs::read_to_string(target.join("references").join("a.md")).unwrap(), - "ref" - ); -} - #[test] fn the_offer_names_the_agents_that_can_take_the_plugin() { let claude = Agent::ClaudeCode("claude".into()); @@ -556,10 +331,7 @@ fn the_offer_names_the_agents_that_can_take_the_plugin() { question(&[claude, codex, Agent::Cursor]), plugin("Claude Code and Codex") ); - assert_eq!( - question(&[]), - Some("Want me to install the ElevenLabs skills for your coding agents?".into()) - ); + assert_eq!(question(&[]), None, "nothing to install into"); assert_eq!( question(&[Agent::Cursor]), None, @@ -587,16 +359,13 @@ fn the_later_command_runs_the_cli_the_way_this_run_was_started() { #[test] fn the_plan_says_what_would_happen_for_each_agent() { - let home = Path::new("/home/dev"); assert_eq!( - plan(&[Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor], home), + plan(&[Agent::ClaudeCode("/bin/claude".into()), Agent::Cursor]), [ "Claude Code (/bin/claude): install the ElevenLabs plugin (skills and the hosted MCP server)", "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install", ] ); - assert_eq!(plan(&[], home).len(), 1); - assert!(plan(&[], home)[0].contains(&skills_root(home).display().to_string())); } // ── finding agents, as a table ────────────────────────────────────── @@ -616,6 +385,8 @@ enum CursorBy { Folder, AgentVariable, TraceVariable, + /// `CURSOR_AGENT` set but empty, which doesn't count. + EmptyVariable, Nothing, } @@ -631,6 +402,7 @@ fn every_combination_of_claude_code_codex_and_cursor_is_found() { CursorBy::Folder, CursorBy::AgentVariable, CursorBy::TraceVariable, + CursorBy::EmptyVariable, CursorBy::Nothing, ] { let (home, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); @@ -653,9 +425,10 @@ fn every_combination_of_claude_code_codex_and_cursor_is_found() { CursorBy::Folder => std::fs::create_dir(home.path().join(".cursor")).unwrap(), CursorBy::AgentVariable => vars.push(("CURSOR_AGENT", "1")), CursorBy::TraceVariable => vars.push(("CURSOR_TRACE_ID", "a1b2")), + CursorBy::EmptyVariable => vars.push(("CURSOR_AGENT", "")), CursorBy::Nothing => {} } - if !matches!(cursor, CursorBy::Nothing) { + if !matches!(cursor, CursorBy::EmptyVariable | CursorBy::Nothing) { want.push(Agent::Cursor); } let path = std::env::join_paths([bin.path()]).unwrap(); @@ -665,7 +438,7 @@ fn every_combination_of_claude_code_codex_and_cursor_is_found() { } } } - assert_eq!(rows, 32); + assert_eq!(rows, 40); } /// A path to `to` relative to this process's folder, as a PATH entry can be. @@ -766,11 +539,24 @@ fn claude_code_execpath_is_used_only_when_it_names_a_claude_file() { // the home folder, or on Windows from the project, would not be them. let relative = relative_to_here(odd.parent().unwrap()).join("claude"); let dot_relative = Path::new(".").join(&relative); + // Each in a folder of its own, so the names differ even where case doesn't. + let named = |name: &str| { + let dir = base.path().join(format!("named {name}")); + std::fs::create_dir(&dir).unwrap(); + script(&dir, name, "exit 0") + }; + let (claude_exe, upper) = (named("Claude.exe"), named("CLAUDE")); + let (node_exe, claude_cmd, desktop) = (named("node.exe"), named("claude.cmd"), named("claude-desktop")); // (row, CLAUDE_CODE_EXECPATH, the program it names when it counts) - let rows: [(&str, &Path, Option<&Path>); 9] = [ + let rows: [(&str, &Path, Option<&Path>); 14] = [ + ("Claude.exe, as Windows spells it", &claude_exe, Some(&claude_exe)), + ("CLAUDE, in capitals", &upper, Some(&upper)), ("node", &node, None), + ("node.exe", &node_exe, None), ("bun", &bun, None), + ("claude.cmd, a shim that would run node", &claude_cmd, None), + ("claude-desktop", &desktop, None), ("a missing file", &missing, None), ("a folder named claude", &named_folder, None), ("an empty value", Path::new(""), None), @@ -792,16 +578,21 @@ fn claude_code_execpath_is_used_only_when_it_names_a_claude_file() { // ── installing, as a table and as a randomized run ────────────────── -/// The six commands, in the order `install` runs them. -const COMMANDS: [&str; 6] = [ +/// The seven commands, in the order `install` runs them. +const COMMANDS: [&str; 7] = [ + "claude plugin marketplace add elevenlabs/plugin", "claude plugin marketplace update elevenlabs", - "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin install elevenlabs@elevenlabs", "claude plugin update elevenlabs@elevenlabs", "codex plugin marketplace add elevenlabs/plugin", "codex plugin marketplace upgrade elevenlabs", "codex plugin add elevenlabs@elevenlabs", ]; +/// The commands of [`COMMANDS`] whose result decides their agent's: Claude +/// Code's `plugin install` and `plugin update`, and Codex's `plugin add`. +const DECIDES: [usize; 3] = [2, 3, 6]; + const INSTALLED: &str = "installed the ElevenLabs plugin (skills and the hosted MCP server)"; #[test] @@ -809,37 +600,56 @@ fn each_failing_command_stops_its_own_agent_and_no_other() { let claude = Agent::ClaudeCode("claude".into()); let codex = Agent::Codex("codex".into()); let both = [claude.clone(), codex.clone(), Agent::Cursor]; - // (agents, the command that fails, the commands that ran, each agent's result) - let rows: [(&[Agent], usize, &[usize], &[bool]); 12] = [ - (&[claude.clone()], 0, &[0, 1, 2], &[true]), - (&[claude.clone()], 1, &[0, 1], &[false]), - (&[claude.clone()], 2, &[0, 1, 2], &[false]), - (&[codex.clone()], 3, &[3], &[false]), - (&[codex.clone()], 4, &[3, 4, 5], &[true]), - (&[codex.clone()], 5, &[3, 4, 5], &[false]), - (&both, 0, &[0, 1, 2, 3, 4, 5], &[true, true, true]), - (&both, 1, &[0, 1, 3, 4, 5], &[false, true, true]), - (&both, 2, &[0, 1, 2, 3, 4, 5], &[false, true, true]), - (&both, 3, &[0, 1, 2, 3], &[true, false, true]), - (&both, 4, &[0, 1, 2, 3, 4, 5], &[true, true, true]), - (&both, 5, &[0, 1, 2, 3, 4, 5], &[true, false, true]), + // (agents, the commands that fail, the commands that ran, each agent's + // result: `None` for success, else the command whose error it shows) + let rows: [(&[Agent], &[usize], &[usize], &[Option]); 18] = [ + (&[claude.clone()], &[0], &[0, 1, 2, 3], &[None]), + (&[claude.clone()], &[1], &[0, 1, 2, 3], &[None]), + (&[claude.clone()], &[0, 1], &[0, 1, 2, 3], &[None]), + (&[claude.clone()], &[2], &[0, 1, 2], &[Some(2)]), + (&[claude.clone()], &[3], &[0, 1, 2, 3], &[Some(3)]), + (&[claude.clone()], &[0, 2], &[0, 1, 2], &[Some(0)]), + (&[claude.clone()], &[1, 3], &[0, 1, 2, 3], &[Some(1)]), + (&[codex.clone()], &[4], &[4, 5, 6], &[None]), + (&[codex.clone()], &[5], &[4, 5, 6], &[None]), + (&[codex.clone()], &[6], &[4, 5, 6], &[Some(6)]), + (&[codex.clone()], &[4, 6], &[4, 5, 6], &[Some(4)]), + (&both, &[], &[0, 1, 2, 3, 4, 5, 6], &[None, None, None]), + (&both, &[0, 4], &[0, 1, 2, 3, 4, 5, 6], &[None, None, None]), + (&both, &[2], &[0, 1, 2, 4, 5, 6], &[Some(2), None, None]), + (&both, &[3], &[0, 1, 2, 3, 4, 5, 6], &[Some(3), None, None]), + (&both, &[5], &[0, 1, 2, 3, 4, 5, 6], &[None, None, None]), + (&both, &[6], &[0, 1, 2, 3, 4, 5, 6], &[None, Some(6), None]), + (&both, &[0, 2, 6], &[0, 1, 2, 4, 5, 6], &[Some(0), Some(6), None]), ]; - for (agents, fails, ran, ok) in rows { - let row = format!("{:?} with `{}` failing", agents.iter().map(Agent::name).collect::>(), COMMANDS[fails]); - let recorder = Recorder::exactly(COMMANDS[fails]); - let outcomes = install(agents, LIMITS_FOR_TESTS, &|p, a, l| recorder.run(p, a, l)); + for (agents, fails, ran, results) in rows { + let fails: Vec<&str> = fails.iter().map(|&i| COMMANDS[i]).collect(); + let row = format!("{:?} with {fails:?} failing", agents.iter().map(Agent::name).collect::>()); + let recorder = Recorder::new(&fails); + let outcomes = recorder.install(agents); let want: Vec = ran.iter().map(|&i| COMMANDS[i].to_string()).collect(); assert_eq!(recorder.ran(), want, "{row}"); let want: Vec = agents .iter() - .zip(ok) - .map(|(agent, &ok)| Outcome { - agent: agent.name(), - ok, - message: match (agent, ok) { - (Agent::Cursor, _) => format!("open {CURSOR_PAGE} and select Install"), - (_, true) => INSTALLED.into(), - (_, false) => format!("`{}` exited with 1: boom", COMMANDS[fails]), + .zip(results) + .map(|(agent, shows)| match (agent, shows) { + (Agent::Cursor, _) => Outcome { + agent: "Cursor", + ok: true, + message: format!("open {CURSOR_PAGE} and select Install"), + finish: None, + }, + (_, None) => Outcome { + agent: agent.name(), + ok: true, + message: INSTALLED.into(), + finish: agent.finish(), + }, + (_, Some(i)) => Outcome { + agent: agent.name(), + ok: false, + message: format!("`{}` exited with 1: boom", COMMANDS[*i]), + finish: None, }, }) .collect(); @@ -894,7 +704,7 @@ fn install_keeps_its_rules_under_2000_random_failure_patterns() { let rng = RefCell::new(Rng(rng.next() | 1)); let clock = std::cell::Cell::new(Duration::ZERO); let calls = RefCell::new(Vec::::new()); - let outcomes = install_timed( + let outcomes = install( &agents, limits, &|program, args, limit| { @@ -949,12 +759,12 @@ fn install_keeps_its_rules_under_2000_random_failure_patterns() { "{why}: {call:?}" ); } - let decides = |line: &str| { - agent.steps().iter().any(|(args, decides)| *decides && line.ends_with(&args.join(" "))) - }; + let decides = |line: &str| DECIDES.iter().any(|&i| COMMANDS[i] == line); let deciding_failure = mine.iter().position(|c| c.failed && decides(&c.line)); if let Some(at) = deciding_failure { assert_eq!(at + 1, mine.len(), "{why}: nothing runs after a deciding command fails"); + let first = mine.iter().find(|c| c.failed).unwrap(); + assert_eq!(outcome.message, format!("`{}` failed", first.line), "{why}: the first failed command shows"); } let finished = mine.len() == planned.len(); if !finished && deciding_failure.is_none() { @@ -963,11 +773,10 @@ fn install_keeps_its_rules_under_2000_random_failure_patterns() { "{why}: stopped early only when time ran out: {outcome:?}" ); } - let every_deciding_command_worked = agent - .steps() + let every_deciding_command_worked = planned .iter() - .filter(|(_, decides)| *decides) - .all(|(args, _)| mine.iter().any(|c| c.line.ends_with(&args.join(" ")) && !c.failed)); + .filter(|c| decides(c)) + .all(|c| mine.iter().any(|m| &m.line == c && !m.failed)); let want_ok = matches!(agent, Agent::Cursor) || every_deciding_command_worked; assert_eq!(outcome.ok, want_ok, "{why}: {outcome:?} after {lines:?}"); } @@ -975,353 +784,6 @@ fn install_keeps_its_rules_under_2000_random_failure_patterns() { } } -// ── the skills copy, against a local server standing in for GitHub ── - -/// Everything under `root` but `/.agents/skills`: each path with its -/// kind, its bytes or its link's target, and its permissions. The `.agents` -/// folder that holds it is left out too, so making it counts as no change. -#[cfg(unix)] -fn outside_the_skills(root: &Path) -> std::collections::BTreeMap { - let mut all = tree(root, &[skills_root(Path::new(""))]); - if all.get(Path::new(".agents")).is_some_and(|what| what.starts_with("folder")) { - all.remove(Path::new(".agents")); - } - all -} - -/// Every path under `root` but those under `skip`, with what it holds. -#[cfg(unix)] -fn tree(root: &Path, skip: &[PathBuf]) -> std::collections::BTreeMap { - use std::os::unix::fs::PermissionsExt; - fn walk(root: &Path, at: &Path, skip: &[PathBuf], out: &mut std::collections::BTreeMap) { - for entry in std::fs::read_dir(at).unwrap().flatten() { - let path = entry.path(); - let relative = path.strip_prefix(root).unwrap().to_path_buf(); - if skip.contains(&relative) { - continue; - } - let meta = std::fs::symlink_metadata(&path).unwrap(); - let mode = meta.permissions().mode() & 0o7777; - let what = if meta.file_type().is_symlink() { - format!("link to {}", std::fs::read_link(&path).unwrap().display()) - } else if meta.is_dir() { - format!("folder {mode:o}") - } else { - format!("file {mode:o} {:?}", std::fs::read(&path).unwrap()) - }; - out.insert(relative, what); - if meta.is_dir() && !meta.file_type().is_symlink() { - walk(root, &path, skip, out); - } - } - } - let mut out = std::collections::BTreeMap::new(); - walk(root, root, skip, &mut out); - out -} - -/// A `.tar.gz` of `files` (path and text; a path ending in `/` is a folder, -/// text starting with `->` a symlink) under `skills-main/`, made with the -/// system's tar as GitHub's archive is laid out. -#[cfg(unix)] -fn archive(files: &[(&str, &str)]) -> Vec { - let dir = tempfile::tempdir().unwrap(); - let top = dir.path().join("skills-main"); - std::fs::create_dir(&top).unwrap(); - for (path, text) in files { - let at = top.join(path); - std::fs::create_dir_all(if path.ends_with('/') { &at } else { at.parent().unwrap() }).unwrap(); - if let Some(target) = text.strip_prefix("->") { - std::os::unix::fs::symlink(target, &at).unwrap(); - } else if !path.ends_with('/') { - std::fs::write(&at, text).unwrap(); - } - } - let out = dir.path().join("skills.tar.gz"); - let status = Command::new("tar") - .arg("-czf") - .arg(&out) - .arg("-C") - .arg(dir.path()) - .arg("skills-main") - .status() - .unwrap(); - assert!(status.success()); - std::fs::read(out).unwrap() -} - -#[cfg(unix)] -const TWO_SKILLS: &[(&str, &str)] = &[ - ("music/SKILL.md", "# music"), - ("music/references/api.md", "the api"), - ("text-to-speech/SKILL.md", "# tts"), - ("README.md", "readme"), - ("evals/cases/a.json", "{}"), -]; - -/// A server standing in for GitHub that answers `GET /` with each of -/// `answers`, and the URL of the first. -#[cfg(unix)] -async fn github(answers: Vec<(&str, wiremock::ResponseTemplate)>) -> (wiremock::MockServer, String) { - let server = wiremock::MockServer::start().await; - let first = format!("{}/{}", server.uri(), answers[0].0); - for (name, answer) in answers { - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path(format!("/{name}"))) - .respond_with(answer) - .mount(&server) - .await; - } - (server, first) -} - -#[cfg(unix)] -fn body(bytes: Vec) -> wiremock::ResponseTemplate { - wiremock::ResponseTemplate::new(200).set_body_bytes(bytes) -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread")] -async fn the_skills_download_is_copied_or_fails_with_what_went_wrong() { - let good = archive(TWO_SKILLS); - let truncated = good[..good.len() / 2].to_vec(); - let odd = archive(&[ - ("music/SKILL.md", "# music"), - ("music/hosts", "->/etc/hosts"), - ("music/up", "->../../outside"), - ("linked", "->music"), - ("notes", "a file where a skill folder would be"), - ("weird/SKILL.md/", ""), - ("weird/other.md", "no SKILL.md file"), - ]); - let none = archive(&[("README.md", "readme"), ("evals/a.json", "{}")]); - let copied = |n: usize| move |root: &Path| format!("copied {n} ElevenLabs skills into {}", root.display()); - type Want = Box (bool, String)>; - let ok = |n: usize| -> Want { Box::new(move |_, root| (true, copied(n)(root))) }; - let starts = |ok: bool, prefix: &'static str| -> Want { - Box::new(move |url, _| (ok, prefix.replace("{url}", url))) - }; - // (row, how the server answers, the outcome, the skill folders left) - let rows: Vec<(&str, Vec<(&str, wiremock::ResponseTemplate)>, Want, &[&str])> = vec![ - ("a good archive", vec![("main.tar.gz", body(good.clone()))], ok(2), &["music", "text-to-speech"]), - ( - "a redirect to it", - vec![ - ("archive", wiremock::ResponseTemplate::new(302).insert_header("Location", "/codeload.tar.gz")), - ("codeload.tar.gz", body(good.clone())), - ], - ok(2), - &["music", "text-to-speech"], - ), - ( - "HTTP 404", - vec![("main.tar.gz", wiremock::ResponseTemplate::new(404))], - starts(false, "couldn't download the skills from {url}: HTTP status client error (404 Not Found)"), - &[], - ), - ( - "HTTP 500", - vec![("main.tar.gz", wiremock::ResponseTemplate::new(500))], - starts(false, "couldn't download the skills from {url}: HTTP status server error (500 Internal Server Error)"), - &[], - ), - ( - "a body that is not gzip", - vec![("main.tar.gz", body(b"not here".to_vec()))], - starts(false, "tar couldn't unpack the skills: "), - &[], - ), - ( - "a truncated archive", - vec![("main.tar.gz", body(truncated))], - starts(false, "tar couldn't unpack the skills: "), - &[], - ), - ( - "an archive with no skills", - vec![("main.tar.gz", body(none))], - starts(false, "the download from {url} held no skills"), - &[], - ), - ( - "links, a file and a SKILL.md folder where skills would be", - vec![("main.tar.gz", body(odd))], - ok(1), - &["music"], - ), - ]; - for (row, answers, want, left) in rows { - let (_server, url) = github(answers).await; - let home = tempfile::tempdir().unwrap(); - std::fs::write(home.path().join(".profile"), "mine").unwrap(); - let before = outside_the_skills(home.path()); - let outcome = skills_from(&url, home.path()); - let root = skills_root(home.path()); - let (ok, message) = want(&url, &root); - assert_eq!(outcome.ok, ok, "{row}: {outcome:?}"); - assert!(outcome.message.starts_with(&message), "{row}: {:?} should start {message:?}", outcome.message); - assert_eq!(outside_the_skills(home.path()), before, "{row}: nothing else in the home folder changed"); - let mut there: Vec = std::fs::read_dir(&root) - .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect()) - .unwrap_or_default(); - there.sort(); - assert_eq!(there, left, "{row}"); - if left.contains(&"music") { - let music = tree(&root.join("music"), &[]); - let names: Vec = music.keys().map(|p| p.display().to_string()).collect(); - let want: &[&str] = if row.starts_with("links") { - &[MARKER, "SKILL.md"] - } else { - &[MARKER, "SKILL.md", "references", "references/api.md"] - }; - assert_eq!(names, want, "{row}: no link is followed or copied"); - } - } -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread")] -async fn the_skills_land_whatever_the_home_folder_already_holds() { - use std::os::unix::fs::PermissionsExt; - let (_server, url) = github(vec![("main.tar.gz", body(archive(TWO_SKILLS)))]).await; - type Prepare = fn(&Path); - // (row, the home folder before, ok, what the message says after the root) - let rows: [(&str, Prepare, bool, &str); 7] = [ - ("a fresh home folder", |_| {}, true, ""), - ( - "an .agents folder holding other things", - |h| { - std::fs::create_dir_all(h.join(".agents/other")).unwrap(); - std::fs::write(h.join(".agents/other/keep.md"), "keep").unwrap(); - }, - true, - "", - ), - ( - "a skill folder setup wrote", - |h| { - let music = skills_root(h).join("music"); - std::fs::create_dir_all(&music).unwrap(); - std::fs::write(music.join(MARKER), "").unwrap(); - std::fs::write(music.join("stale.md"), "old").unwrap(); - }, - true, - "", - ), - ( - "a skill folder of the developer's own", - |h| { - let music = skills_root(h).join("music"); - std::fs::create_dir_all(&music).unwrap(); - std::fs::write(music.join("SKILL.md"), "mine").unwrap(); - }, - true, - "; left alone, since setup didn't write them: music", - ), - ( - "a skill folder that is a symlink to a marked folder", - |h| { - let elsewhere = h.join("elsewhere"); - std::fs::create_dir_all(&elsewhere).unwrap(); - std::fs::write(elsewhere.join(MARKER), "").unwrap(); - std::fs::create_dir_all(skills_root(h)).unwrap(); - std::os::unix::fs::symlink(&elsewhere, skills_root(h).join("music")).unwrap(); - }, - true, - "; left alone, since setup didn't write them: music", - ), - ( - "an .agents that is a file", - |h| std::fs::write(h.join(".agents"), "not a folder").unwrap(), - false, - "", - ), - ( - "a read-only skill folder setup wrote", - |h| { - let music = skills_root(h).join("music"); - std::fs::create_dir_all(&music).unwrap(); - std::fs::write(music.join(MARKER), "").unwrap(); - std::fs::write(music.join("SKILL.md"), "old").unwrap(); - std::fs::set_permissions(&music, std::fs::Permissions::from_mode(0o555)).unwrap(); - }, - false, - "", - ), - ]; - for (row, prepare, ok, kept) in rows { - let home = tempfile::tempdir().unwrap(); - prepare(home.path()); - let root = skills_root(home.path()); - let before = outside_the_skills(home.path()); - let skills_before = root.is_dir().then(|| tree(&root, &[])); - let outcome = skills_from(&url, home.path()); - assert_eq!(outcome.ok, ok, "{row}: {outcome:?}"); - assert_eq!(outside_the_skills(home.path()), before, "{row}: nothing outside the skills folder changed"); - if ok { - let written = if kept.is_empty() { 2 } else { 1 }; - assert_eq!( - outcome.message, - format!("copied {written} ElevenLabs skills into {}{kept}", root.display()), - "{row}" - ); - let tts = tree(&root.join("text-to-speech"), &[]); - assert_eq!(tts.len(), 2, "{row}: {tts:?}"); - } else if row.contains("read-only") { - assert!(outcome.message.starts_with(&root.join("music").display().to_string()), "{row}: {outcome:?}"); - assert_eq!(Some(tree(&root, &[])), skills_before, "{row}: left as it was"); - std::fs::set_permissions(root.join("music"), std::fs::Permissions::from_mode(0o755)).unwrap(); - } else { - assert!(outcome.message.contains(".agents"), "{row}: {outcome:?}"); - } - if row.contains("own") || row.contains("symlink") { - assert_eq!(Some(tree(&root, &[]).into_iter().filter(|(p, _)| p.starts_with("music")).collect()), - skills_before.map(|t| t.into_iter().filter(|(p, _)| p.starts_with("music")).collect::>()), - "{row}: the folder is left as it was"); - } - } -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread")] -async fn five_runs_give_byte_identical_skills_with_nested_folders_and_odd_names() { - let files: &[(&str, &str)] = &[ - ("music/SKILL.md", "# music"), - ("music/a/b/c/deep.md", "deep"), - ("music/with space.md", "space"), - ("music/ünï 日本.md", "unicode"), - ("music/-dash.md", "dash"), - ("music/.hidden", "hidden"), - ("music/empty/", ""), - ("dubbing/SKILL.md", "# dubbing"), - ]; - let (_server, url) = github(vec![("main.tar.gz", body(archive(files)))]).await; - let home = tempfile::tempdir().unwrap(); - let before = outside_the_skills(home.path()); - let root = skills_root(home.path()); - let mut first = None; - for run in 0..5 { - let outcome = skills_from(&url, home.path()); - assert!(outcome.ok, "run {run}: {outcome:?}"); - let now = tree(&root, &[]); - match &first { - None => first = Some(now), - Some(first) => assert_eq!(&now, first, "run {run}"), - } - assert_eq!(outside_the_skills(home.path()), before, "run {run}"); - } - // Read back by name: macOS's tar may store a name in another Unicode form. - for (path, text) in files { - let at = root.join(path.trim_end_matches('/')); - if path.ends_with('/') { - assert!(at.is_dir(), "{path}"); - } else { - assert_eq!(std::fs::read_to_string(&at).unwrap(), *text, "{path}"); - } - } - assert_eq!(first.unwrap().len(), files.len() + 7, "the files, five folders and the two marks"); -} - // ── starting a program, against real child processes ─────────────── /// Waits for `file` to hold a pid, polling until a deadline. @@ -1421,9 +883,12 @@ fn a_program_that_vanished_or_lost_its_execute_bit_since_it_was_found_fails_to_s std::fs::remove_file(&program).unwrap(); let err = run_program(&program, &[], Duration::from_secs(5), home.path()).unwrap_err(); assert!(err.starts_with("couldn't start it: "), "vanished: {err}"); - let outcomes = install(&[Agent::Codex(program)], LIMITS_FOR_TESTS, &|p, a, l| { - run_program(p, a, l, home.path()) - }); + let outcomes = install( + &[Agent::Codex(program)], + LIMITS_FOR_TESTS, + &|p, a, l| run_program(p, a, l, home.path()), + &|| Duration::ZERO, + ); assert!(!outcomes[0].ok && outcomes[0].message.contains("couldn't start it"), "{outcomes:?}"); } @@ -1447,7 +912,7 @@ fn a_command_past_its_limit_is_stopped_with_its_children_and_grandchildren() { let result = run_program(&hangs, &[], limit, dir.path()); let (child, grandchild) = (pid_in(&child), pid_in(&grandchild)); let _cleanup = (KillOnDrop(child), KillOnDrop(grandchild)); - assert_eq!(result, Err("ran out of time after 1 seconds and was stopped".into())); + assert_eq!(result, Err("ran out of time after 1.5s and was stopped".into())); assert!(started.elapsed() < limit + Duration::from_secs(3), "{:?}", started.elapsed()); assert!(gone(child), "the child {child} still runs"); assert!(gone(grandchild), "the grandchild {grandchild} still runs"); @@ -1467,7 +932,7 @@ fn fifty_runs_in_a_row_leave_no_process_behind() { let (program, limit, want) = match run % 3 { 0 => (&works, Duration::from_secs(10), Ok(())), 1 => (&fails, Duration::from_secs(10), Err("exited with 1: broke".to_string())), - _ => (&hangs, Duration::from_millis(100), Err("ran out of time after 100 ms and was stopped".to_string())), + _ => (&hangs, Duration::from_millis(100), Err("ran out of time after 100ms and was stopped".to_string())), }; assert_eq!(run_program(program, &[], limit, dir.path()), want, "run {run}"); } @@ -1532,35 +997,6 @@ fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characte } } -#[cfg(unix)] -#[test] -fn a_failed_command_reports_a_short_clean_line_whatever_it_printed() { - let dir = tempfile::tempdir().unwrap(); - // (row, the stand-in's script, what run_program returns) - let rows: [(&str, &str, String); 3] = [ - ( - "5 MB on stderr with no newline", - "head -c 5000000 /dev/zero | tr '\\0' e >&2; exit 1", - format!("exited with 1: {}…", "e".repeat(LINE_MAX - 1)), - ), - ( - "megabytes, then a short last line", - "head -c 5000000 /dev/zero | tr '\\0' e >&2; printf '\\nthe real error\\n' >&2; exit 1", - "exited with 1: the real error".into(), - ), - ( - "a progress bar, then a coloured error", - "i=0; while [ $i -lt 2000 ]; do printf 'step %d\\r' $i; i=$((i+1)); done; \ - printf '\\033[31merror:\\033[0m it broke\\r\\n' >&2; exit 2", - "exited with 2: error: it broke".into(), - ), - ]; - for (row, body, want) in rows { - let program = script(dir.path(), "claude", body); - assert_eq!(run_program(&program, &[], Duration::from_secs(20), dir.path()), Err(want), "{row}"); - } -} - #[cfg(unix)] #[test] fn a_missing_home_folder_is_named_and_the_agent_is_not_blamed() { @@ -1573,43 +1009,6 @@ fn a_missing_home_folder_is_named_and_the_agent_is_not_blamed() { ); } -// ── setup --yes with no agent found ───────────────────────────────── - -/// `setup --yes` with no agent found, the skills downloaded from a server -/// standing in for GitHub: what it prints and whether it fails. -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread")] -async fn with_no_agent_found_setup_prints_what_the_skills_copy_did() { - let empty = archive(&[("README.md", "readme")]); - // (row, how the server answers, whether it fails, what it prints) - let rows: [(&str, wiremock::ResponseTemplate, bool, &str); 3] = [ - ("a good archive", body(archive(TWO_SKILLS)), false, "Skills: copied 2 ElevenLabs skills into {root}\n"), - ( - "an archive with no skills", - body(empty), - true, - "Skills: failed: the download from {url} held no skills\n\ - To try again, run `elevenlabs setup --yes`.\n", - ), - ( - "a server error", - wiremock::ResponseTemplate::new(500), - true, - "Skills: failed: couldn't download the skills from {url}: HTTP status server error (500 Internal \ - Server Error) for url ({url})\nTo try again, run `elevenlabs setup --yes`.\n", - ), - ]; - for (row, answer, fails, says) in rows { - let (_server, url) = github(vec![("main.tar.gz", answer)]).await; - let home = tempfile::tempdir().unwrap(); - let root = skills_root(home.path()); - let (text, failed) = report(&run_from(&[], home.path(), LIMITS_FOR_TESTS, &url)); - assert_eq!(failed, fails, "{row}"); - let says = says.replace("{root}", &root.display().to_string()).replace("{url}", &url); - assert_eq!(text, says, "{row}"); - } -} - /// A program that never stops starting new ones while its group is killed: /// one started as the signal lands must not survive it. #[cfg(unix)] @@ -1620,7 +1019,7 @@ fn a_program_starting_others_as_it_is_stopped_leaves_none_running() { let forks = script(dir.path(), "forks", &format!("while :; do {sleep} & done")); for run in 0..20 { let got = run_program(&forks, &[], Duration::from_millis(50), dir.path()); - assert_eq!(got, Err("ran out of time after 50 ms and was stopped".to_string()), "run {run}"); + assert_eq!(got, Err("ran out of time after 50ms and was stopped".to_string()), "run {run}"); } let deadline = Instant::now() + Duration::from_secs(5); loop { @@ -1632,3 +1031,61 @@ fn a_program_starting_others_as_it_is_stopped_leaves_none_running() { std::thread::sleep(Duration::from_millis(20)); } } + +// ── a signal caught during `run` ──────────────────────────────────── + +/// The variable that makes a test below act as the child it starts. +#[cfg(unix)] +const CHILD: &str = "ELEVENLABS_SETUP_TEST_CHILD"; + +/// Run the test `name` of this module again in a child copy of this test +/// program, with SIGTERM at its default and [`CHILD`] set, and how the child +/// ended. A name that matches no test ends it with 0. +#[cfg(unix)] +fn in_a_child(name: &str) -> std::process::ExitStatus { + use std::os::unix::process::CommandExt; + let module = module_path!().split_once("::").unwrap().1; + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([&format!("{module}::{name}"), "--exact", "--test-threads=1", "-q"]) + .env(CHILD, "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + unsafe { + child.pre_exec(|| { + libc::signal(libc::SIGTERM, libc::SIG_DFL); + Ok(()) + }); + } + child.status().unwrap() +} + +/// A signal caught between two commands stops the run before the next starts. +#[cfg(unix)] +#[test] +fn a_signal_caught_before_a_command_starts_exits_before_it_starts() { + if std::env::var_os(CHILD).is_some() { + let _catch = interrupt::Catch::start(); + unsafe { libc::raise(libc::SIGTERM) }; + // Started, a missing program would only fail. + let _ = run_program(Path::new("/nonexistent/claude"), &[], Duration::from_secs(5), Path::new("/")); + std::process::exit(3); + } + let status = in_a_child("a_signal_caught_before_a_command_starts_exits_before_it_starts"); + assert_eq!(status.code(), Some(143), "{status:?}"); +} + +/// A signal caught after the last command still ends the run as interrupted. +#[cfg(unix)] +#[test] +fn a_signal_caught_after_the_last_command_exits_once_the_handlers_are_back() { + if std::env::var_os(CHILD).is_some() { + let catch = interrupt::Catch::start(); + unsafe { libc::raise(libc::SIGTERM) }; + catch.finish(); + std::process::exit(3); + } + let status = in_a_child("a_signal_caught_after_the_last_command_exits_once_the_handlers_are_back"); + assert_eq!(status.code(), Some(143), "{status:?}"); +} diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 080a7e5d..91e18846 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -1823,16 +1823,17 @@ fn setup_offer_due(home: &Path, cwd: &Path) -> PathBuf { } /// Stand-ins for the agents' programs in a folder of their own, each adding -/// the command it was given to `record`, and failing the one whose arguments -/// contain `fail`. Returns a PATH with only them and the system's tools. +/// the command it was given to `record`, and failing those whose arguments +/// contain one of `fail`. Returns a PATH with only them and the system's tools. #[cfg(unix)] -fn stand_ins(dir: &Path, names: &[&str], record: &Path, fail: Option<&str>) -> String { +fn stand_ins(dir: &Path, names: &[&str], record: &Path, fail: &[&str]) -> String { for name in names { let fails = match fail { - Some(args) => format!( - "case \"$*\" in *\"{args}\"*) echo 'something broke' >&2; echo 'install refused' >&2; exit 1;; esac\n" + [] => String::new(), + args => format!( + "case \"$*\" in {}) echo 'something broke' >&2; echo 'install refused' >&2; exit 1;; esac\n", + args.iter().map(|a| format!("*\"{a}\"*")).collect::>().join("|") ), - None => String::new(), }; let script = format!( "#!/bin/sh\necho \"{name} $*\" >> '{}'\n{fails}exit 0\n", @@ -1880,7 +1881,7 @@ async fn a_test_request_that_worked_offers_setup_once_after_a_sign_in() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude"], &record, None); + let path = stand_ins(bin.path(), &["claude"], &record, &[]); write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); let test = |server: &str| { reply( @@ -1933,7 +1934,7 @@ async fn a_voice_agent_project_offers_setup_at_its_link() { .await; let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); - let path = stand_ins(bin.path(), &["codex"], &bin.path().join("ran"), None); + let path = stand_ins(bin.path(), &["codex"], &bin.path().join("ran"), &[]); write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); write(cwd.path(), "index.html", &embed(MINE)); let check = || { @@ -1970,28 +1971,34 @@ async fn a_voice_agent_project_offers_setup_at_its_link() { #[cfg(unix)] #[tokio::test] -async fn with_only_cursor_there_is_no_offer() { +async fn with_only_cursor_or_no_agent_found_there_is_no_offer() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path(format!("/v1/text-to-speech/{VOICE}"))) .respond_with(ResponseTemplate::new(200).set_body_bytes(b"ID3".to_vec())) .mount(&server) .await; - let (home, cwd) = temp(); - std::fs::create_dir(home.path().join(".cursor")).unwrap(); - write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); - let offer = setup_offer_due(home.path(), cwd.path()); - let r = reply( - cli(home.path(), cwd.path(), &server.uri(), &["onboard", "test"]).env("PATH", "/usr/bin:/bin"), - ); - assert_eq!(r["next"], "done", "{r}"); - assert!(offer.exists(), "kept: Cursor has nothing to install with"); + for (row, cursor) in [("only Cursor", true), ("none of the three", false)] { + let (home, cwd) = temp(); + if cursor { + std::fs::create_dir(home.path().join(".cursor")).unwrap(); + } + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let offer = setup_offer_due(home.path(), cwd.path()); + let r = reply( + cli(home.path(), cwd.path(), &server.uri(), &["onboard", "test"]).env("PATH", "/usr/bin:/bin"), + ); + assert_eq!(r["next"], "done", "{row}: {r}"); + assert!(r["details"].get("question").is_none(), "{row}: {r}"); + assert!(offer.exists(), "{row}: kept, with nothing to install into"); + } } -/// The six commands, in order, that install the plugin in Claude Code and Codex. -const INSTALLS: [&str; 6] = [ +/// The seven commands, in order, that install the plugin in Claude Code and Codex. +const INSTALLS: [&str; 7] = [ + "claude plugin marketplace add elevenlabs/plugin", "claude plugin marketplace update elevenlabs", - "claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user", + "claude plugin install elevenlabs@elevenlabs", "claude plugin update elevenlabs@elevenlabs", "codex plugin marketplace add elevenlabs/plugin", "codex plugin marketplace upgrade elevenlabs", @@ -2012,7 +2019,7 @@ async fn onboard_setup_installs_in_each_agent_and_never_makes_speech() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, &[]); write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); let setup = |args: &[&str], env: &[(&str, &str)]| { reply( @@ -2060,7 +2067,7 @@ async fn onboard_setup_installs_in_each_agent_and_never_makes_speech() { let r = setup(&[], off); assert_eq!(r["next"], "done", "{r}"); } - assert_eq!(recorded(&record).len(), 12, "installed both times"); + assert_eq!(recorded(&record).len(), 14, "installed both times"); assert_eq!(user_requests().await.len(), 1, "none with telemetry off"); // Cursor gets its link with the rest. @@ -2079,7 +2086,7 @@ async fn a_failed_install_ends_on_the_command_to_run_later() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, Some("plugin install")); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, &["plugin install"]); for (npm, later) in [ (None, "elevenlabs setup --yes"), (Some("exec"), "npx -y @elevenlabs/cli@latest setup --yes"), @@ -2093,13 +2100,13 @@ async fn a_failed_install_ends_on_the_command_to_run_later() { assert_eq!(r["next"], "done", "{r}"); assert!( say(&r).starts_with( - "Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` \ - exited with 1: install refused. Codex: installed" + "Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: install refused. \ + Codex: installed" ), "{r}" ); assert!( - say(&r).ends_with(&format!("To try again later, run `{later}`.")), + say(&r).ends_with(&format!("To try again, run `{later}`.")), "{r}" ); assert_eq!(r["details"]["setup"][0]["ok"], false, "{r}"); @@ -2108,68 +2115,6 @@ async fn a_failed_install_ends_on_the_command_to_run_later() { assert!(server.received_requests().await.unwrap().is_empty()); } -#[cfg(unix)] -#[test] -fn setup_says_what_it_would_do_and_does_it_with_yes() { - let (home, cwd) = temp(); - let bin = tempfile::tempdir().unwrap(); - let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); - let run = |args: &[&str], path: &str| { - cli(home.path(), cwd.path(), OFFLINE, args) - .env("PATH", path) - .output() - .unwrap() - }; - - let out = run(&["setup"], &path); - assert!(out.status.success(), "{}", all_output(&out)); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!( - stdout, - format!( - "Claude Code ({0}/claude): install the ElevenLabs plugin (skills and the hosted MCP server)\n\ - Codex ({0}/codex): install the ElevenLabs plugin (skills and the hosted MCP server)\n\ - Nothing was changed. Run `elevenlabs setup --yes` to do it.\n", - bin.path().display() - ) - ); - assert!(recorded(&record).is_empty(), "nothing ran"); - - for _ in 0..2 { - let out = run(&["setup", "--yes"], &path); - assert!(out.status.success(), "{}", all_output(&out)); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert!(stdout.contains("\nTo finish:\n- restart Claude Code, then run /mcp"), "{stdout}"); - } - assert_eq!(recorded(&record), [INSTALLS, INSTALLS].concat(), "again: the update commands run too"); - - let failing = tempfile::tempdir().unwrap(); - let path = stand_ins(failing.path(), &["claude"], &failing.path().join("ran"), Some("plugin update")); - let out = run(&["setup", "--yes"], &path); - assert_eq!(out.status.code(), Some(1), "{}", all_output(&out)); - assert_eq!( - String::from_utf8_lossy(&out.stdout), - "Claude Code: failed: `claude plugin update elevenlabs@elevenlabs` exited with 1: install refused\n\ - To try again, run `elevenlabs setup --yes`.\n" - ); - - // Only Cursor: its link, and nothing written. - let (home, cwd) = temp(); - std::fs::create_dir(home.path().join(".cursor")).unwrap(); - let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) - .env("PATH", "/usr/bin:/bin") - .output() - .unwrap(); - assert!(out.status.success(), "{}", all_output(&out)); - assert_eq!( - String::from_utf8_lossy(&out.stdout), - "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install\n" - ); - let entries: Vec<_> = std::fs::read_dir(home.path()).unwrap().flatten().map(|e| e.file_name()).collect(); - assert_eq!(entries, [".cursor"], "nothing written"); -} - /// Kills the process when the test ends, passed or not. #[cfg(unix)] struct KillOnDrop(i32); @@ -2193,54 +2138,6 @@ impl Drop for StopOnDrop { } } -#[cfg(unix)] -#[test] -fn ctrl_c_stops_the_agent_command_and_what_it_started() { - use std::os::unix::process::CommandExt; - let (home, cwd) = temp(); - let bin = tempfile::tempdir().unwrap(); - let pid = bin.path().join("pid"); - let claude = bin.path().join("claude"); - program( - &claude, - &format!("#!/bin/sh\nsleep 300 &\necho $! > '{0}.new'\nmv '{0}.new' '{0}'\nwait\n", pid.display()), - ); - let mut cmd = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]); - cmd.env("PATH", format!("{}:/usr/bin:/bin", bin.path().display())); - // SIGINT at its default, as from a terminal, even where these tests run with it ignored. - unsafe { - cmd.pre_exec(|| { - libc::signal(libc::SIGINT, libc::SIG_DFL); - Ok(()) - }); - } - let mut setup = StopOnDrop(cmd.spawn().unwrap()); - let setup = &mut setup.0; - - let deadline = Instant::now() + Duration::from_secs(20); - while !pid.exists() { - assert!(Instant::now() < deadline, "the stand-in never started"); - std::thread::sleep(Duration::from_millis(20)); - } - let sleep: i32 = std::fs::read_to_string(&pid).unwrap().trim().parse().unwrap(); - let _sleep = KillOnDrop(sleep); - - unsafe { libc::kill(setup.id() as i32, libc::SIGINT) }; - let deadline = Instant::now() + Duration::from_secs(5); - let status = loop { - if let Some(status) = setup.try_wait().unwrap() { - break status; - } - assert!(Instant::now() < deadline, "setup still runs after Ctrl-C"); - std::thread::sleep(Duration::from_millis(20)); - }; - assert_eq!(status.code(), Some(130), "{status:?}"); - while unsafe { libc::kill(sleep, 0) } == 0 { - assert!(Instant::now() < deadline, "sleep {sleep} still runs"); - std::thread::sleep(Duration::from_millis(20)); - } -} - #[cfg(unix)] #[tokio::test] async fn with_dry_run_setup_installs_nothing() { @@ -2248,7 +2145,7 @@ async fn with_dry_run_setup_installs_nothing() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, &[]); write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); let out = cli(home.path(), cwd.path(), &server.uri(), &["setup", "--yes", "--dry-run"]) @@ -2360,95 +2257,70 @@ async fn every_sign_in_leaves_the_setup_offer_and_a_new_one_clears_the_last_ones // ── setup through the binary, for every combination of agents ─────── -const PLUGIN: &str = "install the ElevenLabs plugin (skills and the hosted MCP server)"; -const CURSOR_LINE: &str = "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install\n"; -const FINISH_CLAUDE: &str = "- restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server\n"; -const FINISH_CODEX: &str = - "- restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server\n"; - -/// What `setup` prints with `agents` found, the programs in `bin`: the plan, -/// or with `installs` what it did. -fn setup_says(agents: &[&str], bin: &Path, home: &Path, installs: bool) -> String { - let has = |a: &str| agents.contains(&a); - let mut out = String::new(); - if installs { - for (agent, name) in [("claude", "Claude Code"), ("codex", "Codex")] { - if has(agent) { - out += &format!("{name}: installed the ElevenLabs plugin (skills and the hosted MCP server)\n"); - } - } - if has("cursor") { - out += CURSOR_LINE; - } - if has("claude") || has("codex") { - out += "\nTo finish:\n"; - } - if has("claude") { - out += FINISH_CLAUDE; - } - if has("codex") { - out += FINISH_CODEX; - } - return out; - } - if agents.is_empty() { - out += &format!( - "No Claude Code, Codex or Cursor found: copy the ElevenLabs skills into {}\n", - home.join(".agents").join("skills").display() - ); - } - for (agent, name) in [("claude", "Claude Code"), ("codex", "Codex")] { - if has(agent) { - out += &format!("{name} ({}): {PLUGIN}\n", bin.join(agent).display()); - } - } - if has("cursor") { - out += CURSOR_LINE; - } - out + "Nothing was changed. Run `elevenlabs setup --yes` to do it.\n" -} - -/// The commands of `INSTALLS` that `agents` run. -fn installs_of(agents: &[&str]) -> Vec { - INSTALLS - .iter() - .filter(|c| agents.iter().any(|a| c.starts_with(&format!("{a} ")))) - .map(|c| c.to_string()) - .collect() +const NOTHING_CHANGED: &str = "Nothing was changed. Run `elevenlabs setup --yes` to do it.\n"; +const CLAUDE_PLAN: &str = "Claude Code ({bin}/claude): install the ElevenLabs plugin (skills and the hosted MCP server)\n"; +const CODEX_PLAN: &str = "Codex ({bin}/codex): install the ElevenLabs plugin (skills and the hosted MCP server)\n"; +const CURSOR_PLAN: &str = "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install\n"; +const CLAUDE_DONE: &str = "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, \ + restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server.\n"; +const CODEX_DONE: &str = "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ + Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server.\n"; +const CURSOR_DONE: &str = "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install.\n"; +const TRY_AGAIN: &str = "To try again, run `elevenlabs setup --yes`.\n"; +const NO_AGENT: &str = "No Claude Code, Codex or Cursor found. If you use Claude Code in the Claude desktop app, ask it \ + to run \"elevenlabs setup --yes\" for you. For other coding agents, install the ElevenLabs skills with: npx skills \ + add elevenlabs/skills\n"; + +/// What `setup` printed, with the stand-ins' folder written `{bin}`. +fn stdout_of(out: &Output, bin: &Path) -> String { + String::from_utf8_lossy(&out.stdout).replace(&bin.display().to_string(), "{bin}") } #[cfg(unix)] #[test] fn setup_through_the_binary_for_every_combination_of_agents_and_flags() { - let combinations: [&[&str]; 8] = [ - &[], - &["claude"], - &["codex"], - &["claude", "codex"], - &["cursor"], - &["claude", "cursor"], - &["codex", "cursor"], - &["claude", "codex", "cursor"], - ]; - let flags: [(&[&str], bool); 4] = [ - (&[], false), - (&["--yes"], true), - (&["--yes", "--dry-run"], false), - (&["--dry-run"], false), + // (agents, what `setup` prints without installing, what `setup --yes` + // prints, the commands `setup --yes` runs) + let rows: [(&[&str], String, String, &[&str]); 8] = [ + (&[], NO_AGENT.into(), NO_AGENT.into(), &[]), + (&["claude"], format!("{CLAUDE_PLAN}{NOTHING_CHANGED}"), CLAUDE_DONE.into(), &INSTALLS[..4]), + (&["codex"], format!("{CODEX_PLAN}{NOTHING_CHANGED}"), CODEX_DONE.into(), &INSTALLS[4..]), + ( + &["claude", "codex"], + format!("{CLAUDE_PLAN}{CODEX_PLAN}{NOTHING_CHANGED}"), + format!("{CLAUDE_DONE}{CODEX_DONE}"), + &INSTALLS, + ), + (&["cursor"], format!("{CURSOR_PLAN}{NOTHING_CHANGED}"), CURSOR_DONE.into(), &[]), + ( + &["claude", "cursor"], + format!("{CLAUDE_PLAN}{CURSOR_PLAN}{NOTHING_CHANGED}"), + format!("{CLAUDE_DONE}{CURSOR_DONE}"), + &INSTALLS[..4], + ), + ( + &["codex", "cursor"], + format!("{CODEX_PLAN}{CURSOR_PLAN}{NOTHING_CHANGED}"), + format!("{CODEX_DONE}{CURSOR_DONE}"), + &INSTALLS[4..], + ), + ( + &["claude", "codex", "cursor"], + format!("{CLAUDE_PLAN}{CODEX_PLAN}{CURSOR_PLAN}{NOTHING_CHANGED}"), + format!("{CLAUDE_DONE}{CODEX_DONE}{CURSOR_DONE}"), + &INSTALLS, + ), ]; - let mut rows = 0; - for agents in combinations { - for (args, installs) in flags { - if agents.is_empty() && installs { - // It would download the skills from GitHub: covered by the unit tests. - continue; - } + let mut runs = 0; + for (agents, plan, done, installs) in &rows { + // (flags, whether they install) + for (args, install) in [(&[][..], false), (&["--yes"], true), (&["--yes", "--dry-run"], false), (&["--dry-run"], false)] { let row = format!("{agents:?} with {args:?}"); let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); let programs: Vec<&str> = agents.iter().copied().filter(|a| *a != "cursor").collect(); - let path = stand_ins(bin.path(), &programs, &record, None); + let path = stand_ins(bin.path(), &programs, &record, &[]); if agents.contains(&"cursor") { std::fs::create_dir(home.path().join(".cursor")).unwrap(); } @@ -2457,95 +2329,118 @@ fn setup_through_the_binary_for_every_combination_of_agents_and_flags() { .output() .unwrap(); assert_eq!(out.status.code(), Some(0), "{row}: {}", all_output(&out)); - assert_eq!( - String::from_utf8_lossy(&out.stdout), - setup_says(agents, bin.path(), home.path(), installs), - "{row}" - ); - let want = if installs { installs_of(agents) } else { vec![] }; - assert_eq!(recorded(&record), want, "{row}"); + assert_eq!(&stdout_of(&out, bin.path()), if install { done } else { plan }, "{row}"); + let ran: &[&str] = if install { installs } else { &[] }; + assert_eq!(recorded(&record), ran, "{row}"); let written: Vec<_> = std::fs::read_dir(home.path()).unwrap().flatten().map(|e| e.file_name()).collect(); let cursor_only: &[&str] = if agents.contains(&"cursor") { &[".cursor"] } else { &[] }; assert_eq!(written, cursor_only, "{row}: setup itself writes nothing in the home folder"); - rows += 1; + runs += 1; } } - assert_eq!(rows, 31); + assert_eq!(runs, 32); } #[cfg(unix)] #[test] fn setup_through_the_binary_with_a_stand_in_failing_at_each_step() { - let finish = format!("\nTo finish:\n{FINISH_CLAUDE}{FINISH_CODEX}"); - let all_ok = format!( - "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)\n\ - Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)\n{finish}" - ); - let failed = |line: String, finish: &str| { - format!("{line}\nTo try again, run `elevenlabs setup --yes`.\n\nTo finish:\n{finish}") + let claude_failed = |command: &str| { + format!("Claude Code: failed: `claude {command}` exited with 1: install refused.\n") }; - // (the step that fails, exit code, output, the commands that ran) - let rows: [(&str, i32, String, Vec<&str>); 6] = [ - ("marketplace update", 0, all_ok.clone(), INSTALLS.to_vec()), + // (what fails, exit code, output, the commands that ran) + let rows: [(&[&str], i32, String, Vec<&str>); 7] = [ + (&["marketplace add"], 0, format!("{CLAUDE_DONE}{CODEX_DONE}"), INSTALLS.to_vec()), + (&["marketplace update"], 0, format!("{CLAUDE_DONE}{CODEX_DONE}"), INSTALLS.to_vec()), ( - "plugin install", + &["plugin install"], 1, - failed( - "Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` \ - exited with 1: install refused\nCodex: installed the ElevenLabs plugin (skills and the hosted MCP server)" - .into(), - FINISH_CODEX, - ), - [&INSTALLS[..2], &INSTALLS[3..]].concat(), + format!("{}{CODEX_DONE}{TRY_AGAIN}", claude_failed("plugin install elevenlabs@elevenlabs")), + [&INSTALLS[..3], &INSTALLS[4..]].concat(), ), ( - "plugin update", + &["plugin update"], 1, - failed( - "Claude Code: failed: `claude plugin update elevenlabs@elevenlabs` exited with 1: install refused\n\ - Codex: installed the ElevenLabs plugin (skills and the hosted MCP server)" - .into(), - FINISH_CODEX, - ), + format!("{}{CODEX_DONE}{TRY_AGAIN}", claude_failed("plugin update elevenlabs@elevenlabs")), INSTALLS.to_vec(), ), + (&["marketplace upgrade"], 0, format!("{CLAUDE_DONE}{CODEX_DONE}"), INSTALLS.to_vec()), ( - "marketplace add", + &["plugin add"], 1, - failed( - "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)\n\ - Codex: failed: `codex plugin marketplace add elevenlabs/plugin` exited with 1: install refused" - .into(), - FINISH_CLAUDE, + format!( + "{CLAUDE_DONE}Codex: failed: `codex plugin add elevenlabs@elevenlabs` exited with 1: install refused.\n\ + {TRY_AGAIN}" ), - INSTALLS[..4].to_vec(), + INSTALLS.to_vec(), ), - ("marketplace upgrade", 0, all_ok.clone(), INSTALLS.to_vec()), + // The install fails for want of the marketplace: its own error is the cause. ( - "plugin add", + &["marketplace add", "plugin install"], 1, - failed( - "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server)\n\ - Codex: failed: `codex plugin add elevenlabs@elevenlabs` exited with 1: install refused" - .into(), - FINISH_CLAUDE, - ), - INSTALLS.to_vec(), + format!("{}{CODEX_DONE}{TRY_AGAIN}", claude_failed("plugin marketplace add elevenlabs/plugin")), + [&INSTALLS[..3], &INSTALLS[4..]].concat(), ), ]; for (fails, code, says, ran) in rows { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, Some(fails)); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, fails); let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) .env("PATH", &path) .output() .unwrap(); - assert_eq!(out.status.code(), Some(code), "`{fails}` failing: {}", all_output(&out)); - assert_eq!(String::from_utf8_lossy(&out.stdout), says, "`{fails}` failing"); - assert_eq!(recorded(&record), ran, "`{fails}` failing"); + assert_eq!(out.status.code(), Some(code), "{fails:?} failing: {}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), says, "{fails:?} failing"); + assert_eq!(recorded(&record), ran, "{fails:?} failing"); + } +} + +/// With none of the three found, every way of running `setup` says how to get +/// the skills, and nothing is run, written or requested. +#[cfg(unix)] +#[tokio::test] +async fn with_no_agent_found_setup_says_how_to_get_the_skills_and_changes_nothing() { + let server = MockServer::start().await; + let (home, cwd) = temp(); + for args in [&["setup"][..], &["setup", "--yes"], &["setup", "--dry-run"], &["setup", "--yes", "--dry-run"]] { + let out = cli(home.path(), cwd.path(), &server.uri(), args).env("PATH", "/usr/bin:/bin").output().unwrap(); + assert_eq!(out.status.code(), Some(0), "{args:?}: {}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), NO_AGENT, "{args:?}"); } + let r = reply(cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]).env("PATH", "/usr/bin:/bin")); + assert_eq!(r["next"], "done", "{r}"); + assert_eq!(say(&r), NO_AGENT.trim_end()); + assert_eq!(r["details"]["setup"], json!([]), "{r}"); + let written: Vec<_> = std::fs::read_dir(home.path()).unwrap().flatten().map(|e| e.file_name()).collect(); + assert!(written.is_empty(), "nothing written in the home folder: {written:?}"); + assert!(server.received_requests().await.unwrap().is_empty(), "no request"); +} + +/// Inside the Claude desktop app, `claude` is on no PATH: the program the +/// app names is the one installed into, and without it nothing is found. +#[cfg(unix)] +#[test] +fn claude_code_named_by_claude_code_execpath_is_installed_with_no_claude_on_path() { + let (home, cwd) = temp(); + let app = tempfile::tempdir().unwrap(); + let dir = app.path().join("claude.app").join("Contents").join("MacOS"); + std::fs::create_dir_all(&dir).unwrap(); + let record = app.path().join("ran"); + program(&dir.join("claude"), &format!("#!/bin/sh\necho \"claude $*\" >> '{}'\n", record.display())); + let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) + .env("PATH", "/usr/bin:/bin") + .env("CLAUDE_CODE_EXECPATH", dir.join("claude")) + .output() + .unwrap(); + assert!(out.status.success(), "{}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), CLAUDE_DONE); + assert_eq!(recorded(&record), INSTALLS[..4]); + + let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]).env("PATH", "/usr/bin:/bin").output().unwrap(); + assert!(out.status.success(), "{}", all_output(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), NO_AGENT); + assert_eq!(recorded(&record).len(), 4, "nothing more ran"); } /// The longest error line `setup` repeats. @@ -2587,15 +2482,12 @@ async fn a_failed_install_shows_a_short_clean_error_however_much_it_printed() { &format!("#!/bin/sh\ncase \"$*\" in *\"plugin install\"*) {prints}; exit 1;; esac\nexit 0\n"), ); let path = format!("{}:/usr/bin:/bin", bin.path().display()); - let failed = format!( - "Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` \ - exited with 1: {line}" - ); + let failed = format!("Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: {line}"); let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]).env("PATH", &path).output().unwrap(); assert_eq!(out.status.code(), Some(1), "{row}"); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(stdout, format!("{failed}\nTo try again, run `elevenlabs setup --yes`.\n"), "{row}"); + assert_eq!(stdout, format!("{failed}.\nTo try again, run `elevenlabs setup --yes`.\n"), "{row}"); assert!(stdout.len() < 1000, "{row}: {} bytes", stdout.len()); let out = cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]) @@ -2626,14 +2518,14 @@ fn setup_through_the_binary_from_folders_with_spaces_and_non_ascii_names() { std::fs::create_dir(dir).unwrap(); } let record = bin.join("ran"); - let path = stand_ins(&bin, &["claude", "codex"], &record, None); + let path = stand_ins(&bin, &["claude", "codex"], &record, &[]); let run = |args: &[&str]| cli(&home, &cwd, OFFLINE, args).env("PATH", &path).output().unwrap(); let out = run(&["setup"]); assert!(out.status.success(), "{}", all_output(&out)); - assert_eq!(String::from_utf8_lossy(&out.stdout), setup_says(&["claude", "codex"], &bin, &home, false)); + assert_eq!(stdout_of(&out, &bin), format!("{CLAUDE_PLAN}{CODEX_PLAN}{NOTHING_CHANGED}")); let out = run(&["setup", "--yes"]); assert!(out.status.success(), "{}", all_output(&out)); - assert_eq!(String::from_utf8_lossy(&out.stdout), setup_says(&["claude", "codex"], &bin, &home, true)); + assert_eq!(String::from_utf8_lossy(&out.stdout), format!("{CLAUDE_DONE}{CODEX_DONE}")); assert_eq!(recorded(&record), INSTALLS); } @@ -2643,8 +2535,8 @@ fn setup_through_the_binary_ten_runs_in_a_row_and_two_at_once() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); - let says = setup_says(&["claude", "codex"], bin.path(), home.path(), true); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, &[]); + let says = format!("{CLAUDE_DONE}{CODEX_DONE}"); let start = || { cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]) .env("PATH", &path) @@ -2670,7 +2562,7 @@ fn setup_through_the_binary_ten_runs_in_a_row_and_two_at_once() { ran.sort(); let mut want: Vec = INSTALLS.repeat(2).iter().map(|c| c.to_string()).collect(); want.sort(); - assert_eq!(ran, want, "each run ran all six commands"); + assert_eq!(ran, want, "each run ran all seven commands"); } #[cfg(unix)] @@ -2698,7 +2590,7 @@ fn setup_starts_agents_from_the_shells_environment_never_the_projects_env() { .output() .unwrap(); assert!(out.status.success(), "{}", all_output(&out)); - let want: Vec = INSTALLS[..3].iter().map(|c| format!("shell's {c} [unset]")).collect(); + let want: Vec = INSTALLS[..4].iter().map(|c| format!("shell's {c} [unset]")).collect(); assert_eq!(recorded(&record), want); } @@ -2750,7 +2642,7 @@ async fn a_whole_flow_signs_in_tests_and_sets_up_offering_once_per_sign_in() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, &[]); let onboard = |args: &[String]| { let args: Vec<&str> = args.iter().map(String::as_str).collect(); reply(cli(home.path(), cwd.path(), &server.uri(), &args).env("PATH", &path)) @@ -2796,7 +2688,7 @@ async fn a_whole_flow_signs_in_tests_and_sets_up_offering_once_per_sign_in() { #[cfg(unix)] #[tokio::test] -async fn with_no_agent_found_the_offer_is_the_skills_and_its_run_keeps_the_flags_and_npx() { +async fn the_offers_run_keeps_the_flags_and_npx() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path(format!("/v1/text-to-speech/{VOICE}"))) @@ -2804,6 +2696,8 @@ async fn with_no_agent_found_the_offer_is_the_skills_and_its_run_keeps_the_flags .mount(&server) .await; let (home, cwd) = temp(); + let bin = tempfile::tempdir().unwrap(); + let path = stand_ins(bin.path(), &["codex"], &bin.path().join("ran"), &[]); write(cwd.path(), "other.env", &format!("ELEVENLABS_API_KEY={KEY}\n")); // (row, npm_command, the run it offers) let rows = [ @@ -2822,16 +2716,13 @@ async fn with_no_agent_found_the_offer_is_the_skills_and_its_run_keeps_the_flags &server.uri(), &["onboard", "test", "--env-path", "other.env", "--no-browser"], ); - cmd.env("PATH", "/usr/bin:/bin"); + cmd.env("PATH", &path); if let Some(npm) = npm { cmd.env("npm_command", npm); } let r = reply(&mut cmd); assert_eq!(r["next"], "offer_setup", "{row}: {r}"); - assert_eq!( - r["details"]["question"], "Want me to install the ElevenLabs skills for your coding agents?", - "{row}" - ); + assert_eq!(r["details"]["question"], PLUGIN_QUESTION.replace("{}", "Codex"), "{row}"); assert_eq!(r["run"], run, "{row}"); } } @@ -2942,7 +2833,7 @@ async fn a_project_that_has_not_signed_in_gets_the_replies_it_always_has() { let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); // A Claude Code on PATH: with no sign-in it changes nothing. - let path = stand_ins(bin.path(), &["claude"], &record, None); + let path = stand_ins(bin.path(), &["claude"], &record, &[]); project(cwd.path()); let got: Vec = commands .iter() @@ -3131,7 +3022,7 @@ async fn an_interrupt_with_no_agent_command_running_ends_the_cli_as_before() { let (home, cwd) = temp(); let bin = tempfile::tempdir().unwrap(); let record = bin.path().join("ran"); - let path = stand_ins(bin.path(), &["claude", "codex"], &record, None); + let path = stand_ins(bin.path(), &["claude", "codex"], &record, &[]); write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); let mut cmd = cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]); cmd.env("PATH", &path); From 2930f96d10424d5663a01e15049ade9bfd6d3263 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 08:36:28 -0500 Subject: [PATCH 23/32] docs: the README gives setup's Claude Code commands and what it says with no agent found --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2d53065d..00ace17d 100644 --- a/README.md +++ b/README.md @@ -453,12 +453,14 @@ elevenlabs setup # say what it would do, and change nothing (so does --d elevenlabs setup --yes # do it ``` -- **Claude Code** (when run inside it, the program in `CLAUDE_CODE_EXECPATH` if that is `claude`, else `claude` on `PATH`): `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user`, then `claude plugin update elevenlabs@elevenlabs`. -- **Codex** (`codex` on `PATH`): `codex plugin marketplace add elevenlabs/plugin`, then `codex plugin add elevenlabs@elevenlabs`. +- **Claude Code** (when run inside it, the program in `CLAUDE_CODE_EXECPATH` if that is `claude`, else `claude` on `PATH`): `claude plugin marketplace add elevenlabs/plugin`, `claude plugin marketplace update elevenlabs`, `claude plugin install elevenlabs@elevenlabs`, then `claude plugin update elevenlabs@elevenlabs`. +- **Codex** (`codex` on `PATH`): `codex plugin marketplace add elevenlabs/plugin`, `codex plugin marketplace upgrade elevenlabs`, then `codex plugin add elevenlabs@elevenlabs`. - **Cursor** (a `~/.cursor` folder, or run inside Cursor) has no install command: `setup` prints the link to [its marketplace page](https://cursor.com/marketplace/elevenlabs), where you select Install. -- **None of these**: `setup` copies the [ElevenLabs skills](https://github.com/elevenlabs/skills) into `~/.agents/skills`, one folder per skill. A folder there that `setup` didn't write is left as it is, and named in the output. +- **None of these**: `setup` changes nothing and says how to install only the [ElevenLabs skills](https://github.com/elevenlabs/skills), with `npx skills add elevenlabs/skills`. -Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again. Agent commands run in your home folder, never in the project, and pressing Ctrl-C stops the one running along with anything it started. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. +A terminal can't see a Claude Code that lives only in the Claude desktop app, so there, ask Claude to run `elevenlabs setup --yes` for you. + +Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again (a failed marketplace command alone doesn't fail an agent). Agent commands run in your home folder, never in the project, and pressing Ctrl-C stops the one running along with anything it started. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. When onboarding through a coding agent (`npx -y @elevenlabs/cli@latest onboard init`), the flow can end by offering to do this for you; saying yes runs `onboard setup`, which does what `setup --yes` does. From cdaa474d517e136bb822600279fc7e3ab7f17e1d Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 08:36:29 -0500 Subject: [PATCH 24/32] ci: the Windows setup steps expect Claude Code's four commands --- .github/workflows/windows-smoke.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index f2c33d14..ea6c1c5f 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -97,9 +97,10 @@ jobs: elevenlabs setup --yes expect "$bin/claude.log" "$(printf '%s\n' \ + "plugin marketplace add elevenlabs/plugin" \ "plugin marketplace update elevenlabs" \ - "plugin install elevenlabs --marketplace elevenlabs/plugin --scope user" \ - "plugin update elevenlabs@elevenlabs")" "Expected the three Claude Code commands" + "plugin install elevenlabs@elevenlabs" \ + "plugin update elevenlabs@elevenlabs")" "Expected the four Claude Code commands" expect "$bin/codex.log" "$(printf '%s\n' \ "plugin marketplace add elevenlabs/plugin" \ "plugin marketplace upgrade elevenlabs" \ @@ -113,14 +114,15 @@ jobs: echo "::error::setup --yes with a failing install exited with $code, not 1" exit 1 fi - refused='Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` exited with 1: install refused' + refused='Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: install refused' if [[ "$out" != *"$refused"* ]]; then echo "::error::setup --yes didn't show the install's error" exit 1 fi expect "$failing/claude.log" "$(printf '%s\n' \ + "plugin marketplace add elevenlabs/plugin" \ "plugin marketplace update elevenlabs" \ - "plugin install elevenlabs --marketplace elevenlabs/plugin --scope user")" \ + "plugin install elevenlabs@elevenlabs")" \ "Expected Claude Code to stop at the failed install" # PowerShell and cmd pass the search path as `Path`; Git Bash passes `PATH`. @@ -183,10 +185,11 @@ jobs: elevenlabs setup --yes if ($LASTEXITCODE -ne 0) { throw "setup --yes exited with $LASTEXITCODE" } Assert-Ran $bin 'claude' @( + 'plugin marketplace add elevenlabs/plugin' 'plugin marketplace update elevenlabs' - 'plugin install elevenlabs --marketplace elevenlabs/plugin --scope user' + 'plugin install elevenlabs@elevenlabs' 'plugin update elevenlabs@elevenlabs' - ) 'Expected the three Claude Code commands' + ) 'Expected the four Claude Code commands' Assert-Ran $bin 'codex' @( 'plugin marketplace add elevenlabs/plugin' 'plugin marketplace upgrade elevenlabs' @@ -198,11 +201,12 @@ jobs: $code = $LASTEXITCODE Write-Host $out if ($code -ne 1) { throw "setup --yes with a failing install exited with $code, not 1" } - $refused = 'Claude Code: failed: `claude plugin install elevenlabs --marketplace elevenlabs/plugin --scope user` exited with 1: install refused' + $refused = 'Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: install refused' if (-not $out.Contains($refused)) { throw "setup --yes didn't show the install's error" } Assert-Ran $failing 'claude' @( + 'plugin marketplace add elevenlabs/plugin' 'plugin marketplace update elevenlabs' - 'plugin install elevenlabs --marketplace elevenlabs/plugin --scope user' + 'plugin install elevenlabs@elevenlabs' ) 'Expected Claude Code to stop at the failed install' # The failed setup above exited 1 on purpose, and pwsh would end the step with it. exit 0 From 96af521cfaeaa343b2801a0216be8ea946ef1c46 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 08:52:23 -0500 Subject: [PATCH 25/32] test: setup's error-line table holds the control characters the escape parser keeps A vertical tab, a form feed and U+0085 pass anstream's strip and only the filter drops them, so a break of the filter now fails the table. The grandchildren test gives its stand-in 3 s, not 1.5 s, to start both on a busy machine. --- cli/elevenlabs/workflow/setup/tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index 7b5dd92a..cd036c8a 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -908,11 +908,12 @@ fn a_command_past_its_limit_is_stopped_with_its_children_and_grandchildren() { ), ); let started = Instant::now(); - let limit = Duration::from_millis(1500); + // Long enough for the stand-in to start both on a busy machine. + let limit = Duration::from_secs(3); let result = run_program(&hangs, &[], limit, dir.path()); let (child, grandchild) = (pid_in(&child), pid_in(&grandchild)); let _cleanup = (KillOnDrop(child), KillOnDrop(grandchild)); - assert_eq!(result, Err("ran out of time after 1.5s and was stopped".into())); + assert_eq!(result, Err("ran out of time after 3s and was stopped".into())); assert!(started.elapsed() < limit + Duration::from_secs(3), "{:?}", started.elapsed()); assert!(gone(child), "the child {child} still runs"); assert!(gone(grandchild), "the grandchild {grandchild} still runs"); @@ -970,6 +971,9 @@ fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characte ("a progress bar redrawn last", b"done one\nstep 1/3\rstep 2/3\rstep 3/3".to_vec(), Some("step 3/3".into())), ("a window title and a link", b"\x1b]0;claude\x07\x1b]8;;https://x.test\x1b\\link\x1b]8;;\x1b\\ failed".to_vec(), Some("link failed".into())), ("a tab and a bell", b"one\ttwo\x07\x08three".to_vec(), Some("one twothree".into())), + // The escape parser keeps these as whitespace or text: the filter drops them. + ("a vertical tab and a form feed", b"error:\x0bvt\x0cff\n".to_vec(), Some("error:vtff".into())), + ("a one-character next line, U+0085", "error: nel\u{85}x\n".into(), Some("error: nelx".into())), ("a multi-byte character straddling the cut", straddles.into_bytes(), Some(straddles_cut)), ("exactly 300 characters", exactly.clone().into_bytes(), Some(exactly)), ("bytes that are not UTF-8", b"caf\xe9 \xff broke\n".to_vec(), Some("caf\u{FFFD} \u{FFFD} broke".into())), From d417eedbf84111b8632dddd227c0edc1d98641d4 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 09:24:12 -0500 Subject: [PATCH 26/32] fix: setup says to run the Codex sign-in in a terminal --- README.md | 2 +- cli/elevenlabs/workflow/setup.rs | 3 ++- cli/elevenlabs/workflow/setup/tests.rs | 4 ++-- tests/onboard_test.rs | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 00ace17d..ec572b32 100644 --- a/README.md +++ b/README.md @@ -460,7 +460,7 @@ elevenlabs setup --yes # do it A terminal can't see a Claude Code that lives only in the Claude desktop app, so there, ask Claude to run `elevenlabs setup --yes` for you. -Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again (a failed marketplace command alone doesn't fail an agent). Agent commands run in your home folder, never in the project, and pressing Ctrl-C stops the one running along with anything it started. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` for Codex. +Running it again updates the plugin. Each agent command may take 30 seconds and the whole run 100; `setup` exits 1 when an agent's install fails, with that agent's error and the command to try again (a failed marketplace command alone doesn't fail an agent). Agent commands run in your home folder, never in the project, and pressing Ctrl-C stops the one running along with anything it started. To finish, restart the agent and sign in to the ElevenLabs MCP server: run `/mcp` in Claude Code, or `codex mcp login elevenlabs` in a terminal for Codex. When onboarding through a coding agent (`npx -y @elevenlabs/cli@latest onboard init`), the flow can end by offering to do this for you; saying yes runs `onboard setup`, which does what `setup --yes` does. diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 80ba9556..0d82c154 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -74,7 +74,8 @@ impl Agent { Some("restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server") } Agent::Codex(_) => Some( - "restart Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server", + "restart Codex, then run `codex mcp login elevenlabs` in a terminal to sign in to the ElevenLabs MCP \ + server", ), Agent::Cursor => None, } diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index cd036c8a..f283b065 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -150,7 +150,7 @@ fn install_runs_each_agents_own_commands_in_order_and_says_how_to_finish() { restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server." .to_string(), "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ - Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server." + Codex, then run `codex mcp login elevenlabs` in a terminal to sign in to the ElevenLabs MCP server." .to_string(), "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install.".to_string(), ], @@ -167,7 +167,7 @@ fn install_runs_each_agents_own_commands_in_order_and_says_how_to_finish() { "Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: boom." .to_string(), "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ - Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server." + Codex, then run `codex mcp login elevenlabs` in a terminal to sign in to the ElevenLabs MCP server." .to_string(), "To try again, run `elevenlabs setup --yes`.".to_string(), ], diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 91e18846..a0680612 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2053,7 +2053,7 @@ async fn onboard_setup_installs_in_each_agent_and_never_makes_speech() { "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ Claude Code, then run /mcp and sign in to the ElevenLabs MCP server. Codex: installed the ElevenLabs \ plugin (skills and the hosted MCP server). To finish, restart Codex, then run `codex mcp login \ - elevenlabs` to sign in to the ElevenLabs MCP server." + elevenlabs` in a terminal to sign in to the ElevenLabs MCP server." ); let results = r["details"]["setup"].as_array().unwrap(); assert_eq!(results.len(), 2, "{r}"); @@ -2264,7 +2264,7 @@ const CURSOR_PLAN: &str = "Cursor: open https://cursor.com/marketplace/elevenlab const CLAUDE_DONE: &str = "Claude Code: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, \ restart Claude Code, then run /mcp and sign in to the ElevenLabs MCP server.\n"; const CODEX_DONE: &str = "Codex: installed the ElevenLabs plugin (skills and the hosted MCP server). To finish, restart \ - Codex, then run `codex mcp login elevenlabs` to sign in to the ElevenLabs MCP server.\n"; + Codex, then run `codex mcp login elevenlabs` in a terminal to sign in to the ElevenLabs MCP server.\n"; const CURSOR_DONE: &str = "Cursor: open https://cursor.com/marketplace/elevenlabs and select Install.\n"; const TRY_AGAIN: &str = "To try again, run `elevenlabs setup --yes`.\n"; const NO_AGENT: &str = "No Claude Code, Codex or Cursor found. If you use Claude Code in the Claude desktop app, ask it \ From 8bebb2409680731f7ec5c9c0690fe9f7bc4d8c9f Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 09:24:20 -0500 Subject: [PATCH 27/32] fix: setup's result lines end in one full stop --- cli/elevenlabs/workflow/setup.rs | 4 +++- cli/elevenlabs/workflow/setup/tests.rs | 31 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 0d82c154..6c566dd5 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -503,7 +503,9 @@ pub(super) fn report(outcomes: &[Outcome]) -> (Vec, bool) { .iter() .map(|o| { let failed = if o.ok { "" } else { "failed: " }; - let mut line = format!("{}: {failed}{}.", o.agent, o.message); + // An error line can end a sentence of its own, or be cut with `…`. + let stop = if o.message.ends_with(['.', '!', '?', '…']) { "" } else { "." }; + let mut line = format!("{}: {failed}{}{stop}", o.agent, o.message); if let Some(finish) = o.finish { line.push_str(&format!(" To finish, {finish}.")); } diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index f283b065..ac14220e 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -214,6 +214,37 @@ fn agents_not_reached_in_time_count_as_failed() { assert!(outcomes[2].ok, "Cursor runs nothing"); } +#[test] +fn a_result_line_ends_in_one_full_stop() { + let cut = format!("{}…", "q".repeat(LINE_MAX - 1)); + let upgrade = "Codex: failed: `codex plugin marketplace upgrade elevenlabs` exited with 1:"; + // (the error line, what follows `upgrade`): Codex's real one first, as + // its upgrade and then its add fail. + let rows = [ + ("Error: 1 upgrade failure(s) occurred.", " Error: 1 upgrade failure(s) occurred."), + ("Error: no such plugin!", " Error: no such plugin!"), + ("is the plugin there?", " is the plugin there?"), + ("error: no such plugin", " error: no such plugin."), + ]; + for (error, said) in rows { + let fails = |_: &Path, a: &[&str], _: Duration| match a { + ["plugin", "marketplace", "upgrade", ..] | ["plugin", "add", ..] => { + Err(format!("exited with 1: {error}")) + } + _ => Ok(()), + }; + let outcomes = install(&all_agents()[1..2], LIMITS_FOR_TESTS, &fails, &|| Duration::ZERO); + assert_eq!(report(&outcomes).0[0], format!("{upgrade}{said}")); + } + let fails = |_: &Path, _: &[&str], _: Duration| Err(format!("exited with 1: {cut}")); + let outcomes = install(&all_agents()[1..2], LIMITS_FOR_TESTS, &fails, &|| Duration::ZERO); + assert_eq!( + report(&outcomes).0[0], + format!("Codex: failed: `codex plugin marketplace add elevenlabs/plugin` exited with 1: {cut}"), + "a line cut at {LINE_MAX} characters ends in `…`" + ); +} + /// A shell script at `dir/name`, written by a short-lived `sh`, never by this /// process. Tests run as threads of one process: if one forks while another /// holds a script open for writing, the forked child keeps that descriptor From 9553a592eadbb1baa6ac0cd9b8d4918ca6fcf634 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 09:24:27 -0500 Subject: [PATCH 28/32] fix: setup's time limits read as whole seconds --- cli/elevenlabs/workflow/setup.rs | 13 +++++++-- cli/elevenlabs/workflow/setup/tests.rs | 38 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/cli/elevenlabs/workflow/setup.rs b/cli/elevenlabs/workflow/setup.rs index 6c566dd5..a3fdad40 100644 --- a/cli/elevenlabs/workflow/setup.rs +++ b/cli/elevenlabs/workflow/setup.rs @@ -261,7 +261,7 @@ pub(super) fn run_program( Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), _ => { stop_all(&mut child); - return Err(format!("ran out of time after {limit:?} and was stopped")); + return Err(format!("ran out of time after {} and was stopped", seconds(limit))); } } }; @@ -278,6 +278,15 @@ pub(super) fn run_program( }) } +/// `limit` in whole seconds, rounded to the nearest, such as `13s`, or in +/// milliseconds when under a second: limits are cut short by the whole run's. +fn seconds(limit: Duration) -> String { + match limit.as_millis() { + ms if ms < 1000 => format!("{ms}ms"), + ms => format!("{}s", (ms + 500) / 1000), + } +} + /// Stop `child` and the programs it started, and wait for it: the npm /// `codex` is a node script that starts the real one, and on Windows a shim /// runs under cmd. @@ -435,7 +444,7 @@ pub(super) fn install( for step in agent.steps() { let left = limits.total.saturating_sub(elapsed()); if left.is_zero() { - let message = format!("ran out of time: setup stops after {:?}", limits.total); + let message = format!("ran out of time: setup stops after {}", seconds(limits.total)); return outcome(false, message); } if let Err(why) = run(program, step.args, limits.command.min(left)) { diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index ac14220e..a9cb3652 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -214,6 +214,42 @@ fn agents_not_reached_in_time_count_as_failed() { assert!(outcomes[2].ok, "Cursor runs nothing"); } +/// A fraction of a second written as digits before `s`, such as `12.6s`. +fn has_fractional_seconds(text: &str) -> bool { + text.split('.').skip(1).any(|rest| { + let after = rest.trim_start_matches(|c: char| c.is_ascii_digit()); + after.len() < rest.len() && after.starts_with('s') + }) +} + +#[test] +fn a_limit_cut_short_reads_as_whole_seconds() { + // (the limit, as shown): the whole run's cap shortens a command's limit, + // and `onboard setup` takes its account request's time off the run's. + let rows = [ + (Duration::from_nanos(12_642_639_792), "13s"), + (Duration::from_nanos(99_999_912_542), "100s"), + (Duration::from_nanos(99_873_421_000), "100s"), + (Duration::from_millis(1_499), "1s"), + (Duration::from_secs(50), "50s"), + (Duration::from_millis(999), "999ms"), + (Duration::from_millis(200), "200ms"), + ]; + for (total, shown) in rows { + let limits = Limits { command: Duration::from_secs(30), total }; + let outcomes = install(&all_agents()[..1], limits, &|_, _, _| Ok(()), &|| total); + assert_eq!(outcomes[0].message, format!("ran out of time: setup stops after {shown}")); + } + // `onboard setup`'s cap, as onboard/mod.rs makes it. + let total = LIMITS.total.saturating_sub(Duration::from_nanos(126_578_458)); + let limits = Limits { total, ..LIMITS }; + let outcomes = install(&all_agents()[1..2], limits, &|_, _, _| Ok(()), &|| LIMITS.total); + let (said, _) = report(&outcomes); + assert_eq!(said[0], "Codex: failed: ran out of time: setup stops after 100s."); + assert!(!has_fractional_seconds(&said.join(" ")), "{said:?}"); + assert!(has_fractional_seconds("after 12.642639792s"), "the check finds what it looks for"); +} + #[test] fn a_result_line_ends_in_one_full_stop() { let cut = format!("{}…", "q".repeat(LINE_MAX - 1)); @@ -292,6 +328,8 @@ fn a_command_past_its_limit_is_stopped_and_fails() { started.elapsed() ); assert_eq!(err, "ran out of time after 200ms and was stopped"); + let err = run_program(&hangs, &[], Duration::from_millis(1_600), dir.path()).unwrap_err(); + assert_eq!(err, "ran out of time after 2s and was stopped", "not 1.6s"); let outcomes = install( &[Agent::ClaudeCode(hangs), Agent::Cursor], From 6232888ce2892a15a63c6186c09c6a07d3520bc9 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 09:24:35 -0500 Subject: [PATCH 29/32] test: setup reads only the end of a long output --- cli/elevenlabs/workflow/setup/tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs index a9cb3652..84d35e6c 100644 --- a/cli/elevenlabs/workflow/setup/tests.rs +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -1070,6 +1070,18 @@ fn the_error_line_is_the_last_with_text_without_escapes_and_at_most_300_characte } } +#[test] +fn only_the_last_64_kb_of_a_capture_is_read() { + use std::io::Write; + let mut file = tempfile::tempfile().unwrap(); + // A whole-file read would find the line starting with `A`. + let tail = format!("A{}", "q".repeat(70_000)); + file.write_all(&vec![b'z'; 10_000_000]).unwrap(); + file.write_all(format!("\n{tail}").as_bytes()).unwrap(); + let line = last_line(file).unwrap(); + assert_eq!(line, format!("{}…", "q".repeat(LINE_MAX - 1))); +} + #[cfg(unix)] #[test] fn a_missing_home_folder_is_named_and_the_agent_is_not_blamed() { From 97d9c04734e874bb719cb06c8488bcbdb6e12d0a Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 09:26:11 -0500 Subject: [PATCH 30/32] test: setup's binary test expects no full stop after a cut error line --- tests/onboard_test.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index a0680612..ca89881e 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -2483,11 +2483,13 @@ async fn a_failed_install_shows_a_short_clean_error_however_much_it_printed() { ); let path = format!("{}:/usr/bin:/bin", bin.path().display()); let failed = format!("Claude Code: failed: `claude plugin install elevenlabs@elevenlabs` exited with 1: {line}"); + // A line cut with `…` gets no full stop after it. + let said = if line.ends_with('…') { failed } else { format!("{failed}.") }; let out = cli(home.path(), cwd.path(), OFFLINE, &["setup", "--yes"]).env("PATH", &path).output().unwrap(); assert_eq!(out.status.code(), Some(1), "{row}"); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(stdout, format!("{failed}.\nTo try again, run `elevenlabs setup --yes`.\n"), "{row}"); + assert_eq!(stdout, format!("{said}\nTo try again, run `elevenlabs setup --yes`.\n"), "{row}"); assert!(stdout.len() < 1000, "{row}: {} bytes", stdout.len()); let out = cli(home.path(), cwd.path(), &server.uri(), &["onboard", "setup"]) @@ -2501,7 +2503,7 @@ async fn a_failed_install_shows_a_short_clean_error_however_much_it_printed() { assert!(!stdout.contains('\r') && !stdout.contains("\\r"), "{row}: {stdout}"); assert!(!stdout.contains('\u{1b}') && !stdout.contains("\\u001b"), "{row}: {stdout}"); let r: Value = serde_json::from_str(&stdout).unwrap(); - assert!(say(&r).starts_with(&format!("{failed}.")), "{row}: {r}"); + assert!(say(&r).starts_with(&said), "{row}: {r}"); } } From 8e5a1497f1fb315eed60ed7039d9557d6f070e40 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 10:01:09 -0500 Subject: [PATCH 31/32] chore: this release is 1.12.0, since 1.11.0 went out with the agent config formats --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06870d2f..06ecdc5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,7 +528,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "elevenlabs-cli" -version = "1.11.0" +version = "1.12.0" dependencies = [ "anstream", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 0b5d4d33..48347d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "elevenlabs-cli" -version = "1.11.0" +version = "1.12.0" edition = "2021" description = "CLI for the ElevenLabs API" license = "MIT" From 1fde33df2e73ffa8294cc4e441d906614e289cef Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 13 Nov 2026 10:02:27 -0500 Subject: [PATCH 32/32] chore: update quinn-udp to 0.5.16 for RUSTSEC-2026-0538 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06ecdc5a..338c384f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1872,9 +1872,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc",