#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stdout,
clippy::print_stderr,
unused_imports
)]
use agent_first_data::{
CliEmitter, CliEmitterError, Event, OutputFormat, OutputOptions, build_cli_error,
cli_parse_log_filters, cli_parse_output, json_error, json_result, render,
};
#[cfg(feature = "cli-help")]
use clap::CommandFactory;
use clap::{Parser, Subcommand};
use std::io::Write;
macro_rules! stdout {
($($arg:tt)*) => {{
write_stdout_or_exit(&format!($($arg)*));
}};
}
fn write_stdout_or_exit(text: &str) {
let mut stdout = std::io::stdout().lock();
if let Err(err) = stdout.write_all(text.as_bytes()) {
if err.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
std::process::exit(1);
}
}
fn emit_error_exit(format: OutputFormat, event: Event, code: u8) -> ! {
std::process::exit(CliEmitter::finite(format).finish(event, code).into())
}
fn emit_value_error_exit(format: OutputFormat, event: serde_json::Value, code: u8) -> ! {
let mut emitter = CliEmitter::finite(format);
let exit = match emitter.emit_validated_value(event) {
Ok(()) => code,
Err(err) if err.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe) => 0,
Err(_) => 4,
};
std::process::exit(exit.into())
}
fn exit_with(code: u8) -> ! {
std::process::exit(code.into())
}
const AGENT_CLI_HOST_ENV: &str = "AGENT_CLI_HOST";
const STARTUP_ENV_KEYS: &[&str] = &[AGENT_CLI_HOST_ENV];
#[cfg(feature = "skill-admin")]
const WIDGET_SKILL: &str = "---\nname: agent-first-widget\ndescription: Example skill bundled by the agent-cli demo.\n---\n\n# Agent-First Widget\n\nExample behavior rules go here.\n";
#[cfg(feature = "skill-admin")]
const WIDGET_SPEC: agent_first_data::skill::SkillSpec = agent_first_data::skill::SkillSpec {
name: "agent-first-widget",
source: WIDGET_SKILL,
title: "Agent-First Widget",
marker_slug: "afwidget",
assets: &[],
};
const AGENT_CLI_VERSION: &str = "0.13.0";
const AGENT_CLI_EXAMPLE_DISPLAY_NAME: &str = "Agent CLI Example";
#[derive(Parser)]
#[command(
name = "agent-cli",
bin_name = "agent-cli",
version = AGENT_CLI_VERSION,
about = "Minimal agent-first CLI example",
long_about = r#"### Interface Policy
- Agent-facing CLI surfaces should keep stdout structured.
- Help inherits the CLI's JSON output default; use `--output plain` for conventional text.
- Binary names are command-specific, so this example sets `name = "agent-cli"` explicitly.
### Example Usage
```text
agent-cli ping --host example.com
agent-cli --help --recursive --output markdown
```
"#,
disable_help_subcommand = true
)]
struct Cli {
#[arg(long, default_value = "json")]
output: String,
#[arg(long, value_delimiter = ',')]
log: Vec<String>,
#[arg(long)]
verbose: bool,
#[cfg(feature = "stream-redirect")]
#[arg(long, value_name = "PATH", global = true)]
stdout_file: Option<std::path::PathBuf>,
#[cfg(feature = "stream-redirect")]
#[arg(long, value_name = "PATH", global = true)]
stderr_file: Option<std::path::PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Config {
#[command(subcommand)]
action: ConfigAction,
},
Service {
#[command(subcommand)]
action: ServiceAction,
},
Ping {
#[arg(long)]
host: Option<String>,
#[arg(long, default_value = "5000")]
timeout_ms: u64,
},
Cancel,
#[cfg(feature = "skill-admin")]
Skill {
#[command(subcommand)]
action: SkillCmd,
},
}
#[cfg(feature = "skill-admin")]
#[derive(Subcommand)]
enum SkillCmd {
Status(SkillTargetArgs),
Install(SkillWriteArgs),
Uninstall(SkillWriteArgs),
}
#[cfg(feature = "skill-admin")]
#[derive(clap::Args)]
struct SkillTargetArgs {
#[arg(long, default_value = "all")]
agent: String,
#[arg(long, default_value = "personal")]
scope: String,
#[arg(long)]
skills_dir: Option<String>,
}
#[cfg(feature = "skill-admin")]
#[derive(clap::Args)]
struct SkillWriteArgs {
#[command(flatten)]
target: SkillTargetArgs,
#[arg(long)]
force: bool,
}
#[derive(Subcommand)]
enum ConfigAction {
Show,
Set {
#[arg(long)]
key: String,
#[arg(long)]
value: String,
},
}
#[derive(Subcommand)]
enum ServiceAction {
Start {
#[arg(long, default_value = "8080")]
port: u16,
#[arg(long)]
api_key_secret: Option<String>,
},
Stop,
Status,
}
fn main() {
let raw: Vec<String> = std::env::args().collect();
#[cfg(feature = "stream-redirect")]
let _stream_redirect =
match agent_first_data::stream_redirect::install_from_raw_args(raw.clone()) {
Ok(installed) => installed,
Err(err) => emit_error_exit(
OutputFormat::Json,
build_cli_error(&err.to_string(), None),
2,
),
};
let build = match env!("GIT_SHA") {
"unknown" => None,
sha => Some(sha),
};
#[cfg(feature = "cli-help")]
{
match agent_first_data::cli_handle_version_or_help_or_continue(
&raw,
&Cli::command(),
&agent_first_data::HelpConfig::output_aware(),
"agent-cli",
Some(AGENT_CLI_EXAMPLE_DISPLAY_NAME),
AGENT_CLI_VERSION,
build,
) {
Ok(Some(help)) => {
stdout!("{help}");
std::process::exit(0);
}
Ok(None) => {}
Err(err) => emit_value_error_exit(OutputFormat::Json, err, 2),
}
}
#[cfg(not(feature = "cli-help"))]
match agent_first_data::cli_handle_version_or_continue(
&raw,
&Cli::command(),
"agent-cli",
Some(AGENT_CLI_EXAMPLE_DISPLAY_NAME),
AGENT_CLI_VERSION,
build,
) {
Ok(Some(version)) => {
stdout!("{version}");
std::process::exit(0);
}
Ok(None) => {}
Err(err) => emit_error_exit(OutputFormat::Json, err, 2),
}
let cli = Cli::try_parse().unwrap_or_else(|e| {
if matches!(
e.kind(),
clap::error::ErrorKind::DisplayVersion | clap::error::ErrorKind::DisplayHelp
) {
e.exit();
}
emit_error_exit(
OutputFormat::Json,
build_cli_error(&e.to_string(), Some("try: agent-cli --help")),
2,
)
});
#[cfg(feature = "stream-redirect")]
let _stream_redirect_args = (&cli.stdout_file, &cli.stderr_file);
let output = cli.output.clone();
let format = cli_parse_output(&output).unwrap_or_else(|e| {
emit_error_exit(
OutputFormat::Json,
build_cli_error(&e, Some("valid values: json, yaml, plain")),
2,
)
});
let log = if cli.verbose {
let mut entries: Vec<String> = cli.log.clone();
entries.push("all".to_string());
cli_parse_log_filters(&entries)
} else {
cli_parse_log_filters(&cli.log)
};
let mut emitter = CliEmitter::finite(format);
if log.enabled("request") {
let _ = emitter.emit(build_request_log(cli.command.as_ref()));
}
if log.enabled("startup") {
let _ = emitter.emit(build_startup_log(
&raw,
cli.command.as_ref(),
&output,
&log,
cli.verbose,
));
}
match cli.command {
None => exit_with(emitter.finish(
build_cli_error("no subcommand provided", Some("try: agent-cli --help")),
2,
)),
Some(Command::Config { action }) => match action {
ConfigAction::Show => {
exit_with(emitter.finish_result(serde_json::json!({"action": "config_show"})))
}
ConfigAction::Set { key, value } => exit_with(emitter.finish_result(
serde_json::json!({"action": "config_set", "key": key, "value": value}),
)),
},
Some(Command::Service { action }) => match action {
ServiceAction::Start {
port,
api_key_secret,
} => exit_with(emitter.finish_result(
serde_json::json!({"action": "service_start", "port": port, "api_key_secret": api_key_secret}),
)),
ServiceAction::Stop => {
exit_with(emitter.finish_result(serde_json::json!({"action": "service_stop"})))
}
ServiceAction::Status => {
exit_with(emitter.finish_result(serde_json::json!({"action": "service_status"})))
}
},
Some(Command::Ping { host, timeout_ms }) => {
let host = host.or_else(|| std::env::var(AGENT_CLI_HOST_ENV).ok());
if host.is_none() {
let err = json_error("ping_target_not_configured", "ping target not configured")
.hint("pass --host or set AGENT_CLI_HOST")
.field("duration_ms", serde_json::json!(0))
.build()
.expect("error builder failed");
exit_with(emitter.finish(err, 1));
}
exit_with(emitter.finish_result(
serde_json::json!({"action": "ping", "host": host, "timeout_ms": timeout_ms}),
))
}
Some(Command::Cancel) => {
let err = json_error("cancelled", "operation cancelled")
.hint("the operation was cancelled before completion")
.field("duration_ms", serde_json::json!(0))
.build()
.expect("error builder failed");
exit_with(emitter.finish(err, 1))
}
#[cfg(feature = "skill-admin")]
Some(Command::Skill { action }) => {
std::process::exit(run_skill(&mut emitter, action));
}
}
}
fn command_label(command: Option<&Command>) -> &'static str {
match command {
None => "none",
Some(Command::Config { .. }) => "config",
Some(Command::Service { .. }) => "service",
Some(Command::Ping { .. }) => "ping",
Some(Command::Cancel) => "cancel",
#[cfg(feature = "skill-admin")]
Some(Command::Skill { .. }) => "skill",
}
}
fn build_request_log(command: Option<&Command>) -> Event {
agent_first_data::json_log(serde_json::json!({
"level": "info",
"message": "request",
"category": "request",
"command": command_label(command),
}))
.build()
}
fn build_startup_log(
raw: &[String],
command: Option<&Command>,
output: &str,
log: &agent_first_data::LogFilters,
verbose: bool,
) -> Event {
agent_first_data::json_log(serde_json::json!({
"level": "info",
"message": "startup",
"category": "startup",
"event": "startup",
"argv": agent_first_data::redact_argv(raw),
"parsed": {
"command": command_label(command),
"output": output,
"log": log.as_slice(),
"verbose": verbose,
},
"effective_config": {
"output": output,
"log": log.as_slice(),
},
"env": startup_env_snapshot(),
}))
.build()
}
fn startup_env_snapshot() -> serde_json::Value {
serde_json::Value::Array(
STARTUP_ENV_KEYS
.iter()
.map(|key| {
serde_json::json!({
"key": key,
"present": std::env::var_os(*key).is_some(),
"value": std::env::var(*key).ok(),
})
})
.collect(),
)
}
#[cfg(feature = "skill-admin")]
fn run_skill(emitter: &mut CliEmitter<std::io::Stdout>, action: SkillCmd) -> i32 {
use agent_first_data::skill::{self, SkillAction};
let (verb, target, force) = match action {
SkillCmd::Status(target) => (SkillAction::Status, target, false),
SkillCmd::Install(write) => (SkillAction::Install, write.target, write.force),
SkillCmd::Uninstall(write) => (SkillAction::Uninstall, write.target, write.force),
};
let options = match build_skill_options(target, force) {
Ok(options) => options,
Err((message, hint)) => {
let _ = emitter.emit(build_cli_error(&message, Some(&hint)));
return 2;
}
};
match skill::run_skill_admin(&WIDGET_SPEC, verb, &options) {
Ok(report) => match serde_json::to_value(&report) {
Ok(value) => {
let _ = emitter.emit_result(value);
0
}
Err(e) => {
let _ = emitter.emit(build_cli_error(&e.to_string(), None));
1
}
},
Err(err) => {
let _ = emitter.emit(build_cli_error(&err.message, err.hint.as_deref()));
1
}
}
}
#[cfg(feature = "skill-admin")]
fn build_skill_options(
target: SkillTargetArgs,
force: bool,
) -> Result<agent_first_data::skill::SkillOptions, (String, String)> {
use agent_first_data::skill::{SkillAgentSelection, SkillOptions, SkillScope};
let agent = match target.agent.as_str() {
"all" => SkillAgentSelection::All,
"codex" => SkillAgentSelection::Codex,
"claude-code" => SkillAgentSelection::ClaudeCode,
"opencode" => SkillAgentSelection::Opencode,
"hermes" => SkillAgentSelection::Hermes,
other => {
return Err((
format!("invalid --agent '{other}'"),
"valid values: all, codex, claude-code, opencode, hermes".to_string(),
));
}
};
let scope = match target.scope.as_str() {
"personal" => SkillScope::Personal,
"workspace" => SkillScope::Workspace,
other => {
return Err((
format!("invalid --scope '{other}'"),
"valid values: personal, workspace".to_string(),
));
}
};
Ok(SkillOptions {
agent,
scope,
skills_dir: target.skills_dir,
force,
})
}
#[cfg(test)]
mod tests {
use super::*;
use agent_first_data::OutputFormat;
#[cfg(feature = "cli-help")]
#[test]
fn help_root_contains_all_subcommands() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &[]);
assert!(help.contains("config"), "must include config");
assert!(help.contains("service"), "must include service");
assert!(help.contains("ping"), "must include ping");
assert!(help.contains("--output"), "must include global --output");
assert!(help.contains("--log"), "must include global --log");
}
#[cfg(feature = "cli-help")]
#[test]
fn help_root_contains_nested_commands() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &[]);
assert!(help.contains("config show"), "must include config show");
assert!(help.contains("config set"), "must include config set");
assert!(help.contains("service start"), "must include service start");
assert!(help.contains("service stop"), "must include service stop");
assert!(
help.contains("service status"),
"must include service status"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_root_contains_secret_flags() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &[]);
assert!(
help.contains("--api-key-secret"),
"must include secret flag"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_root_contains_suffix_flags() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &[]);
assert!(
help.contains("--timeout-ms"),
"must include timeout_ms flag"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_one_level_plain_omits_nested_details() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::OneLevel,
format: agent_first_data::HelpFormat::Plain,
},
);
assert!(help.contains("config"), "one-level help must list config");
assert!(help.contains("service"), "one-level help must list service");
assert!(help.contains("ping"), "one-level help must list ping");
assert!(
!help.contains("Print this message or the help"),
"one-level help should not advertise clap's help pseudo-command"
);
assert!(
help.contains("--output"),
"one-level help must include globals"
);
assert!(
help.contains("--version"),
"one-level help must include clap's version flag"
);
assert!(
!help.contains("AFDATA:"),
"help should point to --version instead of embedding version metadata"
);
assert!(
!help.contains("--help-all"),
"one-level help must not advertise removed recursive help flag"
);
assert!(
!help.contains("--stream"),
"one-level help must not advertise stream mode"
);
assert!(
!help.contains("--result-only"),
"one-level help must not advertise result-only mode"
);
assert!(
!help.contains("config show"),
"one-level root help must not expand config show"
);
assert!(
!help.contains("--api-key-secret"),
"one-level root help must not include service start flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_subcommand_scoped() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &["service"]);
assert!(help.contains("start"), "service help must include start");
assert!(help.contains("stop"), "service help must include stop");
assert!(help.contains("status"), "service help must include status");
assert!(
!help.contains("config show"),
"service help must NOT include config show"
);
assert!(
!help.contains("--timeout-ms"),
"service help must NOT include ping's --timeout-ms"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_nested_subcommand_scoped() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &["service", "start"]);
assert!(
help.contains("--port"),
"service start help must include --port"
);
assert!(
help.contains("--api-key-secret"),
"service start help must include --api-key-secret"
);
assert!(
!help.contains("service stop"),
"service start help must NOT include stop"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn scoped_help_progressively_discloses_inherited_global_arguments() {
let cmd = clap::Command::new("Example Tool")
.bin_name("tool")
.arg(
clap::Arg::new("workspace")
.long("workspace")
.global(true)
.value_name("PATH")
.help("Workspace path"),
)
.subcommand(
clap::Command::new("sync").arg(
clap::Arg::new("limit")
.long("limit")
.value_name("COUNT")
.help("Maximum records"),
),
);
let plain = agent_first_data::cli_render_help_with_options(
&cmd,
&["sync"],
&agent_first_data::HelpOptions::one_level_plain(),
);
assert!(
plain.contains("Usage: tool sync"),
"scoped plain help needs an invocable usage:\n{plain}"
);
assert!(
plain.contains("--workspace"),
"conventional scoped help must include inherited globals:\n{plain}"
);
let structured = agent_first_data::cli_render_help_with_options(
&cmd,
&["sync"],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::OneLevel,
format: agent_first_data::HelpFormat::Json,
},
);
let event: serde_json::Value =
serde_json::from_str(&structured).expect("structured help must parse");
assert_eq!(event["kind"], "result");
assert_eq!(event["result"]["code"], "help");
let help = &event["result"]["help"];
assert_eq!(help["command_path"], "tool sync");
assert_eq!(
help["inherited_arguments_from"],
serde_json::json!(["tool"])
);
assert!(help["arguments"].as_array().is_some_and(|arguments| {
arguments
.iter()
.any(|argument| argument["name"] == "--limit")
}));
assert!(
!structured.contains("--workspace"),
"structured scoped help must reference, not repeat, inherited globals:\n{structured}"
);
let recursive = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::Recursive,
format: agent_first_data::HelpFormat::Json,
},
);
assert_eq!(
recursive.matches("--workspace").count(),
1,
"recursive help must define a global argument exactly once:\n{recursive}"
);
let recursive_event: serde_json::Value =
serde_json::from_str(&recursive).expect("recursive help must parse");
assert_eq!(
recursive_event["result"]["help"]["arguments"][0]["global"],
true
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_is_plain_text() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &[]);
assert!(
!help.contains("\n# "),
"plain text must not have markdown headings"
);
assert!(
!help.contains("**"),
"plain text must not have markdown bold"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_root_inherits_command_output_default() {
let cmd = Cli::command();
let raw = vec!["agent-cli".to_string(), "--help".to_string()];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("help should render");
let event: serde_json::Value =
serde_json::from_str(&help).expect("default help must be JSON");
assert_eq!(event["kind"], "result");
assert_eq!(event["result"]["code"], "help");
assert!(
event.to_string().contains("--output"),
"root help must include globals"
);
assert!(
!event.to_string().contains("--api-key-secret"),
"one-level default must not recursively expand leaf flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_uses_caller_fallback_without_output_argument() {
let cmd = clap::Command::new("fixed-json");
let raw = vec!["fixed-json".to_string(), "--help".to_string()];
let config = agent_first_data::HelpConfig::output_aware_with_fallback(
agent_first_data::HelpFormat::Json,
);
let help = agent_first_data::cli_handle_help_or_continue(&raw, &cmd, &config)
.expect("valid help request")
.expect("help should render");
let event: serde_json::Value =
serde_json::from_str(&help).expect("JSON fallback must render a JSON event");
assert_eq!(event["kind"], "result");
assert_eq!(event["result"]["code"], "help");
assert_eq!(event["result"]["help"]["command_path"], "fixed-json");
let plain_raw = vec![
"fixed-json".to_string(),
"--help".to_string(),
"--output".to_string(),
"plain".to_string(),
];
let plain = agent_first_data::cli_handle_help_or_continue(&plain_raw, &cmd, &config)
.expect("valid explicit plain request")
.expect("plain help should render");
assert!(
plain.contains("Usage: fixed-json"),
"an explicit help output must override the fallback: {plain}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_inherits_selected_subcommand_output_default() {
let cmd = clap::Command::new("tool").subcommand(
clap::Command::new("child").arg(
clap::Arg::new("output")
.long("output")
.default_value("yaml"),
),
);
let raw = vec![
"tool".to_string(),
"child".to_string(),
"--help".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("help should render");
assert!(
help.starts_with("---") && help.contains("kind: \"result\""),
"selected child default must choose YAML help:\n{help}"
);
assert!(
help.contains("command_path: \"tool child\""),
"selected command path must survive structured rendering:\n{help}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_root_advertises_recursive_modifier() {
let cmd = Cli::command();
let raw = vec!["agent-cli".to_string(), "--help".to_string()];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("help should render");
assert!(
help.contains("--recursive"),
"one-level root help must advertise the --recursive modifier:\n{help}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_leaf_command_omits_recursive_modifier() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"ping".to_string(),
"--help".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("help should render");
assert!(
!help.contains("--recursive"),
"a leaf command with no subcommands must not advertise --recursive:\n{help}"
);
assert!(
help.contains("--output"),
"even a leaf --help must document the --output formats:\n{help}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_always_documents_formats_in_every_output() {
for output in ["plain", "json", "yaml", "markdown"] {
for extra in [Vec::new(), vec!["--recursive".to_string()]] {
let mut raw = vec!["agent-cli".to_string(), "--help".to_string()];
raw.extend(extra.iter().cloned());
raw.push("--output".to_string());
raw.push(output.to_string());
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&Cli::command(),
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.unwrap_or_else(|| panic!("help should render for --output {output} {extra:?}"));
for token in ["--recursive", "--output", "json", "yaml", "markdown"] {
assert!(
help.contains(token),
"--help --output {output} {extra:?} must document '{token}':\n{help}"
);
}
}
}
}
#[cfg(feature = "cli-help")]
fn secret_default_help_command() -> clap::Command {
clap::Command::new("secret-defaults").arg(
clap::Arg::new("api_key_secret")
.long("api-key-secret")
.default_value("sk-help-default")
.help("API key default shown only as a redacted value"),
)
}
#[cfg(feature = "cli-help")]
fn security_help_default_case() -> serde_json::Value {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/spec/fixtures/security.json");
let data = std::fs::read_to_string(path).expect("read security fixture");
let fixture: serde_json::Value =
serde_json::from_str(&data).expect("parse security fixture");
fixture["help_default_cases"][0].clone()
}
#[cfg(feature = "cli-help")]
#[test]
fn help_redacts_secret_default_values_in_every_output() {
let help_case = security_help_default_case();
let default = help_case["default"].as_str().expect("fixture default");
let expected = help_case["expected"].as_str().expect("fixture expected");
assert_eq!(default, "sk-help-default");
assert_eq!(expected, "***");
for output in ["plain", "json", "yaml", "markdown"] {
let raw = vec![
"secret-defaults".to_string(),
"--help".to_string(),
"--output".to_string(),
output.to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&secret_default_help_command(),
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.unwrap_or_else(|| panic!("help should render for --output {output}"));
assert!(
help.contains(expected),
"--help --output {output} must show the redaction marker:\n{help}"
);
assert!(
!help.contains(default),
"--help --output {output} must not leak secret defaults:\n{help}"
);
}
}
#[cfg(feature = "cli-help")]
#[test]
fn recursive_help_documents_modifiers_once() {
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--recursive".to_string(),
"--output".to_string(),
"plain".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&Cli::command(),
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("recursive help should render");
let occurrences = help.matches("\n --output <OUTPUT>").count();
assert_eq!(
occurrences, 1,
"recursive plain help must list the global output option exactly once \
(found {occurrences}):\n{help}"
);
assert!(
!help.contains("════════"),
"recursive plain help must use the compact index rather than full help blocks:\n{help}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_output_plain_is_one_level() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"plain".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("plain help should render");
assert!(help.contains("--output"), "plain help must include globals");
assert!(
!help.contains("--api-key-secret"),
"plain help must stay one-level"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_output_json_without_recursive_is_one_level() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"json".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("json help should render");
let parsed: serde_json::Value = serde_json::from_str(&help).expect("json help must parse");
assert_eq!(parsed["result"]["code"], "help");
assert_eq!(parsed["result"]["help"]["scope"], "one_level");
assert_eq!(parsed["result"]["help"]["command_path"], "agent-cli");
assert!(parsed["result"]["help"].get("code").is_none());
assert!(parsed["result"]["help"].get("versions").is_none());
assert!(
parsed["result"]["help"].get("description").is_none()
&& parsed["result"]["help"]
.get("description_markdown")
.is_none(),
"default structured help must not embed long-form Markdown"
);
assert!(
!parsed.to_string().contains("### Interface Policy"),
"default structured help must not embed Clap long_about"
);
assert!(
!parsed.to_string().contains("api_key_secret"),
"one-level json must not expand nested leaf flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn combined_version_or_help_handler_discloses_version_only_on_request() {
let cmd = clap::Command::new("Agent-First Custom")
.bin_name("custom")
.disable_version_flag(true)
.arg(
clap::Arg::new("output")
.long("output")
.default_value("json"),
)
.arg(
clap::Arg::new("version")
.short('V')
.long("version")
.action(clap::ArgAction::SetTrue),
);
let config = agent_first_data::HelpConfig::output_aware();
let metadata = (
"custom",
Some("Agent-First Custom"),
"9.8.7",
Some("build-123"),
);
let version_raw = vec!["custom".to_string(), "--version".to_string()];
let version = agent_first_data::cli_handle_version_or_help_or_continue(
&version_raw,
&cmd,
&config,
metadata.0,
metadata.1,
metadata.2,
metadata.3,
)
.expect("valid version request")
.expect("version should render");
let version_event: serde_json::Value =
serde_json::from_str(&version).expect("version must be JSON");
let help_raw = vec!["custom".to_string(), "--help".to_string()];
let help = agent_first_data::cli_handle_version_or_help_or_continue(
&help_raw, &cmd, &config, metadata.0, metadata.1, metadata.2, metadata.3,
)
.expect("valid help request")
.expect("help should render");
let help_event: serde_json::Value = serde_json::from_str(&help).expect("help must be JSON");
assert_eq!(version_event["result"]["version"], "9.8.7");
assert_eq!(version_event["result"]["build"], "build-123");
assert_eq!(help_event["result"]["help"]["command_path"], "custom");
assert!(help_event["result"]["help"].get("versions").is_none());
assert!(!help.contains("9.8.7"));
assert!(!help.contains("build-123"));
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_recursive_output_json_is_recursive() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--recursive".to_string(),
"--output".to_string(),
"json".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("json help should render");
let parsed: serde_json::Value = serde_json::from_str(&help).expect("json help must parse");
assert_eq!(parsed["result"]["code"], "help");
assert_eq!(parsed["result"]["help"]["scope"], "recursive");
assert!(
parsed["result"]["help"].get("description").is_none()
&& parsed["result"]["help"]
.get("description_markdown")
.is_none(),
"recursive structured help must not embed long-form Markdown"
);
assert!(
parsed.to_string().contains("--api-key-secret"),
"recursive json export must include nested flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_recursive_plain_expands_tree() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--recursive".to_string(),
"--output".to_string(),
"plain".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("recursive plain help should render");
assert!(
help.contains("--api-key-secret"),
"recursive plain help must expand nested leaf flags"
);
assert!(
!help.contains("\n# "),
"recursive plain help must stay plain text"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_bare_recursive_falls_through() {
let cmd = Cli::command();
let raw = vec!["agent-cli".to_string(), "--recursive".to_string()];
assert!(
agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid non-help request")
.is_none(),
"a bare --recursive without --help must fall through to clap"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_scopes_subcommand_help() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"service".to_string(),
"--help".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("service help should render");
assert!(help.contains("start"), "service help must list start");
assert!(
!help.contains("--api-key-secret"),
"one-level service help must not expand service start flags"
);
assert!(
!help.contains("--timeout-ms"),
"service help must not include ping flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_does_not_intercept_help_pseudo_command() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"help".to_string(),
"service".to_string(),
];
assert!(
agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid non-helper request")
.is_none(),
"`help <subcommand>` is not a recommended afdata help path"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_does_not_intercept_help_all_flag() {
let cmd = Cli::command();
let raw = vec!["agent-cli".to_string(), "--help-all".to_string()];
assert!(
agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid non-helper request")
.is_none(),
"`--help-all` is not part of the canonical afdata help path"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_markdown_without_recursive_is_one_level() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"markdown".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("markdown help should render");
assert!(
help.contains(&format!(
"# {} - {}",
"agent-cli", "Minimal agent-first CLI example"
)),
"markdown heading must use display name and Cargo package description"
);
assert!(
help.contains("### Interface Policy"),
"Markdown help must preserve the full long-form description"
);
assert!(help.contains("```text"), "markdown must wrap clap help");
assert_eq!(
help.matches("Agent-facing CLI surfaces should keep stdout structured.")
.count(),
1,
"long_about should render once, outside the fenced clap help block"
);
assert!(
!help.contains("--api-key-secret"),
"one-level markdown must not expand nested leaf flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_recursive_markdown_output() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--recursive".to_string(),
"--output".to_string(),
"markdown".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("markdown help should render");
assert!(help.contains("# agent-cli"), "markdown must have heading");
assert!(help.contains("```text"), "markdown must wrap clap help");
assert_eq!(
help.matches("Agent-facing CLI surfaces should keep stdout structured.")
.count(),
1,
"recursive markdown should not repeat root long_about in fenced help"
);
assert!(
help.contains("--api-key-secret"),
"recursive markdown export must include nested flags"
);
assert!(
!help.contains("Print this message or the help of the given subcommand"),
"Markdown must not resurrect clap's help pseudo-command"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_supports_inline_output_format() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output=json".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("json help should render");
let parsed: serde_json::Value =
serde_json::from_str(&help).expect("inline json help must parse");
assert_eq!(parsed["result"]["code"], "help");
assert_eq!(parsed["result"]["help"]["scope"], "one_level");
assert_eq!(parsed["result"]["help"]["name"], "agent-cli");
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_invalid_output_format_is_error() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"xml".to_string(),
];
let err = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect_err("invalid help output must return error");
assert_eq!(err["kind"], "error");
assert_eq!(err["error"]["code"], "cli_error");
assert!(
err["error"]["message"]
.as_str()
.is_some_and(|s| s.contains("xml")),
"error should mention invalid value: {err}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_protocol_v1_json_wraps_help_schema() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"json".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("json help should render");
let event: serde_json::Value = serde_json::from_str(help.trim()).expect("json event");
assert_eq!(event["kind"], "result");
assert_eq!(event["result"]["code"], "help");
assert!(event["result"]["help"].is_object());
assert_eq!(event["trace"], serde_json::json!({}));
agent_first_data::validate_protocol_event(&event, true).expect("strict event");
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_protocol_v1_yaml_wraps_help_schema() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"yaml".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("yaml help should render");
assert!(help.contains("kind: \"result\""), "{help}");
assert!(help.contains("code: \"help\""), "{help}");
assert!(help.contains("trace: {}"), "{help}");
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_protocol_v1_invalid_format_has_trace() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
"xml".to_string(),
];
let event = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect_err("invalid help output must fail");
assert_eq!(event["kind"], "error");
assert_eq!(event["error"]["code"], "cli_error");
assert_eq!(event["trace"], serde_json::json!({}));
agent_first_data::validate_protocol_event(&event, true).expect("strict event");
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_missing_output_format_is_error() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--help".to_string(),
"--output".to_string(),
];
let err = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect_err("missing help output value must return error");
assert_eq!(err["kind"], "error");
assert_eq!(err["error"]["code"], "cli_error");
assert!(
err["error"]["message"]
.as_str()
.is_some_and(|s| s.contains("missing value")),
"error should mention missing value: {err}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_missing_output_before_help_is_error() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--output".to_string(),
"--help".to_string(),
];
let err = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect_err("missing help output value must return error");
assert_eq!(err["kind"], "error");
assert_eq!(err["error"]["code"], "cli_error");
assert!(
err["error"]["message"]
.as_str()
.is_some_and(|s| s.contains("missing value")),
"error should mention missing value: {err}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_skips_flag_values_when_finding_subcommands() {
let cmd = Cli::command();
let raw = vec![
"agent-cli".to_string(),
"--log".to_string(),
"service".to_string(),
"--help".to_string(),
"--recursive".to_string(),
"--output".to_string(),
"markdown".to_string(),
];
let help = agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid help request")
.expect("markdown help should render");
assert!(
help.contains("config show"),
"flag value named like a subcommand must not scope help"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_handler_without_help_returns_none() {
let cmd = Cli::command();
let raw = vec!["agent-cli".to_string(), "ping".to_string()];
assert!(
agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("valid non-help request")
.is_none(),
"non-help invocations must continue to clap"
);
}
#[cfg(feature = "cli-help-markdown")]
#[test]
fn help_markdown_root_contains_all() {
let cmd = Cli::command();
let md = agent_first_data::cli_render_help_markdown(&cmd, &[]);
assert!(md.contains("config"), "markdown must include config");
assert!(md.contains("service"), "markdown must include service");
assert!(md.contains("ping"), "markdown must include ping");
assert!(
md.contains("--api-key-secret"),
"markdown must include secret flag"
);
assert!(
md.contains("--timeout-ms"),
"markdown must include timeout flag"
);
}
#[cfg(feature = "cli-help-markdown")]
#[test]
fn help_markdown_has_headings() {
let cmd = Cli::command();
let md = agent_first_data::cli_render_help_markdown(&cmd, &[]);
assert!(md.contains('#'), "markdown must have headings");
}
#[cfg(feature = "cli-help")]
#[test]
fn help_markdown_one_level_omits_descendant_details() {
let cmd = Cli::command();
let md = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::OneLevel,
format: agent_first_data::HelpFormat::Markdown,
},
);
assert!(md.contains("# agent-cli"), "markdown must include root");
assert!(
!md.contains("--api-key-secret"),
"one-level markdown must not include nested flags"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_markdown_strips_about_from_leading_long_about() {
let cmd = clap::Command::new("sample")
.about("Shared summary")
.long_about("Shared summary\n\nDetailed policy.");
let md = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::OneLevel,
format: agent_first_data::HelpFormat::Markdown,
},
);
assert!(md.contains("# sample - Shared summary"));
assert_eq!(
md.matches("Shared summary").count(),
1,
"about should live in the heading, not repeat at the start of long_about"
);
assert!(md.contains("Detailed policy."));
}
#[cfg(feature = "cli-help")]
#[test]
fn help_markdown_suppresses_name_dash_about_body() {
let cmd = clap::Command::new("Agent CLI")
.about("Brief summary")
.long_about("Agent CLI - Brief summary");
let md = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::OneLevel,
format: agent_first_data::HelpFormat::Markdown,
},
);
assert!(
md.contains("# Agent CLI - Brief summary"),
"heading must include name and about"
);
assert_eq!(
md.matches("Brief summary").count(),
1,
"when long_about equals 'name - about', body is suppressed to avoid duplication"
);
}
#[cfg(feature = "cli-help-markdown")]
#[test]
fn help_markdown_no_footer() {
let cmd = Cli::command();
let md = agent_first_data::cli_render_help_markdown(&cmd, &[]);
assert!(
!md.contains("<hr/>"),
"markdown must not have clap-markdown footer"
);
assert!(
!md.contains("<small>"),
"markdown must not have clap-markdown footer"
);
}
#[cfg(feature = "cli-help-markdown")]
#[test]
fn help_markdown_subcommand_scoped() {
let cmd = Cli::command();
let md = agent_first_data::cli_render_help_markdown(&cmd, &["service"]);
assert!(
md.contains("--api-key-secret"),
"service markdown must include secret flag"
);
assert!(
!md.contains("--timeout-ms"),
"service markdown must NOT include ping's --timeout-ms"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn short_help_flag_is_left_to_the_application() {
let cmd = clap::Command::new("tool")
.disable_help_flag(true)
.arg(
clap::Arg::new("output")
.long("output")
.default_value("json"),
)
.arg(
clap::Arg::new("host")
.short('h')
.long("host")
.value_name("HOST"),
);
let raw = vec!["tool".to_string(), "-h".to_string(), "myhost".to_string()];
assert!(
agent_first_data::cli_handle_help_or_continue(
&raw,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("-h must not be a malformed help request")
.is_none(),
"-h must fall through to the application's own parser"
);
let long = vec!["tool".to_string(), "--help".to_string()];
let rendered = agent_first_data::cli_handle_help_or_continue(
&long,
&cmd,
&agent_first_data::HelpConfig::output_aware(),
)
.expect("--help must render")
.expect("--help is always a help request");
let event: serde_json::Value =
serde_json::from_str(&rendered).expect("structured help must parse");
let arguments = event["result"]["help"]["arguments"]
.as_array()
.expect("help lists arguments");
assert!(
arguments
.iter()
.any(|arg| arg["name"] == "--host" && arg["short"] == "-h"),
"an application short must survive in the model: {arguments:?}"
);
assert!(
arguments
.iter()
.any(|arg| arg["name"] == "--help" && arg.get("short").is_none()),
"--help must be advertised without a short: {arguments:?}"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_json_schema_is_parseable() {
let cmd = Cli::command();
let json = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::OneLevel,
format: agent_first_data::HelpFormat::Json,
},
);
let event: serde_json::Value = serde_json::from_str(&json).expect("help json must parse");
assert_eq!(event["kind"], "result");
assert_eq!(event["result"]["code"], "help");
let parsed = &event["result"]["help"];
assert_eq!(parsed["scope"], "one_level");
assert_eq!(parsed["command_path"], "agent-cli");
assert_eq!(parsed["name"], "agent-cli");
assert!(
parsed["arguments"]
.as_array()
.is_some_and(|arguments| arguments.iter().any(|arg| arg["name"] == "--version")),
"structured help must include clap's lazy --version flag"
);
assert!(
parsed["subcommands"][0].get("arguments").is_none(),
"compact one-level summaries must omit empty argument metadata"
);
assert!(
parsed.get("code").is_none()
&& parsed.get("versions").is_none()
&& parsed.get("path").is_none()
&& parsed.get("long_about").is_none()
&& parsed.get("description").is_none()
&& parsed.get("description_markdown").is_none(),
"structured help must omit redundant paths and long-form Markdown"
);
}
#[cfg(feature = "cli-help")]
#[test]
fn help_yaml_schema_is_raw_yaml() {
let cmd = Cli::command();
let yaml = agent_first_data::cli_render_help_with_options(
&cmd,
&[],
&agent_first_data::HelpOptions {
scope: agent_first_data::HelpScope::Recursive,
format: agent_first_data::HelpFormat::Yaml,
},
);
assert!(yaml.starts_with("---"), "yaml help must start with marker");
assert!(
yaml.contains("scope: \"recursive\""),
"yaml help must include recursive scope"
);
assert!(
yaml.contains("command_path: \"agent-cli\""),
"yaml help must include the invocable command path"
);
assert!(!yaml.contains("versions:"));
assert!(
yaml.contains("--api-key-secret"),
"raw help schema must preserve secret-like flag names"
);
}
#[test]
fn parse_output_all_variants() {
assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
assert!(matches!(cli_parse_output("yaml"), Ok(OutputFormat::Yaml)));
assert!(matches!(cli_parse_output("plain"), Ok(OutputFormat::Plain)));
assert!(cli_parse_output("xml").is_err());
}
#[test]
fn parse_log_normalizes() {
let f = cli_parse_log_filters(&["Startup", " REQUEST ", "startup"]);
assert_eq!(f.as_slice(), &["startup", "request"]);
}
#[test]
fn log_filter_is_explicit_with_wildcards() {
assert!(!cli_parse_log_filters::<String>(&[]).enabled("startup"));
assert!(!cli_parse_log_filters(&["query.result"]).enabled("startup"));
assert!(cli_parse_log_filters(&["startup"]).enabled("startup"));
assert!(cli_parse_log_filters(&["all"]).enabled("startup"));
assert!(cli_parse_log_filters(&["all"]).enabled("request"));
assert!(!cli_parse_log_filters(&["*"]).enabled("request"));
assert!(!cli_parse_log_filters(&["startup"]).enabled("request"));
}
#[test]
fn request_log_is_category_tagged() {
let v = build_request_log(None).into_value();
assert_eq!(v["kind"], "log");
assert_eq!(v["log"]["category"], "request");
assert_eq!(v["log"]["command"], "none");
}
#[test]
fn startup_log_contains_parsed_config_and_scoped_env() {
let log = cli_parse_log_filters(&["startup"]);
let command = Command::Ping {
host: Some("example.com".to_string()),
timeout_ms: 5000,
};
let raw = vec![
"agent-cli".to_string(),
"--api-key-secret".to_string(),
"sk-test".to_string(),
"ping".to_string(),
];
let v = build_startup_log(&raw, Some(&command), "yaml", &log, false).into_value();
assert_eq!(
v["log"]["argv"],
serde_json::json!(["agent-cli", "--api-key-secret", "***", "ping"])
);
assert_eq!(v["kind"], "log");
assert_eq!(v["log"]["category"], "startup");
assert_eq!(v["log"]["event"], "startup");
assert_eq!(
v["log"]["parsed"],
serde_json::json!({
"command": "ping",
"output": "yaml",
"log": ["startup"],
"verbose": false,
})
);
assert_eq!(
v["log"]["effective_config"],
serde_json::json!({
"output": "yaml",
"log": ["startup"],
})
);
let env = v["log"]["env"].as_array().expect("env must be an array");
let host = env
.iter()
.find(|entry| entry["key"] == AGENT_CLI_HOST_ENV)
.expect("startup env must include exact AGENT_CLI_HOST key");
assert!(host["present"].is_boolean());
if std::env::var_os(AGENT_CLI_HOST_ENV).is_some() {
assert!(host["value"].is_string());
} else {
assert!(host["value"].is_null());
}
}
#[test]
fn build_cli_error_structure() {
let v = build_cli_error("--output: invalid value 'xml'", None).into_value();
assert_eq!(v["kind"], "error");
assert_eq!(v["error"]["code"], "cli_error");
assert_eq!(v["error"]["message"], "--output: invalid value 'xml'");
assert!(v.get("error_code").is_none());
assert!(v.get("retryable").is_none());
assert!(v["trace"].is_object());
}
#[test]
fn build_cli_error_with_hint() {
let v =
build_cli_error("unknown action: foo", Some("valid actions: echo, ping")).into_value();
assert_eq!(v["kind"], "error");
assert_eq!(v["error"]["hint"], "valid actions: echo, ping");
}
#[test]
fn json_error_with_hint() {
let v = json_error("not_configured", "not configured")
.hint("set PING_HOST")
.build()
.expect("error builder failed")
.into_value();
assert_eq!(v["kind"], "error");
assert_eq!(v["error"]["code"], "not_configured");
assert_eq!(v["error"]["message"], "not configured");
assert_eq!(v["error"]["hint"], "set PING_HOST");
}
#[test]
fn json_error_without_hint_has_no_hint_key() {
let v = json_error("failed", "something failed")
.build()
.expect("error builder failed")
.into_value();
assert!(v["error"].get("hint").is_none());
}
#[test]
fn render_all_formats_compile_and_run() {
let v = json_result(serde_json::json!({"ok": true}))
.build()
.into_value();
let json_out = render(&v, OutputFormat::Json, &OutputOptions::default());
let yaml_out = render(&v, OutputFormat::Yaml, &OutputOptions::default());
let plain_out = render(&v, OutputFormat::Plain, &OutputOptions::default());
assert!(json_out.contains("\"kind\""));
assert!(yaml_out.starts_with("---"));
assert!(plain_out.contains("kind=result"));
}
#[test]
fn error_round_trip_is_valid_jsonl() {
let err = build_cli_error("unknown flag: --foo", None).into_value();
let line = render(
&err,
agent_first_data::OutputFormat::Json,
&OutputOptions::default(),
);
let parsed: serde_json::Value =
serde_json::from_str(&line).unwrap_or(serde_json::Value::Null);
assert_eq!(parsed["kind"], "error");
assert_eq!(parsed["error"]["code"], "cli_error");
assert!(!line.contains('\n'));
}
#[cfg(feature = "skill-admin")]
mod skill {
use super::*;
use agent_first_data::skill::{
SkillAction, SkillAgentSelection, SkillOptions, SkillScope, run_skill_admin,
};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_skills_dir(tag: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("agent_cli_skill_{tag}_{}", suffix))
}
#[cfg(feature = "cli-help")]
#[test]
fn help_includes_skill_subcommand() {
let cmd = Cli::command();
let help = agent_first_data::cli_render_help(&cmd, &[]);
assert!(help.contains("skill"), "root help must include skill");
assert!(
help.contains("skill install"),
"help must expand skill install"
);
assert!(
help.contains("--skills-dir"),
"help must include --skills-dir"
);
}
#[test]
fn build_options_parses_agents_and_scopes() {
let target = SkillTargetArgs {
agent: "opencode".to_string(),
scope: "workspace".to_string(),
skills_dir: Some("/tmp/x".to_string()),
};
let options = build_skill_options(target, true).expect("valid options");
assert_eq!(options.agent, SkillAgentSelection::Opencode);
assert_eq!(options.scope, SkillScope::Workspace);
assert!(options.force);
let workspace = SkillTargetArgs {
agent: "codex".to_string(),
scope: "workspace".to_string(),
skills_dir: None,
};
let options = build_skill_options(workspace, false).expect("valid options");
assert_eq!(options.agent, SkillAgentSelection::Codex);
assert_eq!(options.scope, SkillScope::Workspace);
}
#[test]
fn build_options_rejects_unknown_agent_and_scope() {
let bad_agent = SkillTargetArgs {
agent: "vim".to_string(),
scope: "personal".to_string(),
skills_dir: None,
};
assert!(build_skill_options(bad_agent, false).is_err());
let bad_scope = SkillTargetArgs {
agent: "codex".to_string(),
scope: "global".to_string(),
skills_dir: None,
};
assert!(build_skill_options(bad_scope, false).is_err());
}
#[test]
fn install_status_uninstall_widget_skill() {
let dir = temp_skills_dir("opencode");
let options = SkillOptions {
agent: SkillAgentSelection::Opencode,
scope: SkillScope::Personal,
skills_dir: Some(dir.to_string_lossy().to_string()),
force: false,
};
let installed = run_skill_admin(&WIDGET_SPEC, SkillAction::Install, &options);
assert!(installed.is_ok());
let skill_path = dir.join("agent-first-widget").join("SKILL.md");
assert!(skill_path.is_file());
let report =
run_skill_admin(&WIDGET_SPEC, SkillAction::Status, &options).expect("status ok");
let status = serde_json::to_value(&report).expect("serialize report");
assert_eq!(status["installed_all"], true);
assert_eq!(status["valid_all"], true);
assert_eq!(status["current_all"], true);
assert_eq!(status["targets"][0]["agent"], "opencode");
let removed = run_skill_admin(&WIDGET_SPEC, SkillAction::Uninstall, &options);
assert!(removed.is_ok());
assert!(!skill_path.exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn run_skill_returns_zero_on_success() {
let dir = temp_skills_dir("run");
let args = SkillTargetArgs {
agent: "codex".to_string(),
scope: "personal".to_string(),
skills_dir: Some(dir.to_string_lossy().to_string()),
};
let mut emitter = CliEmitter::finite(OutputFormat::Json);
let code = run_skill(
&mut emitter,
SkillCmd::Install(SkillWriteArgs {
target: args,
force: false,
}),
);
assert_eq!(code, 0);
let _ = std::fs::remove_dir_all(dir);
}
}
}