From f5070f88fc280e212e97bb118b6754bedf42128c Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Thu, 3 Sep 2026 09:11:39 +0200 Subject: [PATCH 1/4] Feedback and intent headers --- README.md | 39 ++ cli/elevenlabs/workflow/api.rs | 10 +- cli/elevenlabs/workflow/feedback.rs | 165 +++++++ cli/elevenlabs/workflow/intent.rs | 697 ++++++++++++++++++++++++++++ cli/elevenlabs/workflow/mod.rs | 7 + 5 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 cli/elevenlabs/workflow/feedback.rs create mode 100644 cli/elevenlabs/workflow/intent.rs diff --git a/README.md b/README.md index 43c1658..23e08e5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The CLI does two things: - [Advanced](#advanced) - [Common flags](#common-flags) - [Environment variables](#environment-variables) + - [Telling us what you are doing](#telling-us-what-you-are-doing) - [Output formats](#output-formats) - [Shell completion](#shell-completion) - [Development](#development) @@ -283,6 +284,7 @@ These flags are available on every operation: | `--page-all` | Auto-paginate and stream results as NDJSON | | `--page-limit ` | Max pages to fetch when auto-paginating (default `10`) | | `-q, --quiet` | Suppress stdout output on success (errors still go to stderr) | +| `--intent ` | Optional one-sentence description of what you are trying to do (see [Telling us what you are doing](#telling-us-what-you-are-doing)) | ### Environment variables @@ -293,9 +295,46 @@ These flags are available on every operation: | `ELEVENLABS_INSECURE=1` | Skip TLS verification (debugging only) | | `ELEVENLABS_PROXY` | HTTP(S) proxy URL | | `ELEVENLABS_TIMEOUT_SECS` | Total request timeout in seconds | +| `ELEVENLABS_AGENT_INTENT` | Default value for `--intent`, applied to every command | Standard environment variables (`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` / `SSL_CERT_FILE`) are also honored. +### Telling us what you are doing + +Most `elevenlabs` traffic comes from AI agents. Two optional inputs let an agent +say what it is doing and what it could not do, which is what tells us which +commands to build next. Both are opt-in; the CLI behaves identically without them. + +**`--intent`** — why this command is running. Sent as an `X-Agent-Intent` +request header. + +```bash +elevenlabs voices search --intent "pick a narrator voice for an audiobook" + +# Or set it once for a whole task, so every command inherits it: +export ELEVENLABS_AGENT_INTENT="migrate the support bot to eleven_turbo_v2" +``` + +**`elevenlabs feedback missing-capability`** — you needed something the CLI does +not do. There is no request to attach that to, so it gets its own command: + +```bash +elevenlabs feedback missing-capability \ + "no way to batch-render a script to separate files per speaker" +``` + +#### Never put personal data in either field + +Describe the *goal*, not the data. No names, email addresses, phone numbers, API +keys, absolute file paths, or customer content. Resource ids (`agent_01jz…`) and +project-relative paths are fine. + +The CLI enforces this rather than trusting it. A value longer than 500 +characters, or one that looks like it contains personal data, is dropped before +the request is built — `--intent` warns on stderr and the command proceeds +normally, while `feedback` fails so you can rewrite it. Free text is also +withheld server-side for zero-retention and enterprise workspaces. + ### Output formats Use the global `--format` flag to control output. Supported values: `json` (default), `table`, `yaml`, `csv`. diff --git a/cli/elevenlabs/workflow/api.rs b/cli/elevenlabs/workflow/api.rs index ba4fce9..8bb5f4a 100644 --- a/cli/elevenlabs/workflow/api.rs +++ b/cli/elevenlabs/workflow/api.rs @@ -108,6 +108,14 @@ fn request_options() -> Option { let mut opts = RequestOptions::new(); opts.additional_headers .insert("X-Source".to_string(), X_SOURCE.to_string()); + // The generated ` ` commands get this header from the + // framework's global-parameter injection; hand-written commands go + // through the SDK executor instead, which that injection does not + // reach, so add it here. Already sanitised and percent-encoded. + if let Some(encoded) = super::intent::resolved_encoded() { + opts.additional_headers + .insert(super::intent::INTENT_HEADER.to_string(), encoded); + } Some(opts) } @@ -156,7 +164,7 @@ fn api_error_message(body: &Value) -> String { /// a 401 surfaced as "No agents found in your ElevenLabs workspace" with exit /// code 0 — a wrong answer reported as success. `execute_request_raw` hands /// back the status alongside the body, so we can judge it here. -fn raw_request( +pub(super) fn raw_request( ctx: &AppContext, method: Method, path: &str, diff --git a/cli/elevenlabs/workflow/feedback.rs b/cli/elevenlabs/workflow/feedback.rs new file mode 100644 index 0000000..c65aea2 --- /dev/null +++ b/cli/elevenlabs/workflow/feedback.rs @@ -0,0 +1,165 @@ +//! The `feedback` command group: a channel for agents to report what the +//! CLI cannot do. +//! +//! [`super::intent`] answers "why is this command running", but the more +//! valuable signal is the one that produces no request at all — an agent +//! looked for a command, found none, and gave up. Nothing in the request +//! log can show that. The hosted MCP solves it with a virtual +//! `get_more_tools` tool; this is the CLI's version of that tool. +//! +//! Unlike the intent header, a rejected value here is an error rather than +//! a warning: the text *is* the payload, so dropping it silently would +//! report success for a command that did nothing. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use reqwest::Method; +use serde_json::{json, Value}; + +use super::util::{downcast_ctx, dry_run_flag}; +use super::{api, intent}; + +/// First-party endpoint for CLI feedback. Deliberately absent from the +/// public OpenAPI spec — it is reached through [`api::raw_request`] and is +/// not an API we want the SDKs to generate clients for. Relative, matching +/// the generated SDK's convention. +const FEEDBACK_PATH: &str = "v1/cli/feedback"; + +/// Mirrors the MCP's `get_more_tools` description, which is the wording +/// that demonstrably gets agents to call it, plus the PII sentence the MCP +/// version lacks. +const LONG_ABOUT: &str = "\ +Call this when the user's request cannot be completed with any available \ +elevenlabs command. Describe the capability you were looking for, so it can \ +inform which commands get built next. Do not call it when an existing command \ +already covers the request. + +Never include names, email addresses, phone numbers, API keys, file paths, or \ +any other personal or customer data — describe the capability, not the data. \ +A description that looks like it contains personal data is rejected."; + +/// Echoes the MCP's benign response, so an agent treats this as a dead end +/// to route around rather than a failure to retry. +const RECORDED_MESSAGE: &str = "Recorded. No command exists for this yet — continue with the \ +available commands, or tell the user this is not supported."; + +/// Assemble the report body. Pure, so the shape is testable without a +/// live `AppContext`. +fn report_body(capability: &str, intent: Option) -> Value { + let mut body = json!({ + "kind": "missing_capability", + "capability": capability, + "cli_version": env!("CARGO_PKG_VERSION"), + "command": "feedback.missing_capability", + }); + // Plain text, not the percent-encoded header form — this is a JSON body. + if let Some(text) = intent { + body["intent"] = json!(text); + } + body +} + +fn handle_missing_capability( + matches: &clap::ArgMatches, + ctx: &AppContext, +) -> Result<(), CliError> { + let _scope = api::command_scope("feedback.missing_capability"); + + let raw = matches + .get_one::("capability") + .expect("capability is a required positional"); + + let capability = intent::sanitize(raw).map_err(|reason| { + CliError::Validation(format!( + "Capability description rejected because {reason}. Rewrite it and try again." + )) + })?; + + let body = report_body(&capability, intent::resolved_text()); + + // Honour the framework's global --dry-run. Without this an agent probing + // the command with --dry-run would file a real report. + if dry_run_flag(matches) { + println!("[DRY RUN] Would report missing capability: {capability}"); + if let Some(text) = body.get("intent").and_then(Value::as_str) { + println!(" [DRY RUN] With intent: {text}"); + } + return Ok(()); + } + + api::raw_request(ctx, Method::POST, FEEDBACK_PATH, Some(body), None)?; + println!("{RECORDED_MESSAGE}"); + Ok(()) +} + +/// Register the `feedback` command group. +/// +/// Registered untyped so the handler can read the framework's global +/// `--dry-run`, which the typed form does not surface — the same reason +/// `tools push` and `agents pull` use this form. +pub fn register(app: CliApp) -> CliApp { + app.command_under( + &["feedback"], + clap::Command::new("missing-capability") + .about("Report that a task could not be completed with any available command") + .long_about(LONG_ABOUT) + .arg( + clap::Arg::new("capability") + .required(true) + .help( + "What you were trying to accomplish that the available commands \ + could not do. One or two sentences, max 500 characters, no \ + personal data.", + ), + ), + Box::new(|matches, ctx| handle_missing_capability(matches, downcast_ctx(ctx)?)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_long_about_names_the_pii_rule() { + // The wording is the whole mechanism — an agent reads this and + // nothing else before deciding what to send. + assert!(LONG_ABOUT.contains("cannot be completed")); + assert!(LONG_ABOUT.contains("Never include names")); + assert!(LONG_ABOUT.contains("Do not call it when an existing command")); + } + + #[test] + fn a_capability_with_personal_data_is_refused_before_any_request() { + // Rejection has to happen client-side — the request would otherwise + // carry the data to the server, which is the thing being prevented. + assert!(intent::sanitize("import +1 555 010 9999 as an outbound number").is_err()); + assert!(intent::sanitize("no way to batch-render per speaker").is_ok()); + } + + #[test] + fn the_body_carries_the_capability_and_omits_an_absent_intent() { + let body = report_body("cannot batch-render per speaker", None); + assert_eq!(body["kind"], "missing_capability"); + assert_eq!(body["capability"], "cannot batch-render per speaker"); + assert_eq!(body["command"], "feedback.missing_capability"); + assert_eq!(body["cli_version"], env!("CARGO_PKG_VERSION")); + assert!(body.get("intent").is_none(), "absent intent must be omitted"); + } + + #[test] + fn the_body_includes_the_intent_as_plain_text() { + // Percent-encoding is for the header only; encoding it here would + // land escaped text in the analytics store. + let body = report_body("no accent picker", Some("gerar diálogo".to_string())); + assert_eq!(body["intent"], "gerar diálogo"); + } + + #[test] + fn the_endpoint_path_is_relative() { + // `raw_request` builds on the SDK's convention; a leading slash + // produces a doubled path. + assert!(!FEEDBACK_PATH.starts_with('/')); + } +} diff --git a/cli/elevenlabs/workflow/intent.rs b/cli/elevenlabs/workflow/intent.rs new file mode 100644 index 0000000..105eeb7 --- /dev/null +++ b/cli/elevenlabs/workflow/intent.rs @@ -0,0 +1,697 @@ +//! Optional agent-supplied intent, carried on every request as +//! `X-Agent-Intent`. +//! +//! The CLI is driven mostly by AI agents. We already know *which* command +//! ran (`cmd/agents.push` in the User-Agent — see [`super::api::command_scope`]) +//! but never *why*. The hosted MCP server answers that by injecting a +//! `context` argument into every advertised tool schema; this is the CLI's +//! equivalent, minus the ability to make it required. +//! +//! ## Why the flag never reaches the wire +//! +//! The framework injects a [`GlobalParameter`]'s value **verbatim** at the +//! configured wire location — there is no validation hook anywhere between +//! clap and the request. So `--intent` is registered with +//! [`GlobalParameterApplyMode::Explicit`] and a target no operation opts +//! into, which means the framework accepts the flag but never sends it. A +//! second, hidden, env-only parameter carries the value that *is* sent, and +//! the only writer of that env var is [`resolve`] — which runs before clap +//! parses anything and refuses to write a value that looks like personal +//! data. +//! +//! Dropping is deliberately silent-ish: a rejected intent warns on stderr +//! and the command proceeds normally. Telemetry must never be able to fail +//! a user's request. The `feedback` command is the one exception (see +//! [`super::feedback`]) — there the text *is* the payload, so a rejection +//! is an error. + +use std::sync::OnceLock; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, +}; +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; + +/// Header carrying the sanitised, percent-encoded intent. +pub const INTENT_HEADER: &str = "X-Agent-Intent"; + +/// Env var an agent sets to describe its goal once for a whole task. +const INTENT_ENV: &str = "ELEVENLABS_AGENT_INTENT"; + +/// Internal env var holding the sanitised, encoded value. Written only by +/// [`resolve`]; read by the framework through the hidden global parameter. +const CHECKED_ENV: &str = "ELEVENLABS_AGENT_INTENT_CHECKED"; + +/// Matches `xi_mcp`'s `_INTENT_MAX_LENGTH`, so the CLI and the MCP agree on +/// what "briefly" means and the backend's cap never has to truncate ours. +const MAX_CHARS: usize = 500; + +/// Backstop on the encoded form. 500 characters of ASCII encode to ~500 +/// bytes; 500 characters of CJK to ~4500. Beyond this we are into territory +/// where intermediate proxies start rejecting header sizes, and a request +/// that fails because of telemetry is the one outcome this module must not +/// produce. +const MAX_ENCODED_BYTES: usize = 4096; + +/// Escape controls plus `%` itself, so the encoding round-trips. Non-ASCII +/// bytes are not expressible in an `AsciiSet` at all — `utf8_percent_encode` +/// always escapes them, which is what carries the non-English intents +/// through: header values are ASCII-only in practice, and a large slice of +/// what the MCP collects is not English. +const HEADER_ESCAPE: &AsciiSet = &CONTROLS.add(b'%'); + +/// The resolved intent for this process: `None` when absent or dropped. +/// `(plain, encoded)` — the JSON-bodied `feedback` command wants the former, +/// the header path the latter. +static RESOLVED: OnceLock> = OnceLock::new(); + +/// Why a value was refused. Each variant produces its own warning so the +/// agent learns what to change rather than just that "something" was wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DropReason { + Empty, + TooLong, + ControlChars, + Email, + Phone, + Secret, + Path, + UrlCredentials, +} + +impl DropReason { + /// Agent-facing explanation. Phrased as an instruction, not a complaint — + /// the reader is a model deciding what to send next time. + pub fn advice(self) -> &'static str { + match self { + DropReason::Empty => "it is empty", + DropReason::TooLong => { + "it is longer than 500 characters — describe the goal in one sentence" + } + DropReason::ControlChars => "it contains line breaks or control characters", + DropReason::Email => { + "it looks like it contains an email address — describe the goal, not the data" + } + DropReason::Phone => { + "it looks like it contains a phone number — describe the goal, not the data" + } + DropReason::Secret => { + "it looks like it contains an API key or token — never include credentials" + } + DropReason::Path => { + "it looks like it contains an absolute file path — describe the goal, not the data" + } + DropReason::UrlCredentials => { + "it looks like it contains a URL with embedded credentials" + } + } + } +} + +impl std::fmt::Display for DropReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.advice()) + } +} + +// ── Sanitising ────────────────────────────────────────────────────── + +/// Validate agent-supplied free text, returning the trimmed value. +/// +/// Pure: no env, no I/O. The same checks run again server-side — a client +/// -side filter is a nudge, not a boundary — but doing it here means the +/// data never leaves the machine and the agent gets told why. +pub fn sanitize(raw: &str) -> Result { + let text = raw.trim(); + if text.is_empty() { + return Err(DropReason::Empty); + } + if text.chars().count() > MAX_CHARS { + return Err(DropReason::TooLong); + } + if text.chars().any(char::is_control) { + return Err(DropReason::ControlChars); + } + if contains_email(text) { + return Err(DropReason::Email); + } + if contains_url_credentials(text) { + return Err(DropReason::UrlCredentials); + } + if contains_secret(text) { + return Err(DropReason::Secret); + } + if contains_absolute_path(text) { + return Err(DropReason::Path); + } + if contains_phone(text) { + return Err(DropReason::Phone); + } + Ok(text.to_string()) +} + +/// Percent-encode a sanitised value for use as a header. Separate from +/// [`sanitize`] because `feedback` sends its text in a JSON body, where +/// encoding would be wrong. +pub fn encode_header(clean: &str) -> Result { + let encoded = utf8_percent_encode(clean, HEADER_ESCAPE).to_string(); + if encoded.len() > MAX_ENCODED_BYTES { + return Err(DropReason::TooLong); + } + Ok(encoded) +} + +/// `local@domain.tld` inside any whitespace-delimited token. +fn contains_email(text: &str) -> bool { + text.split_whitespace().any(|token| { + // Strip punctuation an agent would naturally write around it. + let token = token.trim_matches(|c: char| matches!(c, '(' | ')' | '<' | '>' | ',' | ';' | '"' | '\'')); + let Some((local, domain)) = token.split_once('@') else { + return false; + }; + if local.is_empty() || !local.chars().any(|c| c.is_ascii_alphanumeric()) { + return false; + } + let domain = domain.trim_end_matches('.'); + let Some((host, tld)) = domain.rsplit_once('.') else { + return false; + }; + !host.is_empty() && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()) + }) +} + +/// `scheme://user:pass@host`. +fn contains_url_credentials(text: &str) -> bool { + let mut rest = text; + while let Some(idx) = rest.find("://") { + let after = &rest[idx + 3..]; + let authority_end = after + .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace()) + .unwrap_or(after.len()); + let authority = &after[..authority_end]; + if let Some((userinfo, _)) = authority.split_once('@') { + if userinfo.contains(':') && !userinfo.is_empty() { + return true; + } + } + rest = &after[authority_end..]; + } + false +} + +/// Credential markers, plus a length backstop for anything key-shaped that +/// does not carry a known prefix. The threshold is 40 so it clears +/// ElevenLabs resource ids (`agent_01jz…`, ~24-32 chars), which are useful +/// context and not secrets. +fn contains_secret(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + const MARKERS: &[&str] = &[ + "sk_", + "xi-api-key", + "xi_api_key", + "bearer ", + "api_key=", + "apikey=", + "access_token", + "password", + "secret_", + "-----begin", + ]; + if MARKERS.iter().any(|m| lower.contains(m)) { + return true; + } + text.split(|c: char| !is_token_char(c)).any(|run| { + run.len() >= 40 + && run.chars().any(|c| c.is_ascii_digit()) + && run.chars().any(|c| c.is_ascii_alphabetic()) + }) +} + +fn is_token_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-') +} + +/// Absolute filesystem paths. Relative ones (`agent_configs/x.json`) are +/// left alone — they are project structure, not personal data. +fn contains_absolute_path(text: &str) -> bool { + const UNIX_PREFIXES: &[&str] = &["/Users/", "/home/", "/root/", "/var/folders/"]; + if UNIX_PREFIXES.iter().any(|p| text.contains(p)) { + return true; + } + // Windows drive paths: a *standalone* letter, a colon, then a separator. + // The "standalone" part is load-bearing — without it every `https://` + // matches, since `s:/` has the same shape. + let bytes = text.as_bytes(); + (1..bytes.len().saturating_sub(1)).any(|i| { + bytes[i] == b':' + && bytes[i - 1].is_ascii_alphabetic() + && (bytes[i + 1] == b'\\' || bytes[i + 1] == b'/') + // Nothing alphanumeric before the drive letter. `i < 2` is the + // case where the letter starts the string. + && (i < 2 || !bytes[i - 2].is_ascii_alphanumeric()) + }) +} + +/// Phone-shaped digit runs. Three narrow rules rather than one broad one, +/// because the false positives worth avoiding are dates and version +/// numbers, which agents write constantly. +fn contains_phone(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + + // (a) E.164: `+` then at least 10 digits, ignoring spaces/dashes/parens. + for (i, c) in chars.iter().enumerate() { + if *c != '+' { + continue; + } + let mut digits = 0usize; + for c in &chars[i + 1..] { + if c.is_ascii_digit() { + digits += 1; + } else if matches!(c, ' ' | '-' | '(' | ')' | '.') { + continue; + } else { + break; + } + } + if digits >= 10 { + return true; + } + } + + // (b) A bare run of 9+ digits. Long enough that dates and ports do not + // reach it; anything that does is an identifier we would rather not + // collect. + let mut run = 0usize; + for c in &chars { + if c.is_ascii_digit() { + run += 1; + if run >= 9 { + return true; + } + } else { + run = 0; + } + } + + // (c) Separated shapes like `555-010-9999` or `(555) 010 9999`: 10+ + // digits joined only by phone separators. `:` and `/` break the run, + // which is what keeps `2026-08-25 14:30` and `25/08/2026` out. + let mut digits = 0usize; + for c in &chars { + if c.is_ascii_digit() { + digits += 1; + if digits >= 10 { + return true; + } + } else if matches!(c, ' ' | '-' | '(' | ')' | '.') { + continue; + } else { + digits = 0; + } + } + + false +} + +// ── Resolution ────────────────────────────────────────────────────── + +/// Pull the raw intent out of argv, mirroring clap's last-wins semantics +/// for a `Set` argument. Stops at `--`, after which nothing is a flag. +/// +/// Pre-parse scanning is unavoidable: the generated ` ` +/// commands never call into this crate, so there is no post-parse hook that +/// runs for them. +fn scan_argv>(args: I) -> Option { + let mut found = None; + let mut iter = args.into_iter().skip(1); + while let Some(arg) = iter.next() { + if arg == "--" { + break; + } + if arg == "--intent" { + if let Some(value) = iter.next() { + found = Some(value); + } + } else if let Some(value) = arg.strip_prefix("--intent=") { + found = Some(value.to_string()); + } + } + found +} + +/// Sanitise and encode in one step. Split out so the env plumbing below +/// stays trivial and the decision itself is testable on its own. +fn vet(raw: &str) -> Result<(String, String), DropReason> { + let clean = sanitize(raw)?; + let encoded = encode_header(&clean)?; + Ok((clean, encoded)) +} + +/// Env plumbing for one raw input. Returns what should be published. +/// +/// Always clears `CHECKED_ENV` first: it is internal, so any value already +/// in the environment came from a parent process rather than from us, and +/// honouring it would let a caller put an arbitrary unsanitised header on +/// the wire. +fn resolve_with(raw: Option) -> Option<(String, String)> { + std::env::remove_var(CHECKED_ENV); + let raw = raw?; + match vet(&raw) { + Ok((clean, encoded)) => { + std::env::set_var(CHECKED_ENV, &encoded); + Some((clean, encoded)) + } + Err(reason) => { + eprintln!("warning: --intent dropped because {reason}."); + None + } + } +} + +/// Resolve, validate, and publish the intent for this process. +/// +/// Runs from [`super::register`], i.e. before `CliApp::run` parses argv and +/// before clap reads `.env()` bindings, which is what lets the sanitised +/// value reach the framework at all. +pub fn resolve() { + let raw = scan_argv(std::env::args()).or_else(|| std::env::var(INTENT_ENV).ok()); + let _ = RESOLVED.set(resolve_with(raw)); +} + +/// The sanitised intent as plain text, for JSON payloads. +pub fn resolved_text() -> Option { + RESOLVED.get()?.as_ref().map(|(plain, _)| plain.clone()) +} + +/// The sanitised intent percent-encoded, for the header on the hand-written +/// command path (`super::api::request_options`). The generated command path +/// gets it through the framework instead. +pub fn resolved_encoded() -> Option { + RESOLVED.get()?.as_ref().map(|(_, encoded)| encoded.clone()) +} + +// ── Registration ──────────────────────────────────────────────────── + +const INTENT_HELP: &str = "Optional. Why are you running this command? Briefly describe the \ + user's goal in one sentence (max 500 characters). Never include names, email addresses, \ + phone numbers, API keys, file paths, or any other personal or customer data — describe \ + the goal, not the data. Values that look like personal data are dropped with a warning."; + +/// Register the intent parameters and resolve the value. +/// +/// Two parameters, deliberately: see the module docs for why the flag the +/// agent types is never the one that goes on the wire. +pub fn register(app: CliApp) -> CliApp { + resolve(); + app.global_parameter(GlobalParameter { + name: "intent".into(), + location: GlobalParameterLocation::Header, + // Never sent — `Explicit` with no opt-ins means the framework skips + // it. A target distinct from the real header keeps it that way even + // if the apply-mode rules were ever loosened upstream. + target: "X-Agent-Intent-Unvalidated".into(), + env: Some(INTENT_ENV.into()), + default: None, + optional: true, + apply: GlobalParameterApplyMode::Explicit, + parameter_name: None, + docs: Some(INTENT_HELP.into()), + }) + .global_parameter(GlobalParameter { + name: "intent-checked".into(), + location: GlobalParameterLocation::Header, + target: INTENT_HEADER.into(), + env: Some(CHECKED_ENV.into()), + default: None, + optional: true, + apply: GlobalParameterApplyMode::Auto, + parameter_name: None, + docs: Some("Internal: the sanitised value of --intent. Set --intent instead.".into()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(args: &[&str]) -> Vec { + std::iter::once("elevenlabs") + .chain(args.iter().copied()) + .map(String::from) + .collect() + } + + // ── sanitize: accepts ── + + #[test] + fn accepts_an_ordinary_intent() { + assert_eq!( + sanitize(" pick a narrator voice for an audiobook ").unwrap(), + "pick a narrator voice for an audiobook" + ); + } + + #[test] + fn accepts_resource_ids_and_relative_paths() { + // These are the most common real intents in the MCP data and are + // not personal data — dropping them would gut the signal. + sanitize("update agent_01jz9k4m2n8p7q6r5s4t3u2v1w to use eleven_turbo_v2").unwrap(); + sanitize("push agent_configs/support-bot.json after editing the prompt").unwrap(); + sanitize("poll flow run status for the sfx flow started at 14:30").unwrap(); + sanitize("bump TTS stability from 0.5 to 0.75").unwrap(); + sanitize("check what changed between 2026-08-25 and 2026-09-01").unwrap(); + } + + #[test] + fn accepts_non_english_and_round_trips_it() { + let clean = + sanitize("mehrere Sprecher mit unterschiedlichen voice_ids in einem Durchlauf") + .unwrap(); + let encoded = encode_header(&clean).unwrap(); + assert!(encoded.is_ascii()); + + let accented = sanitize("gerar diálogo em português com duas vozes").unwrap(); + let encoded = encode_header(&accented).unwrap(); + assert!(encoded.is_ascii(), "header values must be ASCII"); + assert!(encoded.contains("%C3%A1"), "expected UTF-8 percent-encoding"); + let decoded = percent_encoding::percent_decode_str(&encoded) + .decode_utf8() + .unwrap(); + assert_eq!(decoded, accented); + } + + #[test] + fn a_percent_sign_survives_the_round_trip() { + let clean = sanitize("raise stability by 25% for the narrator").unwrap(); + let encoded = encode_header(&clean).unwrap(); + assert!(encoded.contains("%25")); + let decoded = percent_encoding::percent_decode_str(&encoded) + .decode_utf8() + .unwrap(); + assert_eq!(decoded, clean); + } + + // ── sanitize: rejects ── + + #[test] + fn rejects_empty_and_whitespace() { + assert_eq!(sanitize(""), Err(DropReason::Empty)); + assert_eq!(sanitize(" \t "), Err(DropReason::Empty)); + } + + #[test] + fn rejects_over_five_hundred_characters() { + let long = "x".repeat(501); + assert_eq!(sanitize(&long), Err(DropReason::TooLong)); + assert!(sanitize(&"x".repeat(500)).is_ok()); + } + + #[test] + fn rejects_control_characters() { + assert_eq!(sanitize("goal\nsecond line"), Err(DropReason::ControlChars)); + assert_eq!(sanitize("goal\r\nsecond line"), Err(DropReason::ControlChars)); + assert_eq!(sanitize("goal\tand more"), Err(DropReason::ControlChars)); + // A trailing newline is just sloppy quoting — trim it rather than + // punish the agent for it. + assert_eq!(sanitize("goal\r\n").unwrap(), "goal"); + } + + #[test] + fn rejects_email_addresses() { + assert_eq!( + sanitize("send the render to jane.doe@example.com"), + Err(DropReason::Email) + ); + assert_eq!(sanitize("cc (bob@corp.co.uk)"), Err(DropReason::Email)); + // A bare @mention is not an address. + assert!(sanitize("ask @support about the quota").is_ok()); + } + + #[test] + fn rejects_phone_numbers() { + assert_eq!( + sanitize("assign +1 555 010 9999 to the outbound agent"), + Err(DropReason::Phone) + ); + assert_eq!(sanitize("dial 555-010-9999 next"), Err(DropReason::Phone)); + assert_eq!(sanitize("number 07700900123 please"), Err(DropReason::Phone)); + } + + #[test] + fn rejects_secrets() { + assert_eq!( + sanitize("use sk_abc123 for the request"), + Err(DropReason::Secret) + ); + assert_eq!( + sanitize("set the xi-api-key header"), + Err(DropReason::Secret) + ); + assert_eq!( + sanitize("pass Bearer eyJhbGciOi to the endpoint"), + Err(DropReason::Secret) + ); + assert_eq!( + sanitize(&format!("token {}", "a1b2c3d4e5".repeat(4))), + Err(DropReason::Secret) + ); + } + + #[test] + fn rejects_absolute_paths() { + assert_eq!( + sanitize("read /Users/jane/projects/agent.json"), + Err(DropReason::Path) + ); + assert_eq!(sanitize("open C:\\Users\\jane\\a.json"), Err(DropReason::Path)); + // Relative project paths are structure, not personal data. + assert!(sanitize("push agent_configs/support.json").is_ok()); + } + + #[test] + fn rejects_urls_with_embedded_credentials() { + assert_eq!( + sanitize("proxy through https://user:pw@proxy.internal/v1"), + Err(DropReason::UrlCredentials) + ); + assert!(sanitize("fetch https://example.com/v1/voices").is_ok()); + } + + #[test] + fn rejects_an_encoded_value_that_grows_past_the_header_budget() { + // Each of these encodes to 9 bytes, so 500 of them clear the + // character cap but blow the byte budget. + let clean = "\u{10348}".repeat(500); + assert_eq!(encode_header(&clean), Err(DropReason::TooLong)); + } + + // ── argv scanning ── + + #[test] + fn scans_both_flag_spellings() { + assert_eq!( + scan_argv(argv(&["voices", "list", "--intent", "find a voice"])), + Some("find a voice".to_string()) + ); + assert_eq!( + scan_argv(argv(&["voices", "list", "--intent=find a voice"])), + Some("find a voice".to_string()) + ); + } + + #[test] + fn the_last_occurrence_wins_like_clap() { + assert_eq!( + scan_argv(argv(&["--intent", "first", "--intent", "second"])), + Some("second".to_string()) + ); + } + + #[test] + fn stops_at_the_double_dash_terminator() { + assert_eq!( + scan_argv(argv(&["voices", "list", "--", "--intent", "not a flag"])), + None + ); + } + + #[test] + fn absent_flag_yields_nothing() { + assert_eq!(scan_argv(argv(&["voices", "list"])), None); + // A trailing `--intent` with no value is not a value. + assert_eq!(scan_argv(argv(&["voices", "list", "--intent"])), None); + } + + // ── env plumbing ── + // + // `resolve_with` mutates process-wide env, so these must not interleave. + // Same shape as the `ENV_LOCK` harness in `api.rs`. + + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn with_env(body: impl FnOnce() -> T) -> T { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved = std::env::var(CHECKED_ENV).ok(); + let out = body(); + match saved { + Some(v) => std::env::set_var(CHECKED_ENV, v), + None => std::env::remove_var(CHECKED_ENV), + } + out + } + + #[test] + fn a_clean_value_is_published_and_exported() { + with_env(|| { + let resolved = resolve_with(Some("pick a narrator voice".to_string())); + assert_eq!( + resolved, + Some(( + "pick a narrator voice".to_string(), + // Spaces are legal in a header value and left readable. + "pick a narrator voice".to_string() + )) + ); + assert_eq!( + std::env::var(CHECKED_ENV).as_deref(), + Ok("pick a narrator voice") + ); + }); + } + + #[test] + fn a_rejected_value_exports_nothing() { + with_env(|| { + assert_eq!(resolve_with(Some("call +1 555 010 9999".to_string())), None); + assert!( + std::env::var(CHECKED_ENV).is_err(), + "a dropped intent must not reach the wire" + ); + }); + } + + #[test] + fn an_inherited_checked_value_is_discarded() { + with_env(|| { + // Only `resolve_with` may write this var. A value already in the + // environment came from a parent process, and trusting it would + // put an unsanitised header on the wire. + std::env::set_var(CHECKED_ENV, "smuggled%20value"); + assert_eq!(resolve_with(None), None); + assert!(std::env::var(CHECKED_ENV).is_err()); + }); + } + + #[test] + fn an_inherited_checked_value_loses_to_a_real_intent() { + with_env(|| { + std::env::set_var(CHECKED_ENV, "smuggled%20value"); + resolve_with(Some("list the workspace voices".to_string())); + assert_eq!( + std::env::var(CHECKED_ENV).as_deref(), + Ok("list the workspace voices") + ); + }); + } +} diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 1a33e5c..0ba283a 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -16,6 +16,8 @@ use fern_cli_sdk::app::CliApp; mod agents; mod api; mod components; +mod feedback; +mod intent; mod project; mod residency; mod settings; @@ -27,10 +29,15 @@ mod verify; /// Register every custom command group on the CLI app builder. pub fn register(app: CliApp) -> CliApp { + // First: `intent::register` resolves the agent-supplied intent from argv + // and the environment, which has to happen before `CliApp::run` parses + // anything (see that module's docs). + let app = intent::register(app); 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); + let app = feedback::register(app); components::register(app) } From 7c765038aa04ffdc6de8634dbed40396e7b168d4 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Thu, 3 Sep 2026 10:23:47 +0200 Subject: [PATCH 2/4] Relax filters --- README.md | 22 ++-- cli/elevenlabs/workflow/feedback.rs | 15 +-- cli/elevenlabs/workflow/intent.rs | 149 +++++++--------------------- 3 files changed, 58 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 23e08e5..cb3a285 100644 --- a/README.md +++ b/README.md @@ -323,17 +323,21 @@ elevenlabs feedback missing-capability \ "no way to batch-render a script to separate files per speaker" ``` -#### Never put personal data in either field +#### Keep personal data out of both fields -Describe the *goal*, not the data. No names, email addresses, phone numbers, API -keys, absolute file paths, or customer content. Resource ids (`agent_01jz…`) and -project-relative paths are fine. +Describe the *goal*, not the data. Resource ids (`agent_01jz…`) and +project-relative paths are fine; names, customer content, and anything you would +not want in an analytics store are not. -The CLI enforces this rather than trusting it. A value longer than 500 -characters, or one that looks like it contains personal data, is dropped before -the request is built — `--intent` warns on stderr and the command proceeds -normally, while `feedback` fails so you can rewrite it. Free text is also -withheld server-side for zero-retention and enterprise workspaces. +Two of those are enforced rather than trusted. A value longer than 500 +characters, or one carrying credentials or an absolute file path, is dropped +before the request is built — `--intent` warns on stderr and the command +proceeds normally, while `feedback` fails so you can rewrite it. The rest is +guidance: contact details are deliberately not filtered, because telephony and +support capability gaps cannot be described without them. + +Free text is withheld entirely server-side for zero-retention and enterprise +workspaces, which is the boundary that actually holds. ### Output formats diff --git a/cli/elevenlabs/workflow/feedback.rs b/cli/elevenlabs/workflow/feedback.rs index c65aea2..18a44a0 100644 --- a/cli/elevenlabs/workflow/feedback.rs +++ b/cli/elevenlabs/workflow/feedback.rs @@ -35,9 +35,8 @@ elevenlabs command. Describe the capability you were looking for, so it can \ inform which commands get built next. Do not call it when an existing command \ already covers the request. -Never include names, email addresses, phone numbers, API keys, file paths, or \ -any other personal or customer data — describe the capability, not the data. \ -A description that looks like it contains personal data is rejected."; +Do not include personal or customer data — describe the capability, not the \ +data. A description carrying credentials or a file path is rejected."; /// Echoes the MCP's benign response, so an agent treats this as a dead end /// to route around rather than a failure to retry. @@ -126,16 +125,20 @@ mod tests { // The wording is the whole mechanism — an agent reads this and // nothing else before deciding what to send. assert!(LONG_ABOUT.contains("cannot be completed")); - assert!(LONG_ABOUT.contains("Never include names")); + assert!(LONG_ABOUT.contains("Do not include personal or customer data")); assert!(LONG_ABOUT.contains("Do not call it when an existing command")); } #[test] - fn a_capability_with_personal_data_is_refused_before_any_request() { + fn a_capability_carrying_credentials_is_refused_before_any_request() { // Rejection has to happen client-side — the request would otherwise // carry the data to the server, which is the thing being prevented. - assert!(intent::sanitize("import +1 555 010 9999 as an outbound number").is_err()); + assert!(intent::sanitize("cannot auth with sk_abc123").is_err()); + assert!(intent::sanitize("cannot read /Users/jane/script.txt").is_err()); assert!(intent::sanitize("no way to batch-render per speaker").is_ok()); + // Contact details are no longer refused — telephony capability gaps + // are among the most useful reports we get. See `intent`'s module docs. + assert!(intent::sanitize("cannot import +1 555 010 9999 as a number").is_ok()); } #[test] diff --git a/cli/elevenlabs/workflow/intent.rs b/cli/elevenlabs/workflow/intent.rs index 105eeb7..327ad75 100644 --- a/cli/elevenlabs/workflow/intent.rs +++ b/cli/elevenlabs/workflow/intent.rs @@ -16,8 +16,15 @@ //! into, which means the framework accepts the flag but never sends it. A //! second, hidden, env-only parameter carries the value that *is* sent, and //! the only writer of that env var is [`resolve`] — which runs before clap -//! parses anything and refuses to write a value that looks like personal -//! data. +//! parses anything and refuses to write a value carrying credentials or a +//! filesystem path. +//! +//! Contact details (emails, phone numbers) are *not* refused. They were +//! originally, and it cost too many legitimate intents: telephony and +//! support workflows are a large slice of what the CLI does, and neither is +//! describable without the identifier. The guidance still asks agents to +//! leave them out, and the backend's ZRM/enterprise content gate still +//! withholds free text wholesale for the workspaces that need that. //! //! Dropping is deliberately silent-ish: a rejected intent warns on stderr //! and the command proceeds normally. Telemetry must never be able to fail @@ -73,8 +80,6 @@ pub enum DropReason { Empty, TooLong, ControlChars, - Email, - Phone, Secret, Path, UrlCredentials, @@ -90,12 +95,6 @@ impl DropReason { "it is longer than 500 characters — describe the goal in one sentence" } DropReason::ControlChars => "it contains line breaks or control characters", - DropReason::Email => { - "it looks like it contains an email address — describe the goal, not the data" - } - DropReason::Phone => { - "it looks like it contains a phone number — describe the goal, not the data" - } DropReason::Secret => { "it looks like it contains an API key or token — never include credentials" } @@ -122,6 +121,10 @@ impl std::fmt::Display for DropReason { /// Pure: no env, no I/O. The same checks run again server-side — a client /// -side filter is a nudge, not a boundary — but doing it here means the /// data never leaves the machine and the agent gets told why. +/// +/// Scope is deliberately narrow: credentials, filesystem paths, and shape +/// (length, control characters). Contact details are not refused — see the +/// module docs. pub fn sanitize(raw: &str) -> Result { let text = raw.trim(); if text.is_empty() { @@ -133,9 +136,6 @@ pub fn sanitize(raw: &str) -> Result { if text.chars().any(char::is_control) { return Err(DropReason::ControlChars); } - if contains_email(text) { - return Err(DropReason::Email); - } if contains_url_credentials(text) { return Err(DropReason::UrlCredentials); } @@ -145,9 +145,6 @@ pub fn sanitize(raw: &str) -> Result { if contains_absolute_path(text) { return Err(DropReason::Path); } - if contains_phone(text) { - return Err(DropReason::Phone); - } Ok(text.to_string()) } @@ -162,25 +159,6 @@ pub fn encode_header(clean: &str) -> Result { Ok(encoded) } -/// `local@domain.tld` inside any whitespace-delimited token. -fn contains_email(text: &str) -> bool { - text.split_whitespace().any(|token| { - // Strip punctuation an agent would naturally write around it. - let token = token.trim_matches(|c: char| matches!(c, '(' | ')' | '<' | '>' | ',' | ';' | '"' | '\'')); - let Some((local, domain)) = token.split_once('@') else { - return false; - }; - if local.is_empty() || !local.chars().any(|c| c.is_ascii_alphanumeric()) { - return false; - } - let domain = domain.trim_end_matches('.'); - let Some((host, tld)) = domain.rsplit_once('.') else { - return false; - }; - !host.is_empty() && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()) - }) -} - /// `scheme://user:pass@host`. fn contains_url_credentials(text: &str) -> bool { let mut rest = text; @@ -253,67 +231,6 @@ fn contains_absolute_path(text: &str) -> bool { }) } -/// Phone-shaped digit runs. Three narrow rules rather than one broad one, -/// because the false positives worth avoiding are dates and version -/// numbers, which agents write constantly. -fn contains_phone(text: &str) -> bool { - let chars: Vec = text.chars().collect(); - - // (a) E.164: `+` then at least 10 digits, ignoring spaces/dashes/parens. - for (i, c) in chars.iter().enumerate() { - if *c != '+' { - continue; - } - let mut digits = 0usize; - for c in &chars[i + 1..] { - if c.is_ascii_digit() { - digits += 1; - } else if matches!(c, ' ' | '-' | '(' | ')' | '.') { - continue; - } else { - break; - } - } - if digits >= 10 { - return true; - } - } - - // (b) A bare run of 9+ digits. Long enough that dates and ports do not - // reach it; anything that does is an identifier we would rather not - // collect. - let mut run = 0usize; - for c in &chars { - if c.is_ascii_digit() { - run += 1; - if run >= 9 { - return true; - } - } else { - run = 0; - } - } - - // (c) Separated shapes like `555-010-9999` or `(555) 010 9999`: 10+ - // digits joined only by phone separators. `:` and `/` break the run, - // which is what keeps `2026-08-25 14:30` and `25/08/2026` out. - let mut digits = 0usize; - for c in &chars { - if c.is_ascii_digit() { - digits += 1; - if digits >= 10 { - return true; - } - } else if matches!(c, ' ' | '-' | '(' | ')' | '.') { - continue; - } else { - digits = 0; - } - } - - false -} - // ── Resolution ────────────────────────────────────────────────────── /// Pull the raw intent out of argv, mirroring clap's last-wins semantics @@ -394,9 +311,9 @@ pub fn resolved_encoded() -> Option { // ── Registration ──────────────────────────────────────────────────── const INTENT_HELP: &str = "Optional. Why are you running this command? Briefly describe the \ - user's goal in one sentence (max 500 characters). Never include names, email addresses, \ - phone numbers, API keys, file paths, or any other personal or customer data — describe \ - the goal, not the data. Values that look like personal data are dropped with a warning."; + user's goal in one sentence (max 500 characters). Do not include personal or customer \ + data — describe the goal, not the data. Values carrying credentials or file paths are \ + dropped with a warning."; /// Register the intent parameters and resolve the value. /// @@ -518,24 +435,30 @@ mod tests { } #[test] - fn rejects_email_addresses() { - assert_eq!( - sanitize("send the render to jane.doe@example.com"), - Err(DropReason::Email) - ); - assert_eq!(sanitize("cc (bob@corp.co.uk)"), Err(DropReason::Email)); - // A bare @mention is not an address. - assert!(sanitize("ask @support about the quota").is_ok()); + fn contact_details_are_no_longer_dropped() { + // Email and phone detection was deliberately removed: it cost real + // intents (support workflows and telephony agents are a large slice + // of what the CLI is used for, and both are unusable to describe + // without the identifier). The instructions still ask agents not to + // include them, and the backend's ZRM/enterprise content gate still + // applies — enforcement is what relaxed, not the guidance. + assert!(sanitize("send the render to jane.doe@example.com").is_ok()); + assert!(sanitize("assign +1 555 010 9999 to the outbound agent").is_ok()); + assert!(sanitize("dial 555-010-9999 next").is_ok()); + assert!(sanitize("import 07700900123 as a Twilio number").is_ok()); } #[test] - fn rejects_phone_numbers() { + fn credentials_and_paths_are_still_dropped() { + // Relaxing contact details must not quietly relax the rest. assert_eq!( - sanitize("assign +1 555 010 9999 to the outbound agent"), - Err(DropReason::Phone) + sanitize("call +1 555 010 9999 using sk_abc123"), + Err(DropReason::Secret) + ); + assert_eq!( + sanitize("mail jane@example.com the file at /Users/jane/a.json"), + Err(DropReason::Path) ); - assert_eq!(sanitize("dial 555-010-9999 next"), Err(DropReason::Phone)); - assert_eq!(sanitize("number 07700900123 please"), Err(DropReason::Phone)); } #[test] @@ -663,7 +586,7 @@ mod tests { #[test] fn a_rejected_value_exports_nothing() { with_env(|| { - assert_eq!(resolve_with(Some("call +1 555 010 9999".to_string())), None); + assert_eq!(resolve_with(Some("auth with sk_abc123".to_string())), None); assert!( std::env::var(CHECKED_ENV).is_err(), "a dropped intent must not reach the wire" From fec11dba9d64ba8e1b990d2f65ae10ef63dea76a Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Thu, 3 Sep 2026 11:00:57 +0200 Subject: [PATCH 3/4] update route --- cli/elevenlabs/workflow/feedback.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cli/elevenlabs/workflow/feedback.rs b/cli/elevenlabs/workflow/feedback.rs index 18a44a0..c86594a 100644 --- a/cli/elevenlabs/workflow/feedback.rs +++ b/cli/elevenlabs/workflow/feedback.rs @@ -20,11 +20,15 @@ use serde_json::{json, Value}; use super::util::{downcast_ctx, dry_run_flag}; use super::{api, intent}; -/// First-party endpoint for CLI feedback. Deliberately absent from the -/// public OpenAPI spec — it is reached through [`api::raw_request`] and is -/// not an API we want the SDKs to generate clients for. Relative, matching -/// the generated SDK's convention. -const FEEDBACK_PATH: &str = "v1/cli/feedback"; +/// First-party endpoint for CLI feedback. Sits under the existing +/// `/v1/feedback` root resource, and names the client rather than the +/// resource on purpose: it is absent from the public OpenAPI spec, reached +/// only through [`api::raw_request`], and no SDK generates against it, so +/// naming the one caller is worth more than resource purity. The `kind` +/// field in the body discriminates report types within it. +/// +/// Relative, matching the generated SDK's convention. +const FEEDBACK_PATH: &str = "v1/feedback/cli"; /// Mirrors the MCP's `get_more_tools` description, which is the wording /// that demonstrably gets agents to call it, plus the PII sentence the MCP From 50327dd52756f71e8bb7c1b20fd94e0dbcbc94a8 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Fri, 4 Sep 2026 10:11:40 +0200 Subject: [PATCH 4/4] Add intent and feedback to generate-skills --- cli/elevenlabs/workflow/mod.rs | 3 + cli/elevenlabs/workflow/skills.rs | 229 ++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 cli/elevenlabs/workflow/skills.rs diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 0ba283a..d3f3492 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -21,6 +21,7 @@ mod intent; mod project; mod residency; mod settings; +mod skills; mod templates; mod tests; mod tools; @@ -39,5 +40,7 @@ pub fn register(app: CliApp) -> CliApp { let app = tests::register(app); let app = residency::register(app); let app = feedback::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/skills.rs b/cli/elevenlabs/workflow/skills.rs new file mode 100644 index 0000000..521eae6 --- /dev/null +++ b/cli/elevenlabs/workflow/skills.rs @@ -0,0 +1,229 @@ +//! `generate-skills`, shadowing the framework's built-in so the emitted +//! SKILL.md files mention the two agent-feedback affordances. +//! +//! ## Why this shadows rather than extends +//! +//! The emitter (`fern_cli_sdk::openapi::skill_emitter`) walks the OpenAPI +//! spec and renders fixed templates. It has no hook for extra prose, and +//! it is generated code — editing it would be clobbered by the next +//! `fern generate`. Registering a custom command with the same name wins +//! dispatch over the built-in, so this file (protected by `.fernignore`) +//! can wrap it instead. +//! +//! The wrapper stays deliberately thin: it calls the framework's +//! [`generate_skills`] for the actual content, so upstream improvements to +//! the templates still arrive. It owns only the output path and the extra +//! section. If the emitter's signature changes upstream, this fails to +//! compile — visibly, rather than silently emitting stale skills. +//! +//! ## Why the two features need this at all +//! +//! Neither is reachable by the emitter. `--intent` is a +//! [`GlobalParameter`](super::intent), and the Global Flags table is a +//! hardcoded list that does not enumerate registered globals. `feedback +//! missing-capability` is a hand-written command, and the emitter only +//! walks spec-derived resources. Both gaps are worth fixing upstream in +//! the generator; until then, an agent reading only the skills would never +//! learn either exists. + +use std::path::PathBuf; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::skill_emitter::{generate_skills, generate_skills_command}; +use fern_cli_sdk::openapi::AppContext; + +use super::util::downcast_ctx; + +/// What the emitter writes when it is handed no auth bindings. We cannot +/// hand it ours: they live on the `OpenApiBinding`, which `AppContext` does +/// not expose, and the spec carries no `securitySchemes` for the fallback to +/// use either. Left alone the shared skill would tell agents this CLI needs +/// no credentials, which is both wrong and the first thing they read. +const NO_AUTH_LINE: &str = "No authentication configured."; + +/// Replaces [`NO_AUTH_LINE`]. Hand-written rather than rendered, so it can +/// say the thing an agent actually needs — the env var name — which the +/// generic rendering of our OAuth binding ("custom auth provider") does not. +const AUTH_SECTION: &str = "\ +Every request is authenticated with an ElevenLabs API key, sent as the \ +`xi-api-key` header. + +```bash +export ELEVENLABS_API_KEY=xi-... +``` + +A `.env` file in the working directory is loaded automatically. For a one-off \ +call, pass `--xi-api-key xi-...` instead. `elevenlabs auth login` sets up \ +OAuth in a keyring as an alternative to the env var."; + +/// Matches the emitter's own naming: `{bin_name}-shared/SKILL.md`. +const SHARED_SKILL: &str = "elevenlabs-shared"; + +/// The binary name the emitter uses for headings and file prefixes. +const BIN_NAME: &str = "elevenlabs"; + +/// Appended to the shared skill, which every group skill links as a +/// prerequisite — so this is read once and applies everywhere. +/// +/// Wording tracks the hosted MCP's `context` argument and `get_more_tools` +/// tool, which are what demonstrably get agents to supply these, with the +/// PII sentence the MCP versions lack. +const FEEDBACK_SECTION: &str = r#" +## Telling us what you are doing + +Two optional inputs let you report what you are trying to accomplish and what +you could not do. Both are opt-in and neither changes what a command does. + +### `--intent` — why you are running this command + +Available on every command. Sent as a request header. + +```bash +elevenlabs voices search --intent "pick a narrator voice for an audiobook" +``` + +Set `ELEVENLABS_AGENT_INTENT` instead to apply one intent to every command in a +task, which usually fits better than repeating the flag: + +```bash +export ELEVENLABS_AGENT_INTENT="migrate the support bot to eleven_turbo_v2" +``` + +Keep it to one sentence describing the user's goal, max 500 characters. + +### `elevenlabs feedback missing-capability` — what you could not do + +Call this when the user's request cannot be completed with any available +`elevenlabs` command. Describe the capability you were looking for, so it can +inform which commands get built next. Do not call it when an existing command +already covers the request. + +```bash +elevenlabs feedback missing-capability \ + "no way to batch-render a script to separate files per speaker" +``` + +It records the report and returns; it does not fail the task. Continue with the +available commands, or tell the user the thing is not supported yet. + +### Never put personal data in either field + +Describe the *goal*, not the data. Resource ids (`agent_01jz…`) and +project-relative paths are fine; names, customer content, and anything you +would not want in an analytics store are not. + +Two rules are enforced rather than trusted: a value over 500 characters, or one +carrying credentials or an absolute file path, is dropped before the request is +built. `--intent` warns on stderr and the command proceeds normally; `feedback` +fails so you can rewrite it. +"#; + +fn handle(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError> { + let out_dir = matches + .get_one::("output-dir") + .map(String::as_str) + .unwrap_or("skills"); + // The framework's own validator, so this path behaves exactly as the + // built-in did. Note it deliberately does not sandbox: it rejects control + // characters and resolves the path, but the target may be anywhere on the + // filesystem (see its docs). Shadowing neither adds nor removes that. + let resolved = fern_cli_sdk::validate::validate_safe_output_dir(out_dir)?; + + let shared = PathBuf::from(SHARED_SKILL).join("SKILL.md"); + let mut files = generate_skills(ctx.spec(), BIN_NAME, &[]); + + let mut appended = false; + for (path, content) in files.iter_mut() { + if *path == shared { + // Only when the emitter actually produced the no-auth text. If a + // future framework version renders real bindings, defer to it + // rather than overwriting a better section with ours. + if content.contains(NO_AUTH_LINE) { + *content = content.replace(NO_AUTH_LINE, AUTH_SECTION); + } + content.push_str(FEEDBACK_SECTION); + appended = true; + } + } + if !appended { + // The emitter renamed or dropped the shared skill. Fail loudly: the + // alternative is silently shipping skills without the section, which + // is the exact failure this command exists to prevent. + return Err(CliError::Other(anyhow::anyhow!( + "expected the emitter to produce {}; the feedback section had nowhere to go", + shared.display() + ))); + } + + for (rel_path, content) in &files { + let full_path = resolved.join(rel_path); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Validation(format!( + "Failed to create directory {}: {e}", + parent.display() + )) + })?; + } + std::fs::write(&full_path, content).map_err(|e| { + CliError::Validation(format!("Failed to write {}: {e}", full_path.display())) + })?; + } + + eprintln!( + "Wrote {} skill file(s) to {}/", + files.len(), + resolved.display() + ); + Ok(()) +} + +/// Register the shadowing `generate-skills`. +/// +/// Reuses the framework's own clap definition so `--help` and `--output-dir` +/// stay identical to the command being replaced. +pub fn register(app: CliApp) -> CliApp { + app.command( + generate_skills_command(), + Box::new(|matches, ctx| handle(matches, downcast_ctx(ctx)?)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_section_documents_both_affordances() { + // An agent reads this and nothing else before deciding whether to + // use them, so the trigger phrasing is the whole mechanism. + assert!(FEEDBACK_SECTION.contains("--intent")); + assert!(FEEDBACK_SECTION.contains("ELEVENLABS_AGENT_INTENT")); + assert!(FEEDBACK_SECTION.contains("feedback missing-capability")); + assert!(FEEDBACK_SECTION.contains("cannot be completed")); + assert!(FEEDBACK_SECTION.contains("Do not call it when an existing command")); + } + + #[test] + fn the_section_states_the_pii_rule() { + assert!(FEEDBACK_SECTION.contains("Never put personal data")); + assert!(FEEDBACK_SECTION.contains("500 characters")); + } + + #[test] + fn the_auth_replacement_names_the_env_var() { + // The whole point of overriding the rendered section: an agent needs + // the variable name, not the words "custom auth provider". + assert!(AUTH_SECTION.contains("ELEVENLABS_API_KEY")); + assert!(AUTH_SECTION.contains("xi-api-key")); + assert!(!AUTH_SECTION.contains(NO_AUTH_LINE)); + } + + #[test] + fn the_shared_skill_target_matches_the_emitters_naming() { + // `generate_skills` builds this path as `{bin_name}-shared/SKILL.md`. + // If the two drift, `handle` errors rather than emitting silently. + assert_eq!(SHARED_SKILL, format!("{BIN_NAME}-shared")); + } +}