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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ tests/e2e_smoke.rs
tests/say_test.rs
tests/onboard_test.rs
tests/flows_wait_test.rs
tests/format_flag_test.rs
.fern/replay.lock
.fern/replay.yml
.gitattributes
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ For production, prefer setting `webhook` on `create` and handling the webhook ev

## 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.
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. The global `--format` flag styles printed API responses only; configs are always stored as JSON.

### Project structure

Expand Down
3 changes: 3 additions & 0 deletions cli/elevenlabs/workflow/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ pub fn register(app: CliApp) -> CliApp {
pull_command(),
Box::new(|matches, ctx| handle_pull(matches, downcast_ctx(ctx)?)),
)
// Push and pull read and write local JSON files, not printed responses.
.ignores_format(&["agents", "push"], "agent configs", "read")
.ignores_format(&["agents", "pull"], "agent configs", "written")
// Nested under the generated `agents widget` subgroup rather than
// replacing it: `widget get` (config) and `widget avatar` are API
// commands, and a custom leaf named `widget` would shadow the whole group.
Expand Down
3 changes: 3 additions & 0 deletions cli/elevenlabs/workflow/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub fn register(app: CliApp) -> CliApp {
pull_command(),
Box::new(|matches, ctx| handle_pull(matches, downcast_ctx(ctx)?)),
)
// Push and pull read and write local JSON files, not printed responses.
.ignores_format(&["tests", "push"], "test configs", "read")
.ignores_format(&["tests", "pull"], "test configs", "written")
.command_under_typed_with(
&["tests", "templates"],
clap::Command::new("list").about("List available test templates"),
Expand Down
3 changes: 3 additions & 0 deletions cli/elevenlabs/workflow/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub fn register(app: CliApp) -> CliApp {
pull_command(),
Box::new(|matches, ctx| handle_pull(matches, downcast_ctx(ctx)?)),
)
// Push and pull read and write local JSON files, not printed responses.
.ignores_format(&["tools", "push"], "tool configs", "read")
.ignores_format(&["tools", "pull"], "tool configs", "written")
}

// ── shared helpers ──────────────────────────────────────────────────
Expand Down
52 changes: 51 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ struct CliCommand {
path: Vec<String>,
cmd: clap::Command,
handler: CliCommandHandler,
/// Set by [`CliApp::ignores_format`]: `(noun, verb)` for the warning
/// dispatch prints when `--format` is passed to this command.
format_ignored: Option<(String, String)>,
}

/// Outcome of the dispatch pipeline — separates success from
Expand Down Expand Up @@ -316,6 +319,7 @@ impl CliApp {
path: Vec::new(),
cmd,
handler,
format_ignored: None,
});
self
}
Expand Down Expand Up @@ -401,6 +405,7 @@ impl CliApp {
path: Vec::new(),
cmd: augmented,
handler: erased,
format_ignored: None,
});
self
}
Expand Down Expand Up @@ -431,6 +436,7 @@ impl CliApp {
path: path.iter().map(|s| s.to_string()).collect(),
cmd,
handler,
format_ignored: None,
});
self
}
Expand Down Expand Up @@ -489,10 +495,35 @@ impl CliApp {
path: path.iter().map(|s| s.to_string()).collect(),
cmd: augmented,
handler: erased,
format_ignored: None,
});
self
}

/// Declare that the custom command at `path` (parent path plus its own
/// name) does not print an API response, so the global `--format` flag
/// has no effect on it. Dispatch then warns on stderr when `--format` is
/// passed: "... <noun> are always <verb> as JSON."
///
/// ```rust,ignore
/// app.ignores_format(&["agents", "pull"], "agent configs", "written")
/// ```
pub fn ignores_format(mut self, path: &[&str], noun: &str, verb: &str) -> Self {
let cc = self
.cli_commands
.iter_mut()
.find(|cc| {
cc.path
.iter()
.map(String::as_str)
.chain([cc.cmd.get_name()])
.eq(path.iter().copied())
})
.unwrap_or_else(|| panic!("ignores_format: no custom command at `{}`", path.join(" ")));
cc.format_ignored = Some((noun.to_string(), verb.to_string()));
self
}

// ── Tier 1: Declarative ─────────────────────────────────────────

