Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <resource> <method>`). Run `elevenlabs <resource> --help` to see available methods.
Expand Down
62 changes: 48 additions & 14 deletions cli/elevenlabs/workflow/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
}
134 changes: 130 additions & 4 deletions cli/elevenlabs/workflow/components.rs
Original file line number Diff line number Diff line change
@@ -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<String>) {
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"
]
);
}
}
}
Loading
Loading