diff --git a/.fernignore b/.fernignore index be46d4b..c353e2f 100644 --- a/.fernignore +++ b/.fernignore @@ -9,3 +9,14 @@ cli/elevenlabs/workflow/ # we own it to document the agents-as-code workflow and hero image). README.md assets/ + +# The generated ci.yml uses actions-rust-lang/setup-rust-toolchain, which this +# org's Actions allowlist rejects — that fails the whole workflow at startup, so +# no job ever runs. Our version drops it and uses the runner's preinstalled +# toolchain. Do not let a regeneration reintroduce it. (release.yml is left +# unprotected on purpose so cargo-dist updates still come through.) +.github/workflows/ci.yml + +# Hand-written: the generated types crate ships without a .gitignore, so running +# its test suite leaves untracked build artifacts. +elevenlabs-types/.gitignore diff --git a/README.md b/README.md index 62710ed..805a6a1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ The CLI does two things: - [Authentication](#authentication) - [Quick start](#quick-start) - [Agents as Code](#agents-as-code) +- [Data residency](#data-residency) +- [UI components](#ui-components) - [Usage](#usage) - [Documentation](#documentation) - [Advanced](#advanced) @@ -114,7 +116,6 @@ These sit alongside the generated API commands in the same group, which own the primitives — `elevenlabs agents create | get | list | update | delete`, and the `elevenlabs agents branches ...` subgroup for branch management. -> `residency` and `components add` are being ported from v0 and will land in follow-up changes. ### Tools @@ -166,6 +167,34 @@ Pre-built starting configurations for `agents add`, listed by `elevenlabs agents | `customer-service` | Pre-configured for customer service scenarios | | `assistant` | General purpose AI assistant configuration | +## Data residency + +Select the region your requests are routed to. The setting is stored in `~/.elevenlabs/config.json` and applies to **every** command: + +```bash +elevenlabs residency # show the current region and its base URL +elevenlabs residency eu-residency # switch region +``` + +| Region | Base URL | +|--------|----------| +| `global` (default) | `https://api.elevenlabs.io` | +| `us` | `https://api.us.elevenlabs.io` | +| `eu-residency` | `https://api.eu.residency.elevenlabs.io` | +| `in-residency` | `https://api.in.residency.elevenlabs.io` | +| `sg-residency` | `https://api.sg.residency.elevenlabs.io` | + +`--base-url` and `ELEVENLABS_BASE_URL` take precedence when you need a one-off override. The region also sets the `server-location` attribute emitted by `agents widget`. + +## UI components + +Install [ElevenLabs UI](https://ui.elevenlabs.io) components into your project (delegates to `shadcn`, so Node.js/npm is required): + +```bash +elevenlabs components add # all components +elevenlabs components add conversation-bar +``` + ## Usage Every API resource appears as a subcommand (e.g. `elevenlabs `). Run `elevenlabs --help` to see available methods. diff --git a/cli/elevenlabs/workflow/agents.rs b/cli/elevenlabs/workflow/agents.rs index ce19749..d0f5ae9 100644 --- a/cli/elevenlabs/workflow/agents.rs +++ b/cli/elevenlabs/workflow/agents.rs @@ -15,7 +15,7 @@ use fern_cli_sdk::error::CliError; use fern_cli_sdk::openapi::AppContext; use serde_json::{json, Value}; -use super::util::{downcast_ctx, dry_run_flag, opt_string}; +use super::util::{downcast_ctx, dry_run_flag, opt_string, plan_pull_action, PullAction}; use super::{api, project, settings, templates, verify}; /// Register the `agents` command group. @@ -751,13 +751,6 @@ fn pull_command() -> clap::Command { ) } -#[derive(Clone, Copy, PartialEq)] -enum PullAction { - Create, - Update, - Skip, -} - fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError> { let agent = opt_string(matches, "agent"); let branch = opt_string(matches, "branch"); @@ -839,12 +832,7 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr let (mut n_create, mut n_update, mut n_skip) = (0usize, 0usize, 0usize); for (id, name) in &remote { let existing = registry.agents.iter().position(|a| a.id.as_deref() == Some(id.as_str())); - let action = match existing { - Some(_) if update || all => PullAction::Update, - Some(_) => PullAction::Skip, - None if update => PullAction::Skip, - None => PullAction::Create, - }; + let action = plan_pull_action(existing.is_some(), update, all); match action { PullAction::Create => n_create += 1, PullAction::Update => n_update += 1, @@ -1079,3 +1067,49 @@ fn build_pulled_config(name: &str, live: &Value) -> Value { } Value::Object(obj) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pulled_config_keeps_only_the_on_disk_shape() { + // Server-side metadata (agent_id, version_id, …) must not leak into the + // config file — ids live in agents.json, so a pulled config stays + // pushable as-is. + let live = json!({ + "agent_id": "agent_1", + "version_id": "ver_1", + "conversation_config": { "agent": { "language": "en" } }, + "platform_settings": { "auth": { "enable_auth": false } }, + "tags": ["a"] + }); + let config = build_pulled_config("My Agent", &live); + assert_eq!(config["name"], json!("My Agent")); + assert_eq!(config["conversation_config"]["agent"]["language"], json!("en")); + assert_eq!(config["tags"], json!(["a"])); + assert!(config.get("agent_id").is_none()); + assert!(config.get("version_id").is_none()); + } + + #[test] + fn pulled_config_fills_defaults_for_missing_blocks() { + let config = build_pulled_config("A", &json!({})); + assert_eq!(config["conversation_config"], json!({})); + assert_eq!(config["platform_settings"], json!({})); + assert_eq!(config["tags"], json!([])); + } + + #[test] + fn workflow_is_included_only_when_present_and_non_null() { + let with = build_pulled_config("A", &json!({ "workflow": { "nodes": [] } })); + assert_eq!(with["workflow"], json!({ "nodes": [] })); + + // A null workflow would be rejected on push, so it's omitted entirely. + let null = build_pulled_config("A", &json!({ "workflow": Value::Null })); + assert!(null.get("workflow").is_none()); + + let absent = build_pulled_config("A", &json!({})); + assert!(absent.get("workflow").is_none()); + } +} diff --git a/cli/elevenlabs/workflow/components.rs b/cli/elevenlabs/workflow/components.rs index fb4b469..ca0301e 100644 --- a/cli/elevenlabs/workflow/components.rs +++ b/cli/elevenlabs/workflow/components.rs @@ -1,11 +1,137 @@ //! The `components add` command: install ElevenLabs UI components by -//! shelling out to `npx shadcn`. Ports v0's `src/components/`. -//! -//! (Scaffold — command handler lands in a later step.) +//! delegating to `shadcn`. Ports v0's `src/components/`. + +use std::process::Command; use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; + +const REGISTRY_BASE: &str = "https://ui.elevenlabs.io/r"; + +/// Registry component name. Restricted to the characters a registry slug can +/// contain so it can't inject extra arguments or reshape the URL. +fn validate_component(name: &str) -> Result<(), CliError> { + let valid = !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + && !name.starts_with('.') + && !name.contains(".."); + if valid { + Ok(()) + } else { + Err(CliError::Validation(format!( + "Invalid component name '{name}'. Use letters, digits, '-', '_' or '.'." + ))) + } +} + +/// Build the argv for the shadcn invocation. Arguments are passed +/// individually (never through a shell), so nothing in `url` is re-parsed. +/// Windows needs `cmd /C` because `npx` is a `.cmd` shim that +/// `std::process::Command` won't resolve on its own. +fn shadcn_argv(url: &str) -> (&'static str, Vec) { + let shadcn = ["-y", "shadcn@latest", "add", url].map(str::to_string); + if cfg!(windows) { + let mut args = vec!["/C".to_string(), "npx".to_string()]; + args.extend(shadcn); + ("cmd", args) + } else { + ("npx", shadcn.to_vec()) + } +} + +#[derive(clap::Args)] +struct AddArgs { + /// Component to install. Defaults to `all`. + #[arg(default_value = "all")] + name: String, +} + +fn handle_add(args: AddArgs, _ctx: &AppContext) -> Result<(), CliError> { + validate_component(&args.name)?; + let url = format!("{REGISTRY_BASE}/{}.json", args.name); + + println!("Installing {} from the ElevenLabs UI registry...", args.name); + println!("Source: {url}\n"); + + let (program, argv) = shadcn_argv(&url); + // stdio is inherited so shadcn's interactive prompts work. + let status = Command::new(program).args(&argv).status().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CliError::Validation( + "npx is not available. Please install Node.js/npm to add components.".to_string(), + ) + } else { + CliError::Other(anyhow::anyhow!("Could not run npx: {e}")) + } + })?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) +} /// Register the `components` command group. pub fn register(app: CliApp) -> CliApp { - app + app.command_under_typed_with( + &["components"], + clap::Command::new("add").about("Add a component from the ElevenLabs UI registry"), + handle_add, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_registry_slugs() { + for name in ["all", "conversation-bar", "orb_v2", "widget.embed"] { + assert!(validate_component(name).is_ok(), "{name} should be valid"); + } + } + + #[test] + fn rejects_injection_and_traversal_attempts() { + for name in [ + "", + "a b", + "a;rm -rf /", + "a&&b", + "a|b", + "$(whoami)", + "../../etc/passwd", + "..", + ".hidden", + "a/b", + ] { + assert!( + validate_component(name).is_err(), + "{name:?} should be rejected" + ); + } + } + + #[test] + fn argv_never_goes_through_a_shell_on_unix() { + let (program, argv) = shadcn_argv("https://ui.elevenlabs.io/r/all.json"); + if cfg!(windows) { + assert_eq!(program, "cmd"); + assert_eq!(argv[0], "/C"); + } else { + assert_eq!(program, "npx"); + assert_eq!( + argv, + vec![ + "-y", + "shadcn@latest", + "add", + "https://ui.elevenlabs.io/r/all.json" + ] + ); + } + } } diff --git a/cli/elevenlabs/workflow/residency.rs b/cli/elevenlabs/workflow/residency.rs index 25503dc..e4300d3 100644 --- a/cli/elevenlabs/workflow/residency.rs +++ b/cli/elevenlabs/workflow/residency.rs @@ -1,11 +1,133 @@ //! The `residency` command: get/set the data-residency region, which -//! maps to the API base URL. Ports v0's `auth residency`. +//! selects the API base URL. Ports v0's `auth residency`. //! -//! (Scaffold — command handler lands in a later step.) +//! v0 built its own HTTP client, so it could route calls straight from the +//! stored setting. Here the framework resolves the base URL from +//! `--base-url` / `ELEVENLABS_BASE_URL` and offers no config-file hook, so +//! [`apply_stored_residency`] bridges the gap: it runs during `register` +//! (before `CliApp::run`, which is where the framework reads the env) and +//! exports the stored region's base URL when nothing more explicit is set. +//! That way a configured residency routes *every* command — generated API +//! commands included — while `--base-url` and an explicit +//! `ELEVENLABS_BASE_URL` still win. use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; + +use super::settings; + +/// Env var the framework reads for the base URL. Derived from the binary +/// name (`elevenlabs` → `ELEVENLABS_`), which the generator pins. +const BASE_URL_ENV: &str = "ELEVENLABS_BASE_URL"; + +/// Which base URL to export, given whatever the env already holds and the +/// stored region. `None` means "leave the env alone" — either something more +/// explicit is already set, or the region is the API's default host. Kept +/// separate from the env plumbing so the precedence rules are unit-testable. +fn base_url_to_export(existing: Option<&str>, residency: &str) -> Option<&'static str> { + // An empty value is treated as unset; exporting "" would break every request. + if existing.is_some_and(|v| !v.trim().is_empty()) { + return None; + } + if residency == settings::DEFAULT_RESIDENCY { + return None; + } + Some(settings::base_url_for(residency)) +} + +/// Export the stored residency's base URL unless something more explicit +/// already set one. Precedence ends up: `--base-url` > `ELEVENLABS_BASE_URL` +/// (real env or `.env`) > stored residency > default. +fn apply_stored_residency() { + // The framework loads `.env` inside `run()`, i.e. after this point, and + // dotenvy never overrides an existing var — so load it here first to see + // a `.env`-provided base URL and leave it alone. + let _ = dotenvy::dotenv(); + let existing = std::env::var(BASE_URL_ENV).ok(); + let residency = settings::read_residency(); + if let Some(url) = base_url_to_export(existing.as_deref(), &residency) { + std::env::set_var(BASE_URL_ENV, url); + } +} + +#[derive(clap::Args)] +struct ResidencyArgs { + /// Region to switch to. Omit to show the current setting. + residency: Option, +} + +fn handle_residency(args: ResidencyArgs, _ctx: &AppContext) -> Result<(), CliError> { + let Some(requested) = args.residency else { + let current = settings::read_residency(); + println!("Residency: {current}"); + println!("Base URL: {}", settings::base_url_for(¤t)); + println!("\nAvailable: {}", settings::RESIDENCY_VALUES.join(", ")); + println!("Set one with 'elevenlabs residency '."); + return Ok(()); + }; + + if !settings::RESIDENCY_VALUES.contains(&requested.as_str()) { + return Err(CliError::Validation(format!( + "Invalid residency '{requested}'. Available: {}", + settings::RESIDENCY_VALUES.join(", ") + ))); + } + + settings::write_residency(&requested)?; + println!("Residency set to: {requested}"); + println!("Base URL: {}", settings::base_url_for(&requested)); + println!("\nThis applies to every command from now on. Override it per-run with"); + println!("--base-url or by setting {BASE_URL_ENV}."); + Ok(()) +} /// Register the `residency` command. +/// +/// Top-level rather than under `auth` (where v0 had it): the framework owns +/// the `auth` group, grafting it *after* custom commands and dispatching it +/// first, so nesting here would create a duplicate group whose subcommand +/// never reaches this handler. pub fn register(app: CliApp) -> CliApp { - app + apply_stored_residency(); + app.command_typed_with( + clap::Command::new("residency") + .about("Show or set the data-residency region (selects the API base URL)"), + handle_residency, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_explicit_base_url_is_never_overridden() { + assert_eq!( + base_url_to_export(Some("https://example.test"), "eu-residency"), + None + ); + } + + #[test] + fn an_empty_base_url_counts_as_unset() { + // Exporting "" would break every request, so treat it as absent. + assert_eq!( + base_url_to_export(Some(" "), "eu-residency"), + Some("https://api.eu.residency.elevenlabs.io") + ); + } + + #[test] + fn the_default_region_exports_nothing() { + assert_eq!(base_url_to_export(None, settings::DEFAULT_RESIDENCY), None); + } + + #[test] + fn an_isolated_region_exports_its_host() { + assert_eq!( + base_url_to_export(None, "sg-residency"), + Some("https://api.sg.residency.elevenlabs.io") + ); + } } diff --git a/cli/elevenlabs/workflow/settings.rs b/cli/elevenlabs/workflow/settings.rs index 093fee1..bce1ee5 100644 --- a/cli/elevenlabs/workflow/settings.rs +++ b/cli/elevenlabs/workflow/settings.rs @@ -55,17 +55,26 @@ pub fn write_residency(residency: &str) -> Result<(), CliError> { .map_err(|e| CliError::Other(anyhow::anyhow!("Could not create {}: {e}", dir.display())))?; let path = dir.join("config.json"); - let mut obj = std::fs::read_to_string(&path) + let existing = std::fs::read_to_string(&path) .ok() - .and_then(|d| serde_json::from_str::(&d).ok()) + .and_then(|d| serde_json::from_str::(&d).ok()); + + let rendered = super::project::to_pretty_string(&merge_residency(existing, residency))?; + std::fs::write(&path, rendered) + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not write {}: {e}", path.display()))) +} + +/// Set `residency` on the existing config, preserving unrelated keys and +/// dropping any `api_key` — credentials belong in the framework's keyring, never +/// in this file. Split out from [`write_residency`] so it's testable without +/// touching a real home directory. +fn merge_residency(existing: Option, residency: &str) -> Value { + let mut obj = existing .and_then(|v| v.as_object().cloned()) .unwrap_or_default(); obj.insert("residency".to_string(), Value::String(residency.to_string())); obj.remove("api_key"); - - let rendered = super::project::to_pretty_string(&Value::Object(obj))?; - std::fs::write(&path, rendered) - .map_err(|e| CliError::Other(anyhow::anyhow!("Could not write {}: {e}", path.display()))) + Value::Object(obj) } /// Map a residency to its API base URL. Ports v0's `getApiBaseUrl`. @@ -79,3 +88,70 @@ pub fn base_url_for(residency: &str) -> &'static str { _ => "https://api.elevenlabs.io", } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn every_region_maps_to_its_host() { + // Ports v0's residency.test.ts. + assert_eq!(base_url_for("global"), "https://api.elevenlabs.io"); + assert_eq!(base_url_for("us"), "https://api.us.elevenlabs.io"); + assert_eq!( + base_url_for("eu-residency"), + "https://api.eu.residency.elevenlabs.io" + ); + assert_eq!( + base_url_for("in-residency"), + "https://api.in.residency.elevenlabs.io" + ); + assert_eq!( + base_url_for("sg-residency"), + "https://api.sg.residency.elevenlabs.io" + ); + } + + #[test] + fn an_unrecognized_region_falls_back_to_the_default_host() { + assert_eq!(base_url_for("mars"), base_url_for(DEFAULT_RESIDENCY)); + } + + #[test] + fn the_default_region_is_offered_as_a_choice() { + assert!(RESIDENCY_VALUES.contains(&DEFAULT_RESIDENCY)); + // Every advertised region must map somewhere. + for region in RESIDENCY_VALUES { + assert!(base_url_for(region).starts_with("https://")); + } + } + + #[test] + fn merging_preserves_unrelated_keys() { + let existing = json!({ "residency": "us", "other": 1 }); + let merged = merge_residency(Some(existing), "eu-residency"); + assert_eq!(merged["residency"], json!("eu-residency")); + assert_eq!(merged["other"], json!(1)); + } + + #[test] + fn merging_never_persists_an_api_key() { + let existing = json!({ "api_key": "sk-secret", "residency": "us" }); + let merged = merge_residency(Some(existing), "global"); + assert!( + merged.get("api_key").is_none(), + "api_key must never be written to the config file" + ); + } + + #[test] + fn merging_handles_a_missing_or_malformed_config() { + assert_eq!(merge_residency(None, "us")["residency"], json!("us")); + // A non-object config (e.g. hand-edited to a list) is replaced, not crashed on. + assert_eq!( + merge_residency(Some(json!([1, 2])), "us")["residency"], + json!("us") + ); + } +} diff --git a/cli/elevenlabs/workflow/tests.rs b/cli/elevenlabs/workflow/tests.rs index 67049e3..7db022b 100644 --- a/cli/elevenlabs/workflow/tests.rs +++ b/cli/elevenlabs/workflow/tests.rs @@ -10,7 +10,7 @@ use fern_cli_sdk::error::CliError; use fern_cli_sdk::openapi::AppContext; use serde_json::{json, Value}; -use super::util::{downcast_ctx, dry_run_flag, opt_string}; +use super::util::{downcast_ctx, dry_run_flag, opt_string, plan_pull_action, PullAction}; use super::{api, project}; /// Register the `tests` command group. @@ -564,13 +564,6 @@ fn pull_command() -> clap::Command { ) } -#[derive(Clone, Copy, PartialEq)] -enum PullAction { - Create, - Update, - Skip, -} - fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError> { let test_filter = opt_string(matches, "test"); let output_dir = @@ -632,12 +625,7 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr .tests .iter() .position(|t| t.id.as_deref() == Some(id.as_str())); - let action = match existing { - Some(_) if update || all => PullAction::Update, - Some(_) => PullAction::Skip, - None if update => PullAction::Skip, - None => PullAction::Create, - }; + let action = plan_pull_action(existing.is_some(), update, all); match action { PullAction::Create => n_create += 1, PullAction::Update => n_update += 1, @@ -775,3 +763,15 @@ mod tests { )); } } + +#[cfg(test)] +mod id_tests { + use super::*; + + #[test] + fn test_id_tolerates_both_spellings() { + assert_eq!(test_id_of(&json!({ "id": "t1" })), Some("t1".to_string())); + assert_eq!(test_id_of(&json!({ "test_id": "t2" })), Some("t2".to_string())); + assert_eq!(test_id_of(&json!({ "name": "none" })), None); + } +} diff --git a/cli/elevenlabs/workflow/tools.rs b/cli/elevenlabs/workflow/tools.rs index d448d7a..d6fa622 100644 --- a/cli/elevenlabs/workflow/tools.rs +++ b/cli/elevenlabs/workflow/tools.rs @@ -9,7 +9,7 @@ use fern_cli_sdk::error::CliError; use fern_cli_sdk::openapi::AppContext; use serde_json::{json, Value}; -use super::util::{downcast_ctx, dry_run_flag, opt_string}; +use super::util::{downcast_ctx, dry_run_flag, opt_string, plan_pull_action, PullAction}; use super::{api, project, verify}; /// Register the `tools` command group. @@ -415,13 +415,6 @@ fn pull_command() -> clap::Command { ) } -#[derive(Clone, Copy, PartialEq)] -enum PullAction { - Create, - Update, - Skip, -} - fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError> { let tool_filter = opt_string(matches, "tool"); let output_dir = @@ -484,12 +477,7 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr let (mut n_create, mut n_update, mut n_skip) = (0usize, 0usize, 0usize); for (id, name) in &remote { let existing = registry.tools.iter().position(|t| t.id.as_deref() == Some(id.as_str())); - let action = match existing { - Some(_) if update || all => PullAction::Update, - Some(_) => PullAction::Skip, - None if update => PullAction::Skip, - None => PullAction::Create, - }; + let action = plan_pull_action(existing.is_some(), update, all); match action { PullAction::Create => n_create += 1, PullAction::Update => n_update += 1, @@ -587,3 +575,47 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_id_tolerates_every_spelling_the_api_uses() { + assert_eq!(tool_id_of(&json!({ "id": "t1" })), Some("t1".to_string())); + assert_eq!(tool_id_of(&json!({ "tool_id": "t2" })), Some("t2".to_string())); + assert_eq!(tool_id_of(&json!({ "toolId": "t3" })), Some("t3".to_string())); + assert_eq!(tool_id_of(&json!({ "name": "no id here" })), None); + } + + #[test] + fn tool_id_prefers_id_when_several_are_present() { + assert_eq!( + tool_id_of(&json!({ "id": "first", "tool_id": "second" })), + Some("first".to_string()) + ); + } + + #[test] + fn default_tools_carry_their_type_and_name() { + let webhook = default_webhook_tool("My Hook"); + assert_eq!(webhook["name"], json!("My Hook")); + assert_eq!(webhook["type"], json!("webhook")); + assert_eq!(webhook["api_schema"]["method"], json!("POST")); + + let client = default_client_tool("My Client"); + assert_eq!(client["name"], json!("My Client")); + assert_eq!(client["type"], json!("client")); + assert_eq!(client["expects_response"], json!(false)); + } + + #[test] + fn default_webhook_preserves_header_names_verbatim() { + // Header names are user-facing keys; a casing transform would break them. + let webhook = default_webhook_tool("H"); + assert_eq!( + webhook["api_schema"]["request_headers"]["Content-Type"], + json!("application/json") + ); + } +} diff --git a/cli/elevenlabs/workflow/util.rs b/cli/elevenlabs/workflow/util.rs index c421c7a..36a5861 100644 --- a/cli/elevenlabs/workflow/util.rs +++ b/cli/elevenlabs/workflow/util.rs @@ -26,3 +26,86 @@ pub fn dry_run_flag(matches: &clap::ArgMatches) -> bool { pub fn opt_string(matches: &clap::ArgMatches, id: &str) -> Option { matches.get_one::(id).cloned() } + +/// What a `pull` should do with one remote entity. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PullAction { + Create, + Update, + Skip, +} + +/// Decide a pull action from whether the entity is already tracked locally and +/// the `--update` / `--all` flags. Shared by the agents, tools, and tests pull +/// commands so they can't drift apart. +/// +/// Default (neither flag): create new, skip existing — so a plain `pull` never +/// clobbers local edits. `--update`: existing only. `--all`: both. +pub fn plan_pull_action(exists_locally: bool, update: bool, all: bool) -> PullAction { + match exists_locally { + true if update || all => PullAction::Update, + true => PullAction::Skip, + false if update => PullAction::Skip, + false => PullAction::Create, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matches_from(args: &[&str]) -> clap::ArgMatches { + clap::Command::new("test") + .arg(clap::Arg::new("dry-run").long("dry-run").action(clap::ArgAction::SetTrue)) + .arg(clap::Arg::new("agent").long("agent")) + .get_matches_from(args) + } + + #[test] + fn dry_run_flag_reads_the_global_flag() { + assert!(dry_run_flag(&matches_from(&["test", "--dry-run"]))); + assert!(!dry_run_flag(&matches_from(&["test"]))); + } + + #[test] + fn dry_run_flag_defaults_false_when_the_arg_is_absent() { + // Commands that never declare the flag must not panic. + let m = clap::Command::new("test").get_matches_from(["test"]); + assert!(!dry_run_flag(&m)); + } + + #[test] + fn opt_string_reads_present_and_absent_values() { + assert_eq!( + opt_string(&matches_from(&["test", "--agent", "a_1"]), "agent"), + Some("a_1".to_string()) + ); + assert_eq!(opt_string(&matches_from(&["test"]), "agent"), None); + } + + #[test] + fn default_pull_creates_new_and_skips_existing() { + assert_eq!(plan_pull_action(false, false, false), PullAction::Create); + assert_eq!(plan_pull_action(true, false, false), PullAction::Skip); + } + + #[test] + fn update_only_touches_existing() { + assert_eq!(plan_pull_action(true, true, false), PullAction::Update); + assert_eq!(plan_pull_action(false, true, false), PullAction::Skip); + } + + #[test] + fn all_covers_both() { + assert_eq!(plan_pull_action(true, false, true), PullAction::Update); + assert_eq!(plan_pull_action(false, false, true), PullAction::Create); + } + + #[test] + fn update_still_suppresses_new_items_when_combined_with_all() { + // Matches v0: the new-item branch only checks `--update`, so passing + // both flags still skips items that don't exist locally. + assert_eq!(plan_pull_action(true, true, true), PullAction::Update); + assert_eq!(plan_pull_action(false, true, true), PullAction::Skip); + } +}