/// Register an alias for a command at `path`. Invoking the alias
Expand Down Expand Up @@ -926,7 +957,7 @@ impl CliApp {
.arg(
clap::Arg::new("format")
.long("format")
.help("Output format: json, table, yaml, csv, raw, jsonl, http. Default: table when stdout is a TTY, json when piped. Override default with <NAME>_OUTPUT env var. raw emits unmodified server response bytes. jsonl emits one compact JSON value per line (NDJSON); arrays are flattened. http emits the full HTTP response (status line + headers + body) like curl -i (OpenAPI only).")
.help("Output format for printed API responses. Does not change files written to disk. Formats: json, table, yaml, csv, raw, jsonl, http. Default: table when stdout is a TTY, json when piped. Override default with <NAME>_OUTPUT env var. raw emits unmodified server response bytes. jsonl emits one compact JSON value per line (NDJSON); arrays are flattened. http emits the full HTTP response (status line + headers + body) like curl -i (OpenAPI only).")
.value_name("FORMAT")
.global(true),
)
Expand Down Expand Up @@ -1127,6 +1158,25 @@ impl CliApp {
if let Some(target) = crate::custom_commands::walk_matches_to_custom(
&matches, &cc.path, cc.cmd.get_name(),
) {
if let Some((noun, verb)) = &cc.format_ignored {
if target
.try_get_one::<String>("format")
.ok()
.flatten()
.is_some()
{
let command: Vec<&str> = cc
.path
.iter()
.map(String::as_str)
.chain([cc.cmd.get_name()])
.collect();
eprintln!(
"--format controls how API responses are printed and has no effect on {}; {noun} are always {verb} as JSON.",
command.join(" "),
);
}
}
// Collect contexts from ALL bindings so the handler can
// invoke operations from any binding transparently.
let mut ctx: Option<Box<dyn Any + Send + Sync>> = None;
Expand Down
83 changes: 83 additions & 0 deletions tests/format_flag_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! `--format` on agents-as-code push/pull commands, run through the real binary.
//!
//! `--format` is a global flag declared in `src/app.rs`, so clap accepts it on
//! every command. Push/pull read and write local JSON configs and never print
//! an API response, so they declare `ignores_format` at registration and
//! `CliApp` dispatch warns on stderr. These tests drive the built binary — the
//! same command tree `main` builds — so a change to how the global flag is
//! attached, or a dropped declaration, fails here.
//!
//! No network: `ELEVENLABS_BASE_URL` points at a closed localhost port, so a
//! pull that gets as far as the API fails with a connection error, and each
//! run happens in an empty temp directory. The warning prints before the
//! handler runs, so the handler's own outcome does not matter here.

use std::path::Path;
use std::process::{Command, Output};

fn cli(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_elevenlabs"))
.args(args)
.current_dir(dir)
.env("HOME", dir)
.env("ELEVENLABS_API_KEY", "test-key")
.env("NO_COLOR", "1")
.env("FERN_CLI_CREDENTIAL_STORE", "file")
.env("ELEVENLABS_BASE_URL", "http://127.0.0.1:9")
.env_remove("ELEVENLABS_VIA")
.output()
.expect("failed to spawn the elevenlabs binary")
}

fn stderr_of(args: &[&str]) -> String {
let dir = tempfile::tempdir().expect("tempdir");
String::from_utf8_lossy(&cli(dir.path(), args).stderr).to_string()
}

fn warning(command: &str, noun: &str, verb: &str) -> String {
format!(
"--format controls how API responses are printed and has no effect on {command}; {noun} are always {verb} as JSON.\n"
)
}

#[test]
fn agents_pull_warns_with_format_after_the_command() {
let err = stderr_of(&["agents", "pull", "--format", "yaml"]);
assert!(
err.starts_with(&warning("agents pull", "agent configs", "written")),
"{err}"
);
}

#[test]
fn agents_pull_warns_with_format_before_the_command() {
let err = stderr_of(&["--format", "yaml", "agents", "pull"]);
assert!(
err.starts_with(&warning("agents pull", "agent configs", "written")),
"{err}"
);
}

#[test]
fn tools_push_warns_with_its_own_noun() {
let err = stderr_of(&["tools", "push", "--format", "json"]);
assert!(
err.starts_with(&warning("tools push", "tool configs", "read")),
"{err}"
);
}

#[test]
fn tests_pull_warns_with_its_own_noun() {
let err = stderr_of(&["tests", "pull", "--format", "table"]);
assert!(
err.starts_with(&warning("tests pull", "test configs", "written")),
"{err}"
);
}

#[test]
fn no_warning_without_format() {
let err = stderr_of(&["agents", "pull"]);
assert!(!err.contains("--format"), "{err}");
}
Loading