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/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index f6d8fb96..ea6c1c5f 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -17,6 +17,8 @@ on: paths: - cli/elevenlabs/workflow/onboard/** - cli/elevenlabs/workflow/loopback.rs + - cli/elevenlabs/workflow/setup.rs + - cli/elevenlabs/workflow/setup/** - .github/workflows/windows-smoke.yml jobs: @@ -50,6 +52,165 @@ 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 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" + # Stand-ins for `claude` and `codex` that log their arguments, one + # line per run; the claude in $failing refuses `plugin install`. + stand_in() { + cat > "$1/$2.cmd" <>"$(cygpath -w "$1/$2.log")" + $3 + exit /b 0 + EOF + } + 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 + expect "$bin/claude.log" "$(printf '%s\n' \ + "plugin marketplace add elevenlabs/plugin" \ + "plugin marketplace update elevenlabs" \ + "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" \ + "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 + 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@elevenlabs")" \ + "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 coding agents 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())) + } + # 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 + 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. + 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" } + Assert-Ran $bin 'claude' @( + 'plugin marketplace add elevenlabs/plugin' + 'plugin marketplace update elevenlabs' + 'plugin install elevenlabs@elevenlabs' + 'plugin update elevenlabs@elevenlabs' + ) 'Expected the four 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@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@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 + - name: Set up Node.js uses: actions/setup-node@v6 with: diff --git a/Cargo.lock b/Cargo.lock index 56f6c9a9..338c384f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,8 +528,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "elevenlabs-cli" -version = "1.11.0" +version = "1.12.0" dependencies = [ + "anstream", "anyhow", "arboard", "base64", @@ -1871,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", diff --git a/Cargo.toml b/Cargo.toml index 3a1c5252..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" @@ -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/README.md b/README.md index 4734545f..36991645 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) @@ -451,6 +452,26 @@ 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 (so does --dry-run) +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 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` changes nothing and says how to install only the [ElevenLabs skills](https://github.com/elevenlabs/skills), with `npx skills add elevenlabs/skills`. + +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` 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. + ## Usage Every API resource appears as a subcommand (e.g. `elevenlabs `). Run `elevenlabs --help` to see available methods. @@ -571,6 +592,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/mod.rs b/cli/elevenlabs/workflow/mod.rs index 9bd9f679..9d0f2af7 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -30,6 +30,7 @@ mod project; mod residency; mod say; mod settings; +mod setup; mod skills; mod telemetry; mod templates; @@ -58,6 +59,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/check.rs b/cli/elevenlabs/workflow/onboard/check.rs index 5664d22b..788bde04 100644 --- a/cli/elevenlabs/workflow/onboard/check.rs +++ b/cli/elevenlabs/workflow/onboard/check.rs @@ -77,7 +77,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."), diff --git a/cli/elevenlabs/workflow/onboard/connect.rs b/cli/elevenlabs/workflow/onboard/connect.rs index 0fbd7b44..dd076d9a 100644 --- a/cli/elevenlabs/workflow/onboard/connect.rs +++ b/cli/elevenlabs/workflow/onboard/connect.rs @@ -52,7 +52,8 @@ pub(super) fn handle( super::sign_in_tag(previous.as_ref()), ); let reply = sign_in_and_store(matches, ctx, &project, previous.as_ref(), || { - session.signed_in(&lock) + session.signed_in(&lock); + session.offer_setup(); }) .unwrap_or_else(|e| failed(&project, &e.to_string(), replace)); session.finish(&lock, &reply.to_value())?; diff --git a/cli/elevenlabs/workflow/onboard/mod.rs b/cli/elevenlabs/workflow/onboard/mod.rs index 32b91978..c0abd145 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,67 @@ 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 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 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()), + ..super::setup::LIMITS + }; + let outcomes = super::setup::run(&super::setup::found_here(&home), &home, limits); + 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, 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; + }; + 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 +209,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 +224,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..b3c98fa9 100644 --- a/cli/elevenlabs/workflow/onboard/session.rs +++ b/cli/elevenlabs/workflow/onboard/session.rs @@ -46,6 +46,9 @@ 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 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"; /// 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,7 +200,7 @@ impl Session { restarted = true; } self.keep_last_sign_in(restarted); - for stale in ["result.json", "worker.log", "worker.pid", "signed-in"] { + for stale in ["result.json", "worker.log", "worker.pid", "signed-in", SETUP_OFFER] { let _ = std::fs::remove_file(self.path(stale)); } std::fs::write(self.path("request"), request).map_err(io(&self.path("request")))?; @@ -300,6 +303,21 @@ impl Session { let _ = std::fs::write(self.path("signed-in"), ""); } + /// 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), ""); + } + + /// 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)); + } + /// 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. These work on every + /// version of each agent. + fn steps(&self) -> &'static [Step] { + match self { + Agent::ClaudeCode(_) => &[ + 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(_) => &[ + 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` in a terminal to sign in to the ElevenLabs MCP \ + server", + ), + Agent::Cursor => None, + } + } +} + +/// 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 +/// 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. 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_absolute() && p.is_file() && is_claude(p)) + .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 +} + +/// 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); + 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_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). 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"] + .iter() + .map(|ext| format!("{name}{ext}")) + .collect() + } else { + 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) }) +} + +#[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() +} + +/// 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. 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}")), + }; + 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) + .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); + // 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); + 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); + 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)), + _ => { + stop_all(&mut child); + 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}"), + }) +} + +/// `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. +fn stop_all(child: &mut std::process::Child) { + // The child leads its own group, so this reaches only what it started. + #[cfg(unix)] + let group = i32::try_from(child.id()).ok(); + #[cfg(unix)] + if let Some(group) = group { + 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(); + 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 [`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}; + + const SIGNALS: [libc::c_int; 2] = [libc::SIGINT, libc::SIGTERM]; + + static CAUGHT: AtomicI32 = AtomicI32::new(0); + + extern "C" fn note(signal: libc::c_int) { + CAUGHT.store(signal, Ordering::SeqCst); + } + + /// Catches the signals, holding the handlers from before. + pub struct Catch([libc::sighandler_t; 2]); + + impl Catch { + pub fn start() -> Catch { + 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) }; + } + previous + })) + } + + /// 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(); + } + } + + pub fn caught() -> Option { + Some(CAUGHT.load(Ordering::SeqCst)).filter(|&s| s != 0) + } + + pub fn exit_if_caught() { + if let Some(signal) = caught() { + std::process::exit(128 + signal); + } + } +} + +/// 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, SeekFrom}; + let len = file.seek(SeekFrom::End(0)).ok()?; + file.seek(SeekFrom::Start(len.saturating_sub(TAIL))).ok()?; + let mut bytes = Vec::new(); + file.take(TAIL).read_to_end(&mut bytes).ok()?; + 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, 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 { + // 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]), + _ => line.to_string(), + }) +} + +/// Install the plugin into each agent with `run`, which gets the program, +/// 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>, + elapsed: &dyn Fn() -> Duration, +) -> Vec { + agents + .iter() + .map(|agent| { + let outcome = |ok: bool, message: String| Outcome { + agent: agent.name(), + 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")), + }; + 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() { + 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)) { + let failed = first_failed + .get_or_insert_with(|| format!("`{name} {}` {why}", step.args.join(" "))); + if step.decides { + return outcome(false, failed.clone()); + } + } + } + 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. A Ctrl-C +/// or SIGTERM meanwhile stops the command running and exits. +pub(super) fn run(agents: &[Agent], home: &Path, limits: Limits) -> Vec { + #[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]) -> Vec { + 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() +} + +/// 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, + ); + } + let mut lines: Vec = outcomes + .iter() + .map(|o| { + let failed = if o.ok { "" } else { "failed: " }; + // 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}.")); + } + 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 +/// `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 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(_))); + let names = match (claude, codex) { + (true, true) => "Claude Code and Codex", + (true, false) => "Claude Code", + (false, true) => "Codex", + (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 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); + 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 (lines, failed) = report(&run(&agents, &home, LIMITS)); + for line in lines { + println!("{line}"); + } + if failed { + 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; diff --git a/cli/elevenlabs/workflow/setup/tests.rs b/cli/elevenlabs/workflow/setup/tests.rs new file mode 100644 index 00000000..84d35e6c --- /dev/null +++ b/cli/elevenlabs/workflow/setup/tests.rs @@ -0,0 +1,1176 @@ +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}"))) + } +} + +#[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 those in `fails`. +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.contains(&line.as_str()) { + true => Err("exited with 1: boom".into()), + false => Ok(()), + } + } + + 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 { + vec![ + Agent::ClaudeCode("claude".into()), + Agent::Codex("codex".into()), + Agent::Cursor, + ] +} + +#[test] +fn install_runs_each_agents_own_commands_in_order_and_says_how_to_finish() { + let recorder = Recorder::new(&[]); + 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@elevenlabs", + "claude plugin update elevenlabs@elevenlabs", + "codex plugin marketplace add elevenlabs/plugin", + "codex plugin marketplace upgrade elevenlabs", + "codex plugin add elevenlabs@elevenlabs", + ] + ); + assert_eq!( + 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` 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(), + ], + false + ) + ); + + let recorder = Recorder::new(&["claude plugin install elevenlabs@elevenlabs"]); + let outcomes = recorder.install(&all_agents()[..2]); + assert_eq!( + 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` in a terminal 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 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(&all_agents(), limits, &slow, &|| used.get()); + assert_eq!(recorder.ran().len(), 2, "{:?}", recorder.ran()); + 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"); +} + +/// 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)); + 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 +/// 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 { + let path = dir.join(name); + 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() { + 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 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], + Limits { + command: Duration::from_millis(200), + total: Duration::from_millis(300), + }, + &|p, a, l| run_program(p, a, l, dir.path()), + &|| started.elapsed(), + ); + 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); +} + +/// 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 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]); +} + +#[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(&[]), None, "nothing to install 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() { + assert_eq!( + 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", + ] + ); +} + +// ── 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, + /// `CURSOR_AGENT` set but empty, which doesn't count. + EmptyVariable, + 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::EmptyVariable, + 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::EmptyVariable => vars.push(("CURSOR_AGENT", "")), + CursorBy::Nothing => {} + } + if !matches!(cursor, CursorBy::EmptyVariable | 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, 40); +} + +/// 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:?}"); + } +} + +#[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"); + // 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); + // 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>); 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), + ("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())]); + 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 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@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] +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 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, 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(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(); + 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( + &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| 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() { + assert!( + outcome.message.contains("ran out of time"), + "{why}: stopped early only when time ran out: {outcome:?}" + ); + } + let every_deciding_command_worked = planned + .iter() + .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:?}"); + } + assert_eq!(next, calls.len(), "{why}: every command belongs to an agent, in their order"); + } +} + +// ── 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()), + &|| Duration::ZERO, + ); + 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} & echo $! > \"{g}\"; wait' &\necho $! > '{c}'\nwait", + sleep = sleep_marker(3011), + g = grandchild.display(), + c = child.display() + ), + ); + let started = Instant::now(); + // 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 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"); + assert_eq!(left_behind(&sleep_marker(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 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(())), + 1 => (&fails, Duration::from_secs(10), Err("exited with 1: broke".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}"); + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let mut left = left_behind(&sleep); + 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)); + } +} + +// ── 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())), + // 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())), + ("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())), + // 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())), + ("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), + ]; + 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_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() { + 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())) + ); +} + +/// 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 50ms 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)); + } +} + +// ── 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/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 c2905b71..ca89881e 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", @@ -76,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" @@ -92,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() } @@ -1383,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 @@ -1730,3 +1781,1262 @@ 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) +} + +/// 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); + 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 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: &[&str]) -> String { + for name in names { + let fails = match fail { + [] => 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("|") + ), + }; + let script = format!( + "#!/bin/sh\necho \"{name} $*\" >> '{}'\n{fails}exit 0\n", + record.display() + ); + program(&dir.join(name), &script); + } + 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_after_a_sign_in() { + 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, &[]); + 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 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. + 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"), &[]); + 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_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; + 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 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@elevenlabs", + "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, &[]); + 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` 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}"); + 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(), 14, "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, &["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@elevenlabs` exited with 1: install refused. \ + Codex: installed" + ), + "{r}" + ); + assert!( + say(&r).ends_with(&format!("To try again, 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()); +} + +/// 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)] +#[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, &[]); + 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`." + ); +} + +/// 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!({ + "access_token": "token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .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 sign-in link is as it always was: {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_still_busy().await { + 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 sign_in = |params: &str, key_status: &str| { + 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"); + 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"); +} + +// ── setup through the binary, for every combination of agents ─────── + +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` 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 \ + 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() { + // (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 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, &[]); + 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!(&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"); + runs += 1; + } + } + assert_eq!(runs, 32); +} + +#[cfg(unix)] +#[test] +fn setup_through_the_binary_with_a_stand_in_failing_at_each_step() { + let claude_failed = |command: &str| { + format!("Claude Code: failed: `claude {command}` exited with 1: install refused.\n") + }; + // (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"], + 1, + format!("{}{CODEX_DONE}{TRY_AGAIN}", claude_failed("plugin install elevenlabs@elevenlabs")), + [&INSTALLS[..3], &INSTALLS[4..]].concat(), + ), + ( + &["plugin update"], + 1, + 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()), + ( + &["plugin add"], + 1, + format!( + "{CLAUDE_DONE}Codex: failed: `codex plugin add elevenlabs@elevenlabs` exited with 1: install refused.\n\ + {TRY_AGAIN}" + ), + INSTALLS.to_vec(), + ), + // The install fails for want of the marketplace: its own error is the cause. + ( + &["marketplace add", "plugin install"], + 1, + 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, 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"); + } +} + +/// 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. +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(), + ), + ( + "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(); + 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@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!("{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"]) + .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(&said), "{row}: {r}"); + } +} + +#[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, &[]); + 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!(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), format!("{CLAUDE_DONE}{CODEX_DONE}")); + 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, &[]); + let says = format!("{CLAUDE_DONE}{CODEX_DONE}"); + 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 seven commands"); +} + +#[cfg(unix)] +#[test] +fn setup_starts_agents_from_the_shells_environment_never_the_projects_env() { + 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")] { + program( + &dir.join("claude"), + &format!( + "#!/bin/sh\necho \"{said} claude $* [${{ELEVENLABS_SETUP_TEST:-unset}}]\" >> '{}'\n", + record.display() + ), + ); + } + 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[..4].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_still_busy().await { + 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, &[]); + 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 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}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"ID3".to_vec())) + .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 = [ + ("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", &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"], PLUGIN_QUESTION.replace("{}", "Codex"), "{row}"); + assert_eq!(r["run"], run, "{row}"); + } +} + +/// 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(); + 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 +} + +/// 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 a_project_that_has_not_signed_in_gets_the_replies_it_always_has() { + const MINE: &str = "agent_7101k5zvyjhmfg983brhmhkd98n6"; + 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; + 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]), + ]; + 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, &[]); + 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(); + 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 ─────────────────────────────────── + +/// 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: &str) -> String { + 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() + ); + program(&dir.join(name), &script); + } + 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)); + } +} + +/// 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: &str, 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 = 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 = 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, &[]); + 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"); + } +}