diff --git a/.fernignore b/.fernignore index ecbbe2c..1fbe28f 100644 --- a/.fernignore +++ b/.fernignore @@ -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 diff --git a/README.md b/README.md index b61551f..70c5e99 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/elevenlabs/workflow/agents.rs b/cli/elevenlabs/workflow/agents.rs index 87f8cdf..c35412c 100644 --- a/cli/elevenlabs/workflow/agents.rs +++ b/cli/elevenlabs/workflow/agents.rs @@ -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. diff --git a/cli/elevenlabs/workflow/tests.rs b/cli/elevenlabs/workflow/tests.rs index c4d9a7c..e0632b5 100644 --- a/cli/elevenlabs/workflow/tests.rs +++ b/cli/elevenlabs/workflow/tests.rs @@ -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"), diff --git a/cli/elevenlabs/workflow/tools.rs b/cli/elevenlabs/workflow/tools.rs index 58d1ff6..483d666 100644 --- a/cli/elevenlabs/workflow/tools.rs +++ b/cli/elevenlabs/workflow/tools.rs @@ -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 ────────────────────────────────────────────────── diff --git a/src/app.rs b/src/app.rs index e4771a8..64433fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -45,6 +45,9 @@ struct CliCommand { path: Vec, 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 @@ -316,6 +319,7 @@ impl CliApp { path: Vec::new(), cmd, handler, + format_ignored: None, }); self } @@ -401,6 +405,7 @@ impl CliApp { path: Vec::new(), cmd: augmented, handler: erased, + format_ignored: None, }); self } @@ -431,6 +436,7 @@ impl CliApp { path: path.iter().map(|s| s.to_string()).collect(), cmd, handler, + format_ignored: None, }); self } @@ -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: "... are always 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 @@ -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 _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 _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), ) @@ -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::("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> = None; diff --git a/tests/format_flag_test.rs b/tests/format_flag_test.rs new file mode 100644 index 0000000..4b9e1b6 --- /dev/null +++ b/tests/format_flag_test.rs @@ -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}"); +}