diff --git a/.fernignore b/.fernignore index 084a8eb..be46d4b 100644 --- a/.fernignore +++ b/.fernignore @@ -1 +1,11 @@ # Specify files that shouldn't be modified by Fern + +# Hand-written custom commands (the "agents-as-code" workflow). +# These must never be overwritten by `fern generate`. +cli/elevenlabs/custom.rs +cli/elevenlabs/workflow/ + +# Hand-maintained README + assets (Fern generates a default README; +# we own it to document the agents-as-code workflow and hero image). +README.md +assets/ diff --git a/README.md b/README.md index 4482fd1..92a6c99 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ -# ElevenLabs API Documentation CLI +# ElevenLabs CLI — Agents as Code -Command-line interface for the ElevenLabs API Documentation API. +![hero](./assets/Cover.png) + +Command-line interface for the [ElevenLabs platform](https://elevenlabs.io/docs/agents-platform/overview). + +The CLI does two things: + +- **Full API access** — every ElevenLabs API endpoint is available as a subcommand (`elevenlabs `). +- **Agents as Code** — manage Conversational AI agents from local configuration files, with templates, branches, and push/pull sync. ## Table of contents - [Installation](#installation) - [Authentication](#authentication) - [Quick start](#quick-start) +- [Agents as Code](#agents-as-code) - [Usage](#usage) - [Documentation](#documentation) - [Advanced](#advanced) @@ -58,6 +66,61 @@ elevenlabs Run `elevenlabs --help` to see available methods for a resource. +## Agents as Code + +Manage Conversational AI agents from local configuration files. `elevenlabs agents init` scaffolds a project; agent configs live as JSON on disk and sync to ElevenLabs. Pulled configs are stored as raw wire JSON and pushed back verbatim, so they round-trip losslessly. + +### Project structure + +``` +your_project/ +├── agents.json # Agent registry: ids + branch mappings → config paths +├── tools.json # Tool registry +├── tests.json # Test registry +├── agent_configs/ # Agent configuration files +├── tool_configs/ # Tool configuration files +└── test_configs/ # Test configuration files +``` + +### Commands + +```bash +# Scaffold a new project (pass a path, or --override to reset an existing one) +elevenlabs agents init [path] [--override] + +# Inspect locally-configured agents +elevenlabs agents list +elevenlabs agents status + +# List available agent templates +elevenlabs agents templates list + +# Print an embeddable HTML widget snippet for an agent +elevenlabs agents widget + +# List an agent's branches (e.g. staging vs production) +elevenlabs agents branches list --agent [--include-archived] + +# Delete an agent locally and in ElevenLabs +elevenlabs agents delete +elevenlabs agents delete --all +``` + +> Additional agents-as-code commands (`add`, `push`, `pull`, `test`) and the `tools` / `tests` command groups are being ported from v0 and will land in follow-up changes. + +### Templates + +Pre-built starting configurations, listed by `elevenlabs agents templates list`: + +| Template | Description | +|----------|-------------| +| `default` | Complete configuration with all available fields and sensible defaults | +| `minimal` | Minimal configuration with only essential fields | +| `voice-only` | Optimized for voice-only conversations | +| `text-only` | Optimized for text-only conversations | +| `customer-service` | Pre-configured for customer service scenarios | +| `assistant` | General purpose AI assistant configuration | + ## Usage Every API resource appears as a subcommand (e.g. `elevenlabs `). Run `elevenlabs --help` to see available methods. diff --git a/assets/Cover.png b/assets/Cover.png new file mode 100644 index 0000000..2eed0d3 Binary files /dev/null and b/assets/Cover.png differ diff --git a/cli/elevenlabs/custom.rs b/cli/elevenlabs/custom.rs index b940753..4327a6a 100644 --- a/cli/elevenlabs/custom.rs +++ b/cli/elevenlabs/custom.rs @@ -1,41 +1,26 @@ -//! Custom command handlers. +//! Custom command handlers for the ElevenLabs "agents-as-code" workflow. //! -//! This file is yours to edit — add it to `.fernignore` so -//! `fern generate` will never overwrite your changes. +//! This file and the `workflow/` module tree are hand-written and are +//! protected from regeneration by `.fernignore` +//! (`cli/elevenlabs/custom.rs` and `cli/elevenlabs/workflow/`). The +//! generated `main.rs` calls `custom::register(app)` at startup, composing +//! these commands into the CLI at compile time. //! -//! The generated `main.rs` calls `custom::register(app)` at -//! startup, composing your commands into the CLI at compile time. -//! -//! Each handler receives an `AppContext`. Use `super::sdk::client(ctx)` -//! to get a fully-wired SDK client that inherits the CLI's auth, -//! retries, TLS, and global headers. Use `super::sdk::block_on(future)` -//! to run async SDK calls from synchronous handler context. -//! Types are available via `elevenlabs_sdk::api::*`. +//! Handlers get a fully-wired SDK client via `super::sdk::client(ctx)` +//! (inherits auth, retries, TLS, base URL, and global headers) and run +//! async SDK calls with `super::sdk::block_on(future)`. Types come from +//! `elevenlabs_sdk::api::*`. use fern_cli_sdk::app::CliApp; -/// Register custom commands on the CLI app builder. -/// -/// Called from `main.rs` during startup. Uncomment the example -/// below and adapt it to your API to get started. +// The workflow module tree lives at `cli/elevenlabs/workflow/`. Because +// this file is `custom.rs` (not `mod.rs`), point `mod` at the directory +// explicitly so the tree sits alongside the generated files rather than +// under a `custom/` subdirectory. +#[path = "workflow/mod.rs"] +mod workflow; + +/// Register all custom commands on the CLI app builder. pub fn register(app: CliApp) -> CliApp { - // Example: typed SDK client usage with the co-generated SDK. - // - // use elevenlabs_sdk::api::*; - // - // let app = app.command( - // clap::Command::new("get-plant") - // .about("Fetch a plant by its ID") - // .arg(clap::Arg::new("plant-id").required(true)), - // |matches, ctx| { - // let plant_id = matches.get_one::("plant-id").unwrap(); - // let client = super::sdk::client(ctx); - // let plant = super::sdk::block_on( - // client.plants.get_plant(plant_id, None), - // )?; - // println!("{}", serde_json::to_string_pretty(&plant).unwrap()); - // Ok(()) - // }, - // ); - app + workflow::register(app) } diff --git a/cli/elevenlabs/workflow/agents.rs b/cli/elevenlabs/workflow/agents.rs new file mode 100644 index 0000000..64ab9a0 --- /dev/null +++ b/cli/elevenlabs/workflow/agents.rs @@ -0,0 +1,487 @@ +//! The `agents` command group: init/add/list/status/push/pull/delete/ +//! widget/test/branches. Ports v0's `src/agents/`. +//! +//! Implemented so far: `init`. The remaining subcommands (add/list/ +//! status/push/pull/delete/widget/test/branches) land in a later step. +//! The `agents templates` subgroup is registered from [`super::templates`]. + +use std::path::{Path, PathBuf}; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use serde_json::Value; + +use super::{api, project, settings}; + +/// Register the `agents` command group. +pub fn register(app: CliApp) -> CliApp { + app.command_under_typed_with( + &["agents"], + clap::Command::new("init").about("Initialize a new agent management project"), + handle_init, + ) + .command_under_typed_with( + &["agents"], + clap::Command::new("list").about("List all configured agents"), + handle_list, + ) + .command_under_typed_with( + &["agents"], + clap::Command::new("status").about("Show the status of configured agents"), + handle_status, + ) + .command_under_typed_with( + &["agents"], + clap::Command::new("delete").about("Delete an agent locally and in ElevenLabs"), + handle_delete, + ) + .command_under_typed_with( + &["agents"], + clap::Command::new("widget").about("Print an embeddable HTML widget snippet for an agent"), + handle_widget, + ) + .command_under_typed_with( + &["agents", "branches"], + clap::Command::new("list").about("List branches for an agent"), + handle_branches_list, + ) +} + +// ── Shared helpers ────────────────────────────────────────────────── + +/// Load `agents.json`, erroring with v0's "run init first" hint when it +/// is missing. +fn require_agents() -> Result { + if !Path::new(project::AGENTS_FILE).exists() { + return Err(CliError::Validation( + "agents.json not found. Run 'elevenlabs agents init' first.".to_string(), + )); + } + project::load_agents() +} + +/// Read an agent's display name from its config file. Ports v0's +/// `getAgentName` (Unknown when unreadable, Unnamed when nameless). +fn agent_display_name(config_path: &str) -> String { + match project::read_value(Path::new(config_path)) { + Ok(value) => value + .get("name") + .and_then(Value::as_str) + .unwrap_or("Unnamed Agent") + .to_string(), + Err(_) => "Unknown Agent".to_string(), + } +} + +// ── init ──────────────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct InitArgs { + /// Path to initialize the project in. + #[arg(default_value = ".")] + path: String, + /// Override existing files and recreate config dirs from scratch. + #[arg(long)] + r#override: bool, +} + +fn handle_init(args: InitArgs, _ctx: &AppContext) -> Result<(), CliError> { + let root = PathBuf::from(&args.path); + let abs = std::env::current_dir() + .map(|cwd| cwd.join(&root)) + .unwrap_or_else(|_| root.clone()); + println!("Initializing project in {}", abs.display()); + if args.r#override { + println!("⚠ Override mode: existing files will be overwritten"); + } + + std::fs::create_dir_all(&root).map_err(io_err("create project directory", &root))?; + + init_index_file( + &root.join(project::AGENTS_FILE), + project::AGENTS_FILE, + args.r#override, + &project::AgentsConfig::default(), + )?; + init_index_file( + &root.join(project::TOOLS_FILE), + project::TOOLS_FILE, + args.r#override, + &project::ToolsConfig::default(), + )?; + init_index_file( + &root.join(project::TESTS_FILE), + project::TESTS_FILE, + args.r#override, + &project::TestsConfig::default(), + )?; + + for dir in [ + project::AGENT_CONFIGS_DIR, + project::TOOL_CONFIGS_DIR, + project::TEST_CONFIGS_DIR, + ] { + let dir_path = root.join(dir); + let existed = dir_path.exists(); + if args.r#override && existed { + std::fs::remove_dir_all(&dir_path).map_err(io_err("remove directory", &dir_path))?; + } + std::fs::create_dir_all(&dir_path).map_err(io_err("create directory", &dir_path))?; + if !args.r#override && existed { + println!("Created directory: {dir} (already existed)"); + } else { + println!("Created directory: {dir}"); + } + } + + let env_path = root.join(".env.example"); + if !args.r#override && env_path.exists() { + println!(".env.example already exists (skipped)"); + } else { + std::fs::write( + &env_path, + "# ElevenLabs API Key\nELEVENLABS_API_KEY=your_api_key_here\n", + ) + .map_err(io_err("write .env.example", &env_path))?; + println!("Created .env.example"); + } + + print_next_steps(); + Ok(()) +} + +fn init_index_file( + path: &Path, + name: &str, + overwrite: bool, + default: &T, +) -> Result<(), CliError> { + if !overwrite && path.exists() { + println!("{name} already exists (skipped)"); + } else { + project::write_json(path, default)?; + println!("Created {name}"); + } + Ok(()) +} + +fn print_next_steps() { + println!("\nProject initialized successfully!"); + println!("Next steps:"); + println!("1. Set your ElevenLabs API key: elevenlabs auth login"); + println!("2. Create an agent: elevenlabs agents add \"My Agent\" --template default"); + println!("3. Create tools: elevenlabs tools add \"My Webhook\" --type webhook"); + println!("4. Create tests: elevenlabs tests add \"My Test\" --template basic-llm"); + println!( + "5. Push to ElevenLabs: elevenlabs agents push && elevenlabs tools push && elevenlabs tests push" + ); + println!("6. Run tests: elevenlabs agents test \"My Agent\""); + println!("\nBranch workflow (CI/CD):"); + println!(" Pull all branches: elevenlabs agents pull --all --all-branches"); + println!(" Push all (main + branches): elevenlabs agents push"); +} + +/// Build a closure that maps an [`std::io::Error`] into a [`CliError`] +/// with a consistent, contextual message. +fn io_err(action: &str, path: &Path) -> impl FnOnce(std::io::Error) -> CliError { + let action = action.to_string(); + let path = path.display().to_string(); + move |e| CliError::Other(anyhow::anyhow!("Could not {action} {path}: {e}")) +} + +// ── list ──────────────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct NoArgs {} + +fn handle_list(_args: NoArgs, _ctx: &AppContext) -> Result<(), CliError> { + let config = require_agents()?; + if config.agents.is_empty() { + println!("No agents configured"); + return Ok(()); + } + println!("Configured Agents:"); + println!("{}", "=".repeat(50)); + for (i, agent) in config.agents.iter().enumerate() { + println!("{}. {}", i + 1, agent_display_name(&agent.config)); + println!(" ID: {}", agent.id.as_deref().unwrap_or("No ID")); + println!(" Config: {}", agent.config); + println!(); + } + Ok(()) +} + +// ── status ────────────────────────────────────────────────────────── + +fn handle_status(_args: NoArgs, _ctx: &AppContext) -> Result<(), CliError> { + let config = require_agents()?; + if config.agents.is_empty() { + println!("No agents configured"); + return Ok(()); + } + println!("Agent Status:"); + println!("{}", "=".repeat(50)); + for agent in &config.agents { + println!("\n{}", agent_display_name(&agent.config)); + println!(" Config: {}", agent.config); + println!( + " Agent ID: {}", + agent.id.as_deref().unwrap_or("Not created yet") + ); + if let Some(branch_id) = &agent.branch_id { + println!(" Branch ID: {branch_id}"); + } + if let Some(version_id) = &agent.version_id { + println!(" Version ID: {version_id}"); + } + let config_path = Path::new(&agent.config); + if !config_path.exists() { + println!(" Status: Config file not found"); + } else { + match project::read_value(config_path) { + Ok(_) if agent.id.is_some() => println!(" Status: Created (use push to update)"), + Ok(_) => println!(" Status: Not pushed yet"), + Err(e) => println!(" Status: Config error: {e}"), + } + } + } + Ok(()) +} + +// ── delete ────────────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct DeleteArgs { + /// The agent ID to delete (omit with --all). + agent_id: Option, + /// Delete every configured agent. + #[arg(long)] + all: bool, + /// Skip the confirmation prompt (for --all). + #[arg(long)] + yes: bool, +} + +fn handle_delete(args: DeleteArgs, ctx: &AppContext) -> Result<(), CliError> { + let mut config = require_agents()?; + + if args.all { + if config.agents.is_empty() { + println!("No agents found to delete"); + return Ok(()); + } + println!("\nFound {} agent(s) to delete:", config.agents.len()); + for (i, agent) in config.agents.iter().enumerate() { + println!( + " {}. {} ({})", + i + 1, + agent_display_name(&agent.config), + agent.id.as_deref().unwrap_or("no id") + ); + } + if !args.yes { + println!( + "\nWARNING: This will delete ALL agents from both local configuration and ElevenLabs." + ); + if !project::prompt_confirm("Are you sure you want to delete these agents?")? { + println!("Deletion cancelled"); + return Ok(()); + } + } + println!("\nDeleting agents...\n"); + for agent in &config.agents { + let name = agent_display_name(&agent.config); + println!( + "Deleting '{name}' ({})...", + agent.id.as_deref().unwrap_or("no id") + ); + match &agent.id { + Some(id) => match api::delete_agent(ctx, id) { + Ok(()) => println!(" ✓ Deleted from ElevenLabs"), + Err(e) => eprintln!(" Warning: Failed to delete from ElevenLabs: {e}"), + }, + None => println!(" Warning: No agent ID found, skipping ElevenLabs deletion"), + } + remove_config_file(&agent.config); + } + config.agents.clear(); + project::save_agents(&config)?; + println!("\n✓ Deleted all agents"); + return Ok(()); + } + + let Some(agent_id) = args.agent_id else { + return Err(CliError::Validation( + "Provide an agent ID to delete, or pass --all.".to_string(), + )); + }; + + let index = config + .agents + .iter() + .position(|a| a.id.as_deref() == Some(agent_id.as_str())) + .ok_or_else(|| { + CliError::Validation(format!( + "Agent with ID '{agent_id}' not found in local configuration" + )) + })?; + + let removed = config.agents.remove(index); + let name = agent_display_name(&removed.config); + println!("Deleting agent '{name}' (ID: {agent_id})..."); + println!("Deleting from ElevenLabs..."); + match api::delete_agent(ctx, &agent_id) { + Ok(()) => println!("✓ Successfully deleted from ElevenLabs"), + Err(e) => { + eprintln!("Warning: Failed to delete from ElevenLabs: {e}"); + println!("Continuing with local deletion..."); + } + } + project::save_agents(&config)?; + println!("✓ Removed '{name}' from agents.json"); + if remove_config_file(&removed.config) { + println!("✓ Deleted config file: {}", removed.config); + } + println!("\n✓ Successfully deleted agent '{name}'"); + Ok(()) +} + +/// Remove a config file if present; returns whether it was deleted. +fn remove_config_file(config_path: &str) -> bool { + let path = Path::new(config_path); + if path.exists() { + std::fs::remove_file(path).is_ok() + } else { + false + } +} + +// ── widget ────────────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct WidgetArgs { + /// The agent ID to generate a widget for. + agent_id: String, +} + +fn handle_widget(args: WidgetArgs, _ctx: &AppContext) -> Result<(), CliError> { + let config = require_agents()?; + let agent = config + .agents + .iter() + .find(|a| a.id.as_deref() == Some(args.agent_id.as_str())) + .ok_or_else(|| { + CliError::Validation(format!( + "Agent with ID '{}' not found in configuration", + args.agent_id + )) + })?; + + let residency = settings::read_residency(); + let mut html = format!("\n", + ); + + let name = agent_display_name(&agent.config); + println!("HTML Widget for agent '{name}' (residency: {residency}):"); + println!("{}", "=".repeat(60)); + println!("{html}"); + println!("{}", "=".repeat(60)); + println!("Agent ID: {}", args.agent_id); + Ok(()) +} + +// ── branches list ─────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct BranchesListArgs { + /// The agent whose branches to list. + #[arg(long)] + agent: String, + /// Include archived branches. + #[arg(long)] + include_archived: bool, +} + +fn handle_branches_list(args: BranchesListArgs, ctx: &AppContext) -> Result<(), CliError> { + println!("Listing branches for agent: {}...", args.agent); + let branches = api::list_branches(ctx, &args.agent, args.include_archived)?; + if branches.is_empty() { + println!("No branches found for this agent."); + return Ok(()); + } + + println!( + "{:<25}{:<40}{:<12}{:<10}LAST UPDATED", + "NAME", "BRANCH ID", "STATUS", "TRAFFIC" + ); + println!("{}", "─".repeat(110)); + for branch in &branches { + let raw_name = branch.get("name").and_then(Value::as_str).unwrap_or(""); + let name = if raw_name.chars().count() > 23 { + format!("{}...", raw_name.chars().take(20).collect::()) + } else { + raw_name.to_string() + }; + let id = branch.get("id").and_then(Value::as_str).unwrap_or(""); + let status = if branch + .get("is_archived") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "archived" + } else { + "active" + }; + let traffic = format!( + "{}%", + format_percent( + branch + .get("current_live_percentage") + .and_then(Value::as_f64) + .unwrap_or(0.0) + ) + ); + let last_updated = branch + .get("last_committed_at") + .and_then(Value::as_i64) + .map(epoch_to_date) + .unwrap_or_default(); + println!("{name:<25}{id:<40}{status:<12}{traffic:<10}{last_updated}"); + } + println!("\n{} branch(es) found", branches.len()); + Ok(()) +} + +/// Format a percentage dropping a trailing `.0` (12.0 → "12", 12.5 → "12.5"). +fn format_percent(n: f64) -> String { + if n.fract() == 0.0 { + format!("{}", n as i64) + } else { + format!("{n}") + } +} + +/// Convert a Unix timestamp (seconds) to `YYYY-MM-DD` (UTC), using the +/// standard civil-from-days algorithm (no external date dependency). +fn epoch_to_date(secs: i64) -> String { + let days = secs.div_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + format!("{year:04}-{month:02}-{day:02}") +} diff --git a/cli/elevenlabs/workflow/api.rs b/cli/elevenlabs/workflow/api.rs new file mode 100644 index 0000000..90f01ac --- /dev/null +++ b/cli/elevenlabs/workflow/api.rs @@ -0,0 +1,325 @@ +//! Thin API layer for the workflow commands — ports v0's +//! `src/shared/elevenlabs-api.ts`. +//! +//! All agent/tool/test **config** traffic goes through raw JSON +//! (`serde_json::Value`) via the CLI's authenticated executor, so configs +//! round-trip losslessly (the generated typed models drop unmodeled +//! fields — see the migration plan). Auth, base URL, retries, and TLS are +//! inherited from the CLI automatically. + +#![allow(dead_code)] + +use elevenlabs_sdk::RequestOptions; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use reqwest::Method; +use serde_json::{json, Value}; + +/// `X-Source` tag v0 attached to every request. +const X_SOURCE: &str = "agents-cli"; + +fn request_options() -> Option { + let mut opts = RequestOptions::new(); + opts.additional_headers + .insert("X-Source".to_string(), X_SOURCE.to_string()); + Some(opts) +} + +/// Perform a raw JSON request through the CLI's authenticated executor. +/// +/// Body and response are untyped [`Value`], so agent/tool/test configs +/// round-trip losslessly. Paths are relative (no leading slash), matching +/// the generated SDK's convention. +fn raw_request( + ctx: &AppContext, + method: Method, + path: &str, + body: Option, + query: Option>, +) -> Result { + let client = crate::sdk::client(ctx); + crate::sdk::block_on( + client + .conversational_ai + .http_client + .execute_request::(method, path, body, query, request_options()), + ) +} + +// ── Config cleaning ───────────────────────────────────────────────── + +/// Remove the deprecated `agent.prompt.tools` field when `tool_ids` +/// (or `toolIds`) is present — the API returns both but accepts only one. +/// Ports v0's `cleanConversationConfigForApi`. +pub fn clean_conversation_config(conversation_config: &mut Value) { + if let Some(agent) = conversation_config + .get_mut("agent") + .and_then(Value::as_object_mut) + { + if let Some(prompt) = agent.get_mut("prompt").and_then(Value::as_object_mut) { + if prompt.contains_key("tool_ids") || prompt.contains_key("toolIds") { + prompt.remove("tools"); + } + } + } +} + +/// Build the create/update request body from an on-disk agent config, +/// sending the fields verbatim (raw wire JSON). +fn build_agent_body(config: &Value, version_description: Option<&str>) -> Value { + let mut body = serde_json::Map::new(); + + if let Some(name) = config.get("name") { + body.insert("name".to_string(), name.clone()); + } + + // conversation_config is always sent (defaults to {}), cleaned. + let mut cc = config + .get("conversation_config") + .cloned() + .unwrap_or_else(|| json!({})); + clean_conversation_config(&mut cc); + body.insert("conversation_config".to_string(), cc); + + for key in ["platform_settings", "workflow", "tags"] { + if let Some(v) = config.get(key) { + body.insert(key.to_string(), v.clone()); + } + } + + if let Some(desc) = version_description { + body.insert("version_description".to_string(), json!(desc)); + } + + Value::Object(body) +} + +// ── Agent API ─────────────────────────────────────────────────────── + +/// Result of an agent update — mirrors the fields v0 threads back into +/// `agents.json`. +pub struct UpdateResult { + pub agent_id: String, + pub version_id: Option, + pub branch_id: Option, +} + +/// Create a new agent (raw JSON). Returns the new `agent_id`. +pub fn create_agent(ctx: &AppContext, config: &Value) -> Result { + let body = build_agent_body(config, None); + let resp = raw_request( + ctx, + Method::POST, + "v1/convai/agents/create", + Some(body), + None, + )?; + resp.get("agent_id") + .and_then(Value::as_str) + .map(String::from) + .ok_or_else(|| { + CliError::Other(anyhow::anyhow!( + "Agent create response did not contain an agent_id: {resp}" + )) + }) +} + +/// Update an existing agent (raw JSON), optionally targeting a branch. +pub fn update_agent( + ctx: &AppContext, + agent_id: &str, + config: &Value, + version_description: Option<&str>, + branch_id: Option<&str>, +) -> Result { + let body = build_agent_body(config, version_description); + let query = branch_id.map(|b| vec![("branch_id".to_string(), b.to_string())]); + let resp = raw_request( + ctx, + Method::PATCH, + &format!("v1/convai/agents/{agent_id}"), + Some(body), + query, + )?; + Ok(UpdateResult { + agent_id: resp + .get("agent_id") + .and_then(Value::as_str) + .unwrap_or(agent_id) + .to_string(), + version_id: resp + .get("version_id") + .and_then(Value::as_str) + .map(String::from), + branch_id: resp + .get("branch_id") + .and_then(Value::as_str) + .map(String::from), + }) +} + +/// Fetch an agent's full config (raw JSON), optionally from a branch. +pub fn get_agent( + ctx: &AppContext, + agent_id: &str, + branch_id: Option<&str>, +) -> Result { + let query = branch_id.map(|b| vec![("branch_id".to_string(), b.to_string())]); + raw_request( + ctx, + Method::GET, + &format!("v1/convai/agents/{agent_id}"), + None, + query, + ) +} + +/// List every agent's metadata, paginating to completion. +pub fn list_agents(ctx: &AppContext, search: Option<&str>) -> Result, CliError> { + let mut all = Vec::new(); + let mut cursor: Option = None; + loop { + let mut query = vec![("page_size".to_string(), "100".to_string())]; + if let Some(c) = &cursor { + query.push(("cursor".to_string(), c.clone())); + } + if let Some(s) = search { + query.push(("search".to_string(), s.to_string())); + } + let resp = raw_request(ctx, Method::GET, "v1/convai/agents", None, Some(query))?; + if let Some(agents) = resp.get("agents").and_then(Value::as_array) { + all.extend(agents.iter().cloned()); + } + if !resp.get("has_more").and_then(Value::as_bool).unwrap_or(false) { + break; + } + cursor = resp + .get("next_cursor") + .and_then(Value::as_str) + .map(String::from); + if cursor.is_none() { + break; + } + } + Ok(all) +} + +/// Delete an agent. Uses the typed SDK method (empty response body). +pub fn delete_agent(ctx: &AppContext, agent_id: &str) -> Result<(), CliError> { + let client = crate::sdk::client(ctx); + crate::sdk::block_on( + client + .conversational_ai + .agents + .delete(agent_id, request_options()), + ) +} + +/// List an agent's branches (raw JSON summaries). +pub fn list_branches( + ctx: &AppContext, + agent_id: &str, + include_archived: bool, +) -> Result, CliError> { + let query = vec![("include_archived".to_string(), include_archived.to_string())]; + let resp = raw_request( + ctx, + Method::GET, + &format!("v1/convai/agents/{agent_id}/branches"), + None, + Some(query), + )?; + Ok(resp + .get("results") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) +} + +/// Resolve a branch name or `agtbrch_`-prefixed id to a branch id. +/// Ports v0's `resolveBranchId`. +pub fn resolve_branch_id( + ctx: &AppContext, + agent_id: &str, + branch_name_or_id: &str, +) -> Result { + if branch_name_or_id.starts_with("agtbrch_") { + return Ok(branch_name_or_id.to_string()); + } + let branches = list_branches(ctx, agent_id, true)?; + for branch in &branches { + if branch.get("name").and_then(Value::as_str) == Some(branch_name_or_id) { + if let Some(id) = branch.get("id").and_then(Value::as_str) { + return Ok(id.to_string()); + } + } + } + Err(CliError::Validation(format!( + "Branch '{branch_name_or_id}' not found for agent '{agent_id}'. \ + Use 'elevenlabs agents branches list --agent {agent_id}' to see available branches." + ))) +} + +// ── Test-running API (for `agents test`) ──────────────────────────── + +/// Run the given tests on an agent. Returns the invocation (raw JSON). +pub fn run_tests_on_agent( + ctx: &AppContext, + agent_id: &str, + test_ids: &[String], + agent_config_override: Option<&Value>, +) -> Result { + let tests: Vec = test_ids.iter().map(|id| json!({ "test_id": id })).collect(); + let mut body = serde_json::Map::new(); + body.insert("tests".to_string(), Value::Array(tests)); + if let Some(over) = agent_config_override { + body.insert("agent_config_override".to_string(), over.clone()); + } + raw_request( + ctx, + Method::POST, + &format!("v1/convai/agents/{agent_id}/run-tests"), + Some(Value::Object(body)), + None, + ) +} + +/// Poll a single test invocation's current state (raw JSON). +pub fn get_test_invocation(ctx: &AppContext, invocation_id: &str) -> Result { + raw_request( + ctx, + Method::GET, + &format!("v1/convai/test-invocations/{invocation_id}"), + None, + None, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_removes_tools_when_tool_ids_present() { + let mut cc = json!({ + "agent": { "prompt": { "tool_ids": ["t1"], "tools": [{"name":"x"}] } } + }); + clean_conversation_config(&mut cc); + assert!(cc["agent"]["prompt"].get("tools").is_none()); + assert!(cc["agent"]["prompt"].get("tool_ids").is_some()); + } + + #[test] + fn clean_keeps_tools_when_no_tool_ids() { + let mut cc = json!({ "agent": { "prompt": { "tools": [{"name":"x"}] } } }); + clean_conversation_config(&mut cc); + assert!(cc["agent"]["prompt"].get("tools").is_some()); + } + + #[test] + fn build_body_defaults_conversation_config() { + let body = build_agent_body(&json!({ "name": "A" }), None); + assert_eq!(body["name"], json!("A")); + assert_eq!(body["conversation_config"], json!({})); + } +} diff --git a/cli/elevenlabs/workflow/components.rs b/cli/elevenlabs/workflow/components.rs new file mode 100644 index 0000000..fb4b469 --- /dev/null +++ b/cli/elevenlabs/workflow/components.rs @@ -0,0 +1,11 @@ +//! 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.) + +use fern_cli_sdk::app::CliApp; + +/// Register the `components` command group. +pub fn register(app: CliApp) -> CliApp { + app +} diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs new file mode 100644 index 0000000..818385a --- /dev/null +++ b/cli/elevenlabs/workflow/mod.rs @@ -0,0 +1,34 @@ +//! ElevenLabs "agents-as-code" workflow — hand-written custom commands +//! layered on top of the Fern-generated CLI. +//! +//! This module tree ports the bespoke workflow from the v0 (TypeScript) +//! CLI: local project files (`agents.json`/`tools.json`/`tests.json` + +//! `*_configs/` dirs) plus push/pull/verify/branch tooling that the raw +//! generated API commands do not provide. +//! +//! Everything here is protected from regeneration via `.fernignore`. +//! Each submodule exposes `register(app) -> CliApp`, and [`register`] +//! composes them. New top-level groups (`agents`, `tools`, `tests`) are +//! distinct from the generated `conversational-ai agents …` commands. + +use fern_cli_sdk::app::CliApp; + +mod agents; +mod api; +mod components; +mod project; +mod residency; +mod settings; +mod templates; +mod tests; +mod tools; + +/// Register every custom command group on the CLI app builder. +pub fn register(app: CliApp) -> CliApp { + let app = agents::register(app); + let app = templates::register(app); + let app = tools::register(app); + let app = tests::register(app); + let app = residency::register(app); + components::register(app) +} diff --git a/cli/elevenlabs/workflow/project.rs b/cli/elevenlabs/workflow/project.rs new file mode 100644 index 0000000..eed0963 --- /dev/null +++ b/cli/elevenlabs/workflow/project.rs @@ -0,0 +1,330 @@ +//! Local project model for the "agents-as-code" workflow. +//! +//! Ports v0's `src/shared/utils.ts` plus the `agents.json` / `tools.json` +//! / `tests.json` index-file schemas. Design notes: +//! +//! * Entity **configs** are stored as raw wire JSON (`serde_json::Value`) +//! and pushed verbatim, so they round-trip losslessly (see the +//! round-trip rationale in the migration plan — the generated typed +//! models drop unmodeled fields). +//! * The **index files** map entity name / id / branch metadata to the +//! on-disk config file path. Their `config` field is a *path*, not an +//! inline config — matching v0 exactly, so existing projects keep +//! working. +//! * Files are written pretty-printed with a 4-space indent to match v0's +//! `JSON.stringify(value, null, 4)` on-disk format. + +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use fern_cli_sdk::error::CliError; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ── Index-file locations ──────────────────────────────────────────── + +pub const AGENTS_FILE: &str = "agents.json"; +pub const TOOLS_FILE: &str = "tools.json"; +pub const TESTS_FILE: &str = "tests.json"; + +pub const AGENT_CONFIGS_DIR: &str = "agent_configs"; +pub const TOOL_CONFIGS_DIR: &str = "tool_configs"; +pub const TEST_CONFIGS_DIR: &str = "test_configs"; + +// ── Index-file schemas ────────────────────────────────────────────── + +/// `agents.json` — the agent registry. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentsConfig { + #[serde(default)] + pub agents: Vec, +} + +/// One entry in `agents.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentDefinition { + /// Path to the agent's config file, relative to the project root. + pub config: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_id: Option, + /// Per-branch configs, keyed by branch name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branches: Option>, +} + +/// A branch entry under an [`AgentDefinition`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchDefinition { + /// Path to this branch's config file, relative to the project root. + pub config: String, + pub branch_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_id: Option, +} + +/// `tools.json` — the tool registry. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ToolsConfig { + #[serde(default)] + pub tools: Vec, +} + +/// One entry in `tools.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDefinition { + /// `"webhook"` or `"client"`. + #[serde(rename = "type")] + pub tool_type: String, + /// Path to the tool's config file, relative to the project root. + pub config: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +/// `tests.json` — the test registry. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TestsConfig { + #[serde(default)] + pub tests: Vec, +} + +/// One entry in `tests.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestDefinition { + /// Path to the test's config file, relative to the project root. + pub config: String, + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub test_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +// ── JSON IO ───────────────────────────────────────────────────────── + +/// Read and deserialize a JSON file, with v0-style error messages. +pub fn read_json(path: &Path) -> Result +where + T: serde::de::DeserializeOwned, +{ + let data = std::fs::read_to_string(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CliError::Validation(format!( + "Configuration file not found at {}", + path.display() + )) + } else { + CliError::Other(anyhow::anyhow!("Could not read {}: {e}", path.display())) + } + })?; + serde_json::from_str(&data).map_err(|e| { + CliError::Validation(format!( + "Invalid JSON in configuration file {}: {e}", + path.display() + )) + }) +} + +/// Read a config file as an untyped [`Value`] (lossless). +pub fn read_value(path: &Path) -> Result { + read_json::(path) +} + +/// Serialize with a 4-space indent to match v0's on-disk format. +pub fn to_pretty_string(value: &T) -> Result { + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + value + .serialize(&mut ser) + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not serialize JSON: {e}")))?; + String::from_utf8(buf) + .map_err(|e| CliError::Other(anyhow::anyhow!("Serialized JSON was not valid UTF-8: {e}"))) +} + +/// Write a value as pretty JSON, creating parent directories as needed. +pub fn write_json(path: &Path, value: &T) -> Result<(), CliError> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "Could not create directory {}: {e}", + parent.display() + )) + })?; + } + } + let rendered = to_pretty_string(value)?; + std::fs::write(path, rendered).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "Could not write configuration file to {}: {e}", + path.display() + )) + }) +} + +// ── Filename generation ───────────────────────────────────────────── + +/// Sanitize a user-provided entity name into a filesystem-safe stem. +/// +/// Ports v0's `sanitizeFilename`. This is the primary defense against +/// path traversal: separators and other unsafe characters are replaced +/// with hyphens, leading dots are neutralized, and the length is capped. +pub fn sanitize_filename(name: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + return "unnamed".to_string(); + } + + let starts_with_dot = trimmed.starts_with('.'); + + // Replace filesystem-unsafe characters and whitespace with hyphens. + let mut sanitized: String = trimmed + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + c if c.is_whitespace() => '-', + c => c, + }) + .collect(); + + // Collapse runs of hyphens into one. + while sanitized.contains("--") { + sanitized = sanitized.replace("--", "-"); + } + + // Strip leading/trailing dots and hyphens. + let stripped = sanitized.trim_matches(|c| c == '.' || c == '-').to_string(); + if stripped.is_empty() { + return "unnamed".to_string(); + } + + let mut result = if starts_with_dot { + format!("_{stripped}") + } else { + stripped + }; + + // Cap length to avoid absurdly long filenames. + if result.chars().count() > 100 { + result = result.chars().take(100).collect(); + } + + result +} + +/// Build a collision-free path `/`, appending +/// `-1`, `-2`, … if files already exist. Ports v0's +/// `generateUniqueFilename`. +pub fn generate_unique_filename(dir: &str, entity_name: &str, ext: &str) -> PathBuf { + let sanitized = sanitize_filename(entity_name); + let base = PathBuf::from(dir); + + let candidate = base.join(format!("{sanitized}{ext}")); + if !candidate.exists() { + return candidate; + } + + let mut counter = 1; + loop { + let candidate = base.join(format!("{sanitized}-{counter}{ext}")); + if !candidate.exists() { + return candidate; + } + counter += 1; + } +} + +// ── Registry load/save convenience ────────────────────────────────── + +pub fn agents_path() -> PathBuf { + PathBuf::from(AGENTS_FILE) +} +pub fn tools_path() -> PathBuf { + PathBuf::from(TOOLS_FILE) +} +pub fn tests_path() -> PathBuf { + PathBuf::from(TESTS_FILE) +} + +pub fn load_agents() -> Result { + read_json(&agents_path()) +} +pub fn save_agents(config: &AgentsConfig) -> Result<(), CliError> { + write_json(&agents_path(), config) +} + +pub fn load_tools() -> Result { + read_json(&tools_path()) +} +pub fn save_tools(config: &ToolsConfig) -> Result<(), CliError> { + write_json(&tools_path(), config) +} + +pub fn load_tests() -> Result { + read_json(&tests_path()) +} +pub fn save_tests(config: &TestsConfig) -> Result<(), CliError> { + write_json(&tests_path(), config) +} + +// ── Interactive prompt ────────────────────────────────────────────── + +/// Ask a `y/N` question on stdin. Ports v0's `promptForConfirmation`: +/// only an explicit `y`/`yes` (case-insensitive) counts as yes, so a +/// non-interactive/empty stdin safely defaults to no. +pub fn prompt_confirm(message: &str) -> Result { + use std::io::Write; + print!("{message} (y/N): "); + std::io::stdout().flush().ok(); + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not read confirmation input: {e}")))?; + let answer = input.trim().to_lowercase(); + Ok(answer == "y" || answer == "yes") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_replaces_separators_and_traversal() { + // Leading dot → "_" prefix (matches v0); crucially, no path + // separators survive, so traversal is neutralized either way. + assert_eq!(sanitize_filename("../../etc/passwd"), "_etc-passwd"); + // Interior dots are kept (harmless — no separators remain). + assert_eq!(sanitize_filename("foo/../bar"), "foo-..-bar"); + assert_eq!(sanitize_filename("a/b\\c:d"), "a-b-c-d"); + assert_eq!(sanitize_filename("My Agent"), "My-Agent"); + assert_eq!(sanitize_filename(" spaced out "), "spaced-out"); + } + + #[test] + fn sanitize_handles_dots_and_empties() { + assert_eq!(sanitize_filename(""), "unnamed"); + assert_eq!(sanitize_filename(" "), "unnamed"); + assert_eq!(sanitize_filename("..."), "unnamed"); + assert_eq!(sanitize_filename(".hidden"), "_hidden"); + assert_eq!(sanitize_filename("--dashes--"), "dashes"); + } + + #[test] + fn sanitize_caps_length() { + let long = "a".repeat(250); + assert_eq!(sanitize_filename(&long).chars().count(), 100); + } + + #[test] + fn pretty_string_uses_four_space_indent() { + let v = serde_json::json!({ "a": { "b": 1 } }); + let s = to_pretty_string(&v).unwrap(); + assert!(s.contains("\n \"a\""), "expected 4-space indent, got:\n{s}"); + } +} diff --git a/cli/elevenlabs/workflow/residency.rs b/cli/elevenlabs/workflow/residency.rs new file mode 100644 index 0000000..25503dc --- /dev/null +++ b/cli/elevenlabs/workflow/residency.rs @@ -0,0 +1,11 @@ +//! The `residency` command: get/set the data-residency region, which +//! maps to the API base URL. Ports v0's `auth residency`. +//! +//! (Scaffold — command handler lands in a later step.) + +use fern_cli_sdk::app::CliApp; + +/// Register the `residency` command. +pub fn register(app: CliApp) -> CliApp { + app +} diff --git a/cli/elevenlabs/workflow/settings.rs b/cli/elevenlabs/workflow/settings.rs new file mode 100644 index 0000000..093fee1 --- /dev/null +++ b/cli/elevenlabs/workflow/settings.rs @@ -0,0 +1,81 @@ +//! User-level settings stored in `~/.elevenlabs/config.json` — currently +//! just data residency. Ports the residency half of v0's +//! `src/shared/config.ts`. +//! +//! API-key storage is deliberately NOT handled here: v1 delegates +//! credentials to the framework's keyring/env (`ELEVENLABS_API_KEY`), so +//! this file only ever holds non-sensitive settings. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use fern_cli_sdk::error::CliError; +use serde_json::Value; + +/// Accepted residency values (matches v0's `LOCATIONS`). +pub const RESIDENCY_VALUES: &[&str] = + &["us", "global", "eu-residency", "in-residency", "sg-residency"]; + +pub const DEFAULT_RESIDENCY: &str = "global"; + +fn config_dir() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".elevenlabs")) +} + +fn config_file() -> Option { + config_dir().map(|dir| dir.join("config.json")) +} + +/// Read the configured residency, defaulting to `global` on any problem +/// (missing file, unreadable, malformed) — matching v0's lenient default. +pub fn read_residency() -> String { + let Some(path) = config_file() else { + return DEFAULT_RESIDENCY.to_string(); + }; + let Ok(data) = std::fs::read_to_string(&path) else { + return DEFAULT_RESIDENCY.to_string(); + }; + serde_json::from_str::(&data) + .ok() + .and_then(|v| { + v.get("residency") + .and_then(Value::as_str) + .map(String::from) + }) + .unwrap_or_else(|| DEFAULT_RESIDENCY.to_string()) +} + +/// Persist the residency, preserving any other keys and never writing an +/// API key into the config file (mirrors v0's `saveConfig`). +pub fn write_residency(residency: &str) -> Result<(), CliError> { + let dir = config_dir() + .ok_or_else(|| CliError::Other(anyhow::anyhow!("Could not determine home directory")))?; + std::fs::create_dir_all(&dir) + .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) + .ok() + .and_then(|d| serde_json::from_str::(&d).ok()) + .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()))) +} + +/// Map a residency to its API base URL. Ports v0's `getApiBaseUrl`. +pub fn base_url_for(residency: &str) -> &'static str { + match residency { + "eu-residency" => "https://api.eu.residency.elevenlabs.io", + "in-residency" => "https://api.in.residency.elevenlabs.io", + "sg-residency" => "https://api.sg.residency.elevenlabs.io", + "us" => "https://api.us.elevenlabs.io", + // "global" and anything unrecognized + _ => "https://api.elevenlabs.io", + } +} diff --git a/cli/elevenlabs/workflow/templates.rs b/cli/elevenlabs/workflow/templates.rs new file mode 100644 index 0000000..3ed0715 --- /dev/null +++ b/cli/elevenlabs/workflow/templates.rs @@ -0,0 +1,52 @@ +//! Built-in agent templates and the `agents templates` command group. +//! +//! Ports v0's `src/agents/templates.ts`. For now this exposes the +//! template catalog (`list`); full template bodies and `show` land with +//! the rest of the `agents` group. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; + +/// `(name, description)` for every built-in agent template, in display +/// order. Mirrors v0's `getTemplateOptions()`. +pub const TEMPLATE_OPTIONS: &[(&str, &str)] = &[ + ( + "default", + "Complete configuration with all available fields and sensible defaults", + ), + ("minimal", "Minimal configuration with only essential fields"), + ("voice-only", "Optimized for voice-only conversations"), + ("text-only", "Optimized for text-only conversations"), + ( + "customer-service", + "Pre-configured for customer service scenarios", + ), + ("assistant", "General purpose AI assistant configuration"), +]; + +#[derive(clap::Args)] +struct TemplatesListArgs {} + +fn handle_list(_args: TemplatesListArgs, _ctx: &AppContext) -> Result<(), CliError> { + println!("Available agent templates:"); + println!("{}", "=".repeat(50)); + for (name, description) in TEMPLATE_OPTIONS { + println!("\n{name}"); + println!(" {description}"); + } + println!( + "\nUse 'elevenlabs agents add --template ' \ + to create an agent with a specific template" + ); + Ok(()) +} + +/// Register the `agents templates` command group. +pub fn register(app: CliApp) -> CliApp { + app.command_under_typed_with( + &["agents", "templates"], + clap::Command::new("list").about("List available agent templates"), + handle_list, + ) +} diff --git a/cli/elevenlabs/workflow/tests.rs b/cli/elevenlabs/workflow/tests.rs new file mode 100644 index 0000000..5caae4c --- /dev/null +++ b/cli/elevenlabs/workflow/tests.rs @@ -0,0 +1,12 @@ +//! The `tests` command group: add/delete/push/pull, with push-time +//! auto-discovery of untracked test config files. Ports v0's +//! `src/tests/`. +//! +//! (Scaffold — command handlers land in a later step.) + +use fern_cli_sdk::app::CliApp; + +/// Register the `tests` command group. +pub fn register(app: CliApp) -> CliApp { + app +} diff --git a/cli/elevenlabs/workflow/tools.rs b/cli/elevenlabs/workflow/tools.rs new file mode 100644 index 0000000..39926eb --- /dev/null +++ b/cli/elevenlabs/workflow/tools.rs @@ -0,0 +1,11 @@ +//! The `tools` command group: add/delete/push/pull. Ports v0's +//! `src/tools/`. +//! +//! (Scaffold — command handlers land in a later step.) + +use fern_cli_sdk::app::CliApp; + +/// Register the `tools` command group. +pub fn register(app: CliApp) -> CliApp { + app +}