mod config_gen;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use tracing::info;
use crate::config::{load_config, ApexeConfig};
#[derive(Debug, Parser)]
#[command(name = "apexe", version, about, long_about = None)]
pub struct Cli {
#[arg(long, global = true)]
pub log_level: Option<String>,
#[arg(long, global = true, value_parser = clap::value_parser!(u64).range(1..))]
pub timeout: Option<u64>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Scan(ScanArgs),
Serve(ServeArgs),
A2a(A2aArgs),
List(ListArgs),
Config(ConfigArgs),
}
impl Cli {
pub fn effective_log_level(&self, config_level: &str) -> String {
self.log_level
.clone()
.unwrap_or_else(|| config_level.to_string())
}
pub fn run(self) -> anyhow::Result<()> {
let config = load_config(None)?.with_timeout_override(self.timeout);
config.ensure_dirs()?;
match self.command {
Commands::Scan(args) => args.execute(&config),
Commands::Serve(args) => args.execute(&config),
Commands::A2a(args) => args.execute(&config),
Commands::List(args) => args.execute(&config),
Commands::Config(args) => args.execute(&config),
}
}
}
#[derive(Debug, clap::Args)]
pub struct ScanArgs {
#[arg(required = true)]
pub tools: Vec<String>,
#[arg(long)]
pub output_dir: Option<PathBuf>,
#[arg(long, default_value = "2", value_parser = clap::value_parser!(u32).range(1..=5))]
pub depth: u32,
#[arg(long)]
pub no_cache: bool,
#[arg(long, default_value = "table", value_parser = ["json", "yaml", "table"])]
pub format: String,
#[arg(long)]
pub skills_dir: Option<PathBuf>,
#[arg(long)]
pub overlay: Option<PathBuf>,
#[arg(long)]
pub verify: bool,
#[arg(long)]
pub dry_run: bool,
}
impl ScanArgs {
pub fn execute(self, config: &ApexeConfig) -> anyhow::Result<()> {
let mut orchestrator = crate::scanner::ScanOrchestrator::new(config.clone());
if let Some(ref overlay_path) = self.overlay {
orchestrator
.load_overlay(overlay_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
let outcome = orchestrator.scan(&self.tools, self.no_cache, self.depth);
if outcome.is_total_failure() {
return Err(anyhow::anyhow!(
"No tool could be scanned:\n{}",
Self::render_failures(&outcome.failures)
));
}
let output_dir = self
.output_dir
.clone()
.unwrap_or_else(|| config.modules_dir.clone());
let converter = crate::adapter::CliToolConverter::new();
let modules = converter.convert_all(&outcome.tools);
self.write_bindings(&modules, &output_dir)?;
self.write_acl(&modules, config)?;
self.write_skills(&modules)?;
let scanned_count = outcome.tools.len();
self.print_results(outcome.tools, &modules)?;
Self::report_partial_run(
&outcome.failures,
scanned_count,
self.tools.len(),
&output_dir,
)
}
fn report_partial_run(
failures: &[crate::scanner::ScanFailure],
scanned_count: usize,
requested_count: usize,
output_dir: &std::path::Path,
) -> anyhow::Result<()> {
if failures.is_empty() {
return Ok(());
}
Err(anyhow::anyhow!(
"Scanned {} of {} tools; bindings for the {} that succeeded were written to {}.\n\
Failed:\n{}",
scanned_count,
requested_count,
scanned_count,
output_dir.display(),
Self::render_failures(failures)
))
}
fn render_failures(failures: &[crate::scanner::ScanFailure]) -> String {
failures
.iter()
.map(|failure| format!(" {}: {}", failure.tool, failure.error))
.collect::<Vec<_>>()
.join("\n")
}
fn write_bindings(
&self,
modules: &[apcore_toolkit::ScannedModule],
output_dir: &std::path::Path,
) -> anyhow::Result<()> {
let yaml_output = crate::output::YamlOutput::new();
let write_results = yaml_output
.write(modules, output_dir, self.dry_run)
.map_err(|e| anyhow::anyhow!("Failed to write binding files: {e}"))?;
let mut unverified: Vec<String> = Vec::new();
for wr in &write_results {
match (&wr.path, self.dry_run) {
(_, true) => info!(
module_id = %wr.module_id,
dir = %output_dir.display(),
"Would write binding"
),
(Some(path), false) => info!(path, "Generated binding"),
(None, false) => {
tracing::warn!(module_id = %wr.module_id, "Binding written with no path reported")
}
}
if !wr.verified {
let detail = wr
.verification_error
.as_deref()
.unwrap_or("no reason reported");
tracing::warn!(
module_id = %wr.module_id,
error = detail,
"Binding failed verification"
);
unverified.push(format!(" {}: {detail}", wr.module_id));
}
}
self.report_unverified(&unverified)
}
fn report_unverified(&self, unverified: &[String]) -> anyhow::Result<()> {
if self.verify && !unverified.is_empty() {
anyhow::bail!(
"{} binding(s) failed verification:\n{}",
unverified.len(),
unverified.join("\n")
);
}
Ok(())
}
fn write_acl(
&self,
modules: &[apcore_toolkit::ScannedModule],
config: &ApexeConfig,
) -> anyhow::Result<()> {
let acl_path = config.config_dir.join("acl.yaml");
if self.dry_run {
info!(path = %acl_path.display(), "Would write ACL policy");
return Ok(());
}
let acl_manager = if acl_path.exists() {
crate::governance::AclManager::merge_default(&acl_path, modules)
.map_err(|e| anyhow::anyhow!("Failed to load existing ACL for merge: {e}"))?
} else {
crate::governance::AclManager::generate_default(modules)
};
acl_manager
.write_config(&acl_path)
.map_err(|e| anyhow::anyhow!("Failed to write ACL: {e}"))?;
Ok(())
}
fn write_skills(&self, modules: &[apcore_toolkit::ScannedModule]) -> anyhow::Result<()> {
let Some(ref skills_dir) = self.skills_dir else {
return Ok(());
};
if self.dry_run {
info!(
count = modules.len(),
dir = %skills_dir.join(".claude").join("skills").display(),
"Would write skill file(s)"
);
return Ok(());
}
let paths = crate::output::SkillOutput::new()
.write(modules, skills_dir)
.map_err(|e| anyhow::anyhow!("Failed to write skill files: {e}"))?;
for path in &paths {
info!(path = %path.display(), "Generated skill");
}
Ok(())
}
fn print_results(
&self,
results: Vec<crate::models::ScannedCLITool>,
modules: &[apcore_toolkit::ScannedModule],
) -> anyhow::Result<()> {
match self.format.as_str() {
"json" => {
let report = crate::adapter::ScanReport::new(results, modules);
println!("{}", serde_json::to_string_pretty(&report)?);
}
"yaml" => {
let report = crate::adapter::ScanReport::new(results, modules);
println!("{}", serde_yaml::to_string(&report)?);
}
_ => {
for tool in &results {
Self::print_tool_table(tool);
}
}
}
Ok(())
}
fn print_tool_table(tool: &crate::models::ScannedCLITool) {
println!(
"Tool: {} ({})",
tool.name,
tool.version.as_deref().unwrap_or("unknown")
);
println!(" Binary: {}", tool.binary_path);
println!(" Variant: {}", tool.variant.as_str());
if let Some(ref overlay) = tool.overlay {
println!(" Overlay: {overlay}");
}
println!(" Scan tier: {}", tool.scan_tier);
println!(" Subcommands: {}", tool.subcommands.len());
println!(" Global flags: {}", tool.global_flags.len());
if tool.structured_output.supported {
println!(
" Structured output: {} ({})",
tool.structured_output.flag.as_deref().unwrap_or(""),
tool.structured_output.format.as_deref().unwrap_or("")
);
}
if !tool.warnings.is_empty() {
println!(" Warnings: {}", tool.warnings.join(", "));
}
println!();
}
}
#[derive(Debug, clap::Args)]
pub struct ServeArgs {
#[arg(long, default_value = "stdio", value_parser = ["stdio", "http", "sse"])]
pub transport: String,
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long, default_value = "8000", value_parser = clap::value_parser!(u16).range(1..))]
pub port: u16,
#[arg(long)]
pub explorer: bool,
#[arg(long)]
pub modules_dir: Option<PathBuf>,
#[arg(long, default_value = "apexe")]
pub name: String,
#[arg(long, value_parser = config_gen::ConfigFormat::VALUES)]
pub show_config: Option<String>,
#[arg(long)]
pub tags: Option<String>,
#[arg(long)]
pub prefix: Option<String>,
#[arg(long)]
pub acl: Option<PathBuf>,
#[arg(long)]
pub enable_approval: bool,
#[arg(long, value_parser = ["token", "jwt", "none"])]
pub auth: Option<String>,
#[arg(long, env = "APEXE_AUTH_TOKEN", hide_env_values = true)]
pub auth_token: Option<String>,
#[arg(long, env = "APEXE_JWT_SECRET", hide_env_values = true)]
pub jwt_secret: Option<String>,
#[arg(long)]
pub allow_unauthenticated_bind: bool,
#[arg(long, hide = true)]
pub allow_deprecated_sse: bool,
#[arg(long)]
pub no_logging: bool,
#[arg(long)]
pub no_log_arguments: bool,
#[arg(long)]
pub no_circuit_breaker: bool,
#[arg(long)]
pub no_retry: bool,
#[arg(long)]
pub metrics: bool,
}
impl ServeArgs {
pub fn execute(self, config: &ApexeConfig) -> anyhow::Result<()> {
if let Some(ref format) = self.show_config {
let snippet = config_gen::generate_config(format, &self.invocation())?;
println!("{snippet}");
return Ok(());
}
let server = self.build_server(config)?;
let opts = self.serve_options();
server
.serve_with_options(opts)
.map_err(|e| anyhow::anyhow!("{e}"))
}
fn invocation(&self) -> config_gen::ServeInvocation {
config_gen::ServeInvocation {
name: self.name.clone(),
transport: self.transport.clone(),
host: self.host.clone(),
port: self.port,
modules_dir: self.modules_dir.clone(),
tags: self.tags.clone(),
prefix: self.prefix.clone(),
acl: self.acl.clone(),
enable_approval: self.enable_approval,
no_logging: self.no_logging,
no_log_arguments: self.no_log_arguments,
no_circuit_breaker: self.no_circuit_breaker,
no_retry: self.no_retry,
}
}
fn build_server(&self, config: &ApexeConfig) -> anyhow::Result<apcore_mcp::APCoreMCP> {
let modules_dir = self
.modules_dir
.clone()
.unwrap_or_else(|| config.modules_dir.clone());
let mut builder = crate::mcp::McpServerBuilder::new()
.name(&self.name)
.transport(&self.transport)
.host(&self.host)
.port(self.port)
.explorer(self.explorer)
.modules_dir(modules_dir)
.timeout_ms(config.default_timeout * 1000)
.enable_logging(!self.no_logging)
.log_arguments(!self.no_log_arguments)
.enable_approval(self.enable_approval)
.enable_circuit_breaker(!self.no_circuit_breaker)
.enable_retry(!self.no_retry)
.enable_metrics(self.metrics)
.auth(self.auth_options()?)
.allow_deprecated_sse(self.allow_deprecated_sse)
.audit_path(config.audit_log.clone());
if let Some(ref acl_path) = self.acl {
builder = builder.acl_path(acl_path);
}
if let Some(ref tags_str) = self.tags {
builder = builder.tags(parse_tag_list(tags_str)?);
}
if let Some(ref prefix) = self.prefix {
builder = builder.prefix(prefix);
}
builder.build().map_err(|e| anyhow::anyhow!("{e}"))
}
fn auth_options(&self) -> anyhow::Result<crate::auth::AuthOptions> {
let mode = match self.auth {
Some(ref value) => Some(crate::auth::AuthMode::parse(value).ok_or_else(|| {
anyhow::anyhow!("Unknown --auth mode '{value}' (expected token, jwt or none)")
})?),
None => None,
};
Ok(crate::auth::AuthOptions {
mode,
token: self.auth_token.clone(),
jwt_secret: self.jwt_secret.clone(),
allow_unauthenticated_bind: self.allow_unauthenticated_bind,
})
}
fn serve_options(&self) -> apcore_mcp::ServeOptions {
apcore_mcp::ServeOptions {
explorer: apcore_mcp::ExplorerOptions {
explorer: self.explorer,
allow_execute: true,
..Default::default()
},
..Default::default()
}
}
}
fn parse_tag_list(raw: &str) -> anyhow::Result<Vec<String>> {
raw.split(',')
.map(|token| {
let tag = token.trim();
if tag.is_empty() {
anyhow::bail!(
"--tags '{raw}' contains an empty tag. No module carries an empty tag, so \
the filter would admit nothing and the server would start with no callable \
tools. Remove the stray comma."
);
}
Ok(tag.to_string())
})
.collect()
}
#[derive(Debug, clap::Args)]
pub struct A2aArgs {
#[arg(long, default_value = "http://127.0.0.1:8000")]
pub url: String,
#[arg(long)]
pub modules_dir: Option<PathBuf>,
#[arg(long, default_value = "apexe")]
pub name: String,
#[arg(long)]
pub explorer: bool,
#[arg(long)]
pub acl: Option<PathBuf>,
#[arg(long)]
pub no_logging: bool,
#[arg(long)]
pub no_log_arguments: bool,
#[arg(long)]
pub no_circuit_breaker: bool,
#[arg(long)]
pub no_retry: bool,
#[arg(long, default_value = "300")]
pub execution_timeout: u64,
#[arg(long)]
pub tags: Option<String>,
#[arg(long)]
pub prefix: Option<String>,
#[arg(long)]
pub cors_origin: Vec<String>,
#[arg(long)]
pub allow_unauthenticated_bind: bool,
}
impl A2aArgs {
pub fn execute(self, config: &ApexeConfig) -> anyhow::Result<()> {
let server = self.build_server(config)?;
let runtime = tokio::runtime::Runtime::new()?;
runtime
.block_on(server.serve())
.map_err(|e| anyhow::anyhow!("{e}"))
}
fn build_server(&self, config: &ApexeConfig) -> anyhow::Result<crate::a2a::A2aServerBuilder> {
let modules_dir = self
.modules_dir
.clone()
.unwrap_or_else(|| config.modules_dir.clone());
let mut builder = crate::a2a::A2aServerBuilder::new()
.name(&self.name)
.url(&self.url)
.explorer(self.explorer)
.modules_dir(modules_dir)
.timeout_ms(config.default_timeout * 1000)
.enable_logging(!self.no_logging)
.log_arguments(!self.no_log_arguments)
.enable_circuit_breaker(!self.no_circuit_breaker)
.enable_retry(!self.no_retry)
.execution_timeout(self.execution_timeout)
.cors_origins(self.cors_origin.clone())
.allow_unauthenticated_bind(self.allow_unauthenticated_bind)
.audit_path(config.audit_log.clone());
if let Some(ref tags_str) = self.tags {
builder = builder.tags(parse_tag_list(tags_str)?);
}
if let Some(ref prefix) = self.prefix {
builder = builder.prefix(prefix);
}
if let Some(ref acl_path) = self.acl {
builder = builder.acl_path(acl_path);
}
Ok(builder)
}
}
#[derive(Debug, clap::Args)]
pub struct ListArgs {
#[arg(long, default_value = "table", value_parser = ["json", "table"])]
pub format: String,
#[arg(long)]
pub modules_dir: Option<PathBuf>,
}
impl ListArgs {
pub fn execute(self, config: &ApexeConfig) -> anyhow::Result<()> {
let modules_dir = self.modules_dir.as_ref().unwrap_or(&config.modules_dir);
let modules = self.load_modules(modules_dir)?;
if modules.is_empty() {
println!("No modules found. Run 'apexe scan <tool>' first.");
return Ok(());
}
self.print_modules(&modules)?;
Ok(())
}
fn load_modules(
&self,
dir: &std::path::Path,
) -> anyhow::Result<Vec<apcore_toolkit::ScannedModule>> {
if !dir.exists() {
return Ok(vec![]);
}
crate::output::load_modules_from_dir(dir).map_err(|e| anyhow::anyhow!(e))
}
fn print_modules(&self, modules: &[apcore_toolkit::ScannedModule]) -> anyhow::Result<()> {
let mut sorted: Vec<_> = modules
.iter()
.map(|m| (m.module_id.as_str(), m.description.as_str()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
match self.format.as_str() {
"json" => {
let json: Vec<serde_json::Value> = sorted
.iter()
.map(|(id, desc)| serde_json::json!({"module_id": id, "description": desc}))
.collect();
println!("{}", serde_json::to_string_pretty(&json)?);
}
_ => {
println!("{:<40} DESCRIPTION", "MODULE ID");
println!("{:<40} {}", "\u{2500}".repeat(40), "\u{2500}".repeat(40));
for (id, desc) in &sorted {
let truncated = if desc.chars().count() > 60 {
format!("{}...", desc.chars().take(57).collect::<String>())
} else {
desc.to_string()
};
println!("{:<40} {}", id, truncated);
}
println!("\n{} module(s) found.", sorted.len());
}
}
Ok(())
}
}
#[derive(Debug, clap::Args)]
pub struct ConfigArgs {
#[arg(long)]
pub show: bool,
#[arg(long)]
pub init: bool,
}
impl ConfigArgs {
pub fn execute(self, config: &ApexeConfig) -> anyhow::Result<()> {
if self.show {
let yaml = serde_yaml::to_string(config)?;
println!("{yaml}");
}
if self.init {
let config_path = config.config_dir.join("config.yaml");
if !config_path.exists() {
let default = ApexeConfig::default();
let yaml = serde_yaml::to_string(&default)?;
std::fs::write(&config_path, yaml)?;
println!("Config written to {}", config_path.display());
} else {
println!("Config already exists at {}", config_path.display());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_scan_subcommand() {
let cli = Cli::try_parse_from(["apexe", "scan", "git"]).unwrap();
assert!(matches!(cli.command, Commands::Scan(_)));
if let Commands::Scan(args) = cli.command {
assert_eq!(args.tools, vec!["git".to_string()]);
}
}
#[test]
fn test_parse_no_subcommand_fails() {
let result = Cli::try_parse_from(["apexe"]);
assert!(result.is_err());
}
#[test]
fn test_parse_log_level_flag() {
let cli = Cli::try_parse_from(["apexe", "--log-level", "debug", "scan", "git"]).unwrap();
assert_eq!(cli.log_level.as_deref(), Some("debug"));
assert_eq!(cli.effective_log_level("warn"), "debug");
}
#[test]
fn test_parse_default_log_level() {
let cli = Cli::try_parse_from(["apexe", "scan", "git"]).unwrap();
assert_eq!(cli.log_level, None);
assert_eq!(cli.effective_log_level("debug"), "debug");
}
#[test]
fn test_scan_no_tools_fails() {
let result = Cli::try_parse_from(["apexe", "scan"]);
assert!(result.is_err());
}
#[test]
fn test_scan_depth_zero_fails() {
let result = Cli::try_parse_from(["apexe", "scan", "git", "--depth", "0"]);
assert!(result.is_err());
}
#[test]
fn test_scan_depth_six_fails() {
let result = Cli::try_parse_from(["apexe", "scan", "git", "--depth", "6"]);
assert!(result.is_err());
}
#[test]
fn test_scan_depth_three_succeeds() {
let cli = Cli::try_parse_from(["apexe", "scan", "git", "--depth", "3"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.depth, 3);
}
}
#[test]
fn test_scan_format_xml_fails() {
let result = Cli::try_parse_from(["apexe", "scan", "git", "--format", "xml"]);
assert!(result.is_err());
}
#[test]
fn test_scan_format_json_succeeds() {
let cli = Cli::try_parse_from(["apexe", "scan", "git", "--format", "json"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.format, "json");
}
}
#[test]
fn test_scan_multiple_tools() {
let cli = Cli::try_parse_from(["apexe", "scan", "git", "docker"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.tools, vec!["git".to_string(), "docker".to_string()]);
}
}
#[test]
fn test_scan_default_depth() {
let cli = Cli::try_parse_from(["apexe", "scan", "git"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.depth, 2);
}
}
#[test]
fn test_scan_default_format() {
let cli = Cli::try_parse_from(["apexe", "scan", "git"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.format, "table");
}
}
#[test]
fn test_scan_skills_dir_default_none() {
let cli = Cli::try_parse_from(["apexe", "scan", "git"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert!(args.skills_dir.is_none());
}
}
#[test]
fn test_scan_overlay_default_none() {
let cli = Cli::try_parse_from(["apexe", "scan", "ls"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert!(args.overlay.is_none());
} else {
panic!("expected Commands::Scan");
}
}
#[test]
fn test_scan_overlay_flag() {
let cli =
Cli::try_parse_from(["apexe", "scan", "ls", "--overlay", "/tmp/ls.json"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.overlay, Some(PathBuf::from("/tmp/ls.json")));
} else {
panic!("expected Commands::Scan");
}
}
#[test]
fn test_scan_execute_reports_unreadable_overlay() {
let config = ApexeConfig::default();
let args = ScanArgs {
verify: false,
dry_run: false,
tools: vec!["echo".to_string()],
output_dir: None,
depth: 1,
no_cache: true,
format: "table".to_string(),
skills_dir: None,
overlay: Some(PathBuf::from("/nonexistent/overlay_xyz.json")),
};
let err = args.execute(&config).unwrap_err().to_string();
assert!(
err.contains("Failed to read overlay"),
"unexpected error: {err}"
);
}
#[test]
fn test_scan_skills_dir_flag() {
let cli =
Cli::try_parse_from(["apexe", "scan", "git", "--skills-dir", "/tmp/skills"]).unwrap();
if let Commands::Scan(args) = cli.command {
assert_eq!(args.skills_dir, Some(PathBuf::from("/tmp/skills")));
}
}
#[test]
fn test_serve_defaults() {
let cli = Cli::try_parse_from(["apexe", "serve"]).unwrap();
if let Commands::Serve(args) = cli.command {
assert_eq!(args.transport, "stdio");
assert_eq!(args.host, "127.0.0.1");
assert_eq!(args.port, 8000);
assert!(!args.explorer);
}
}
#[test]
fn test_serve_invalid_transport_fails() {
let result = Cli::try_parse_from(["apexe", "serve", "--transport", "invalid"]);
assert!(result.is_err());
}
#[test]
fn test_serve_port_zero_fails() {
let result = Cli::try_parse_from(["apexe", "serve", "--port", "0"]);
assert!(result.is_err());
}
#[test]
fn test_serve_with_all_flags() {
let cli = Cli::try_parse_from([
"apexe",
"serve",
"--transport",
"http",
"--host",
"0.0.0.0",
"--port",
"9000",
"--explorer",
])
.unwrap();
if let Commands::Serve(args) = cli.command {
assert_eq!(args.transport, "http");
assert_eq!(args.host, "0.0.0.0");
assert_eq!(args.port, 9000);
assert!(args.explorer);
}
}
#[test]
fn test_serve_resilience_flags_default_enabled() {
let cli = Cli::try_parse_from(["apexe", "serve"]).unwrap();
if let Commands::Serve(args) = cli.command {
assert!(!args.no_circuit_breaker);
assert!(!args.no_retry);
} else {
panic!("expected Commands::Serve");
}
}
#[test]
fn test_serve_metrics_default_disabled() {
let cli = Cli::try_parse_from(["apexe", "serve"]).unwrap();
if let Commands::Serve(args) = cli.command {
assert!(!args.metrics);
} else {
panic!("expected Commands::Serve");
}
}
#[test]
fn test_serve_metrics_flag() {
let cli = Cli::try_parse_from(["apexe", "serve", "--metrics"]).unwrap();
if let Commands::Serve(args) = cli.command {
assert!(args.metrics);
} else {
panic!("expected Commands::Serve");
}
}
#[test]
fn test_serve_resilience_flags_can_be_disabled() {
let cli =
Cli::try_parse_from(["apexe", "serve", "--no-circuit-breaker", "--no-retry"]).unwrap();
if let Commands::Serve(args) = cli.command {
assert!(args.no_circuit_breaker);
assert!(args.no_retry);
} else {
panic!("expected Commands::Serve");
}
}
#[test]
fn test_serve_auth_defaults_to_unset() {
let cli = Cli::try_parse_from(["apexe", "serve"]).unwrap();
if let Commands::Serve(args) = cli.command {
assert!(args.auth.is_none());
assert!(!args.allow_unauthenticated_bind);
assert!(!args.allow_deprecated_sse);
assert!(!args.no_log_arguments);
} else {
panic!("expected Commands::Serve");
}
}
#[test]
fn test_serve_auth_rejects_unknown_mode() {
let result = Cli::try_parse_from(["apexe", "serve", "--auth", "basic"]);
assert!(result.is_err());
}
#[test]
fn test_serve_auth_options_maps_flags() {
let cli = Cli::try_parse_from([
"apexe",
"serve",
"--auth",
"token",
"--auth-token",
"s3cret",
"--allow-unauthenticated-bind",
])
.unwrap();
let Commands::Serve(args) = cli.command else {
panic!("expected Commands::Serve");
};
let opts = args.auth_options().unwrap();
assert_eq!(opts.mode, Some(crate::auth::AuthMode::Token));
assert_eq!(opts.token.as_deref(), Some("s3cret"));
assert!(opts.allow_unauthenticated_bind);
}
#[test]
fn test_serve_auth_options_defaults_to_no_explicit_mode() {
let cli = Cli::try_parse_from(["apexe", "serve"]).unwrap();
let Commands::Serve(args) = cli.command else {
panic!("expected Commands::Serve");
};
let opts = args.auth_options().unwrap();
assert!(opts.mode.is_none());
assert!(!opts.allow_unauthenticated_bind);
}
#[test]
fn test_serve_rejects_removed_skip_validation_flag() {
let result = Cli::try_parse_from(["apexe", "serve", "--skip-validation"]);
assert!(result.is_err());
}
#[test]
fn test_serve_show_config_rejects_unknown_format() {
let result = Cli::try_parse_from(["apexe", "serve", "--show-config", "vscode"]);
assert!(result.is_err());
}
#[test]
fn test_parse_tag_list_splits_and_trims() {
assert_eq!(
parse_tag_list("readonly, git ,cli").unwrap(),
vec!["readonly", "git", "cli"]
);
assert_eq!(parse_tag_list("readonly").unwrap(), vec!["readonly"]);
}
#[test]
fn test_parse_tag_list_rejects_an_empty_tag() {
for raw in ["readonly,", ",readonly", "readonly,,git", "readonly, ,git"] {
let err = parse_tag_list(raw).unwrap_err().to_string();
assert!(err.contains("empty tag"), "{raw}: {err}");
assert!(err.contains("no callable"), "{raw}: {err}");
}
assert!(parse_tag_list("").is_err());
}
#[test]
fn test_serve_build_server_rejects_a_trailing_comma_in_tags() {
let cli = Cli::try_parse_from(["apexe", "serve", "--tags", "readonly,"]).unwrap();
let Commands::Serve(args) = cli.command else {
panic!("expected Commands::Serve");
};
let err = match args.build_server(&ApexeConfig::default()) {
Err(err) => err,
Ok(_) => panic!("a trailing comma must not produce a toolless server"),
};
assert!(err.to_string().contains("empty tag"), "{err}");
}
#[test]
fn test_serve_invocation_carries_every_surface_flag() {
let cli = Cli::try_parse_from([
"apexe",
"serve",
"--modules-dir",
"/srv/modules",
"--tags",
"readonly",
"--prefix",
"cli.git",
"--acl",
"/etc/apexe/acl.yaml",
"--enable-approval",
"--no-retry",
"--name",
"mytools",
])
.unwrap();
let Commands::Serve(args) = cli.command else {
panic!("expected Commands::Serve");
};
let invocation = args.invocation();
assert_eq!(invocation.name, "mytools");
assert_eq!(invocation.modules_dir, Some(PathBuf::from("/srv/modules")));
assert_eq!(invocation.tags.as_deref(), Some("readonly"));
assert_eq!(invocation.prefix.as_deref(), Some("cli.git"));
assert_eq!(invocation.acl, Some(PathBuf::from("/etc/apexe/acl.yaml")));
assert!(invocation.enable_approval);
assert!(invocation.no_retry);
assert!(!invocation.no_logging);
}
#[test]
fn test_serve_invocation_omits_credentials() {
let cli = Cli::try_parse_from([
"apexe",
"serve",
"--transport",
"http",
"--auth",
"token",
"--auth-token",
"super-secret-value",
])
.unwrap();
let Commands::Serve(args) = cli.command else {
panic!("expected Commands::Serve");
};
let snippet = config_gen::generate_config("claude-desktop", &args.invocation()).unwrap();
assert!(
!snippet.contains("super-secret-value"),
"snippet leaked the bearer token: {snippet}"
);
}
#[test]
fn test_a2a_rejects_enable_approval_flag() {
let result = Cli::try_parse_from(["apexe", "a2a", "--enable-approval"]);
assert!(result.is_err());
}
#[test]
fn test_a2a_build_server_wires_the_surface_filters() {
let cli =
Cli::try_parse_from(["apexe", "a2a", "--prefix", "cli.git.", "--tags", "readonly"])
.unwrap();
let Commands::A2a(args) = cli.command else {
panic!("expected Commands::A2a");
};
let builder = args
.build_server(&ApexeConfig::default())
.expect("well-formed flags build a server");
let filter = builder.module_filter();
assert_eq!(filter.prefix.as_deref(), Some("cli.git."));
assert_eq!(
filter.tags.as_deref(),
Some(["readonly".to_string()].as_slice())
);
}
#[test]
fn test_a2a_build_server_rejects_a_trailing_comma_in_tags() {
let cli = Cli::try_parse_from(["apexe", "a2a", "--tags", "readonly,"]).unwrap();
let Commands::A2a(args) = cli.command else {
panic!("expected Commands::A2a");
};
let err = match args.build_server(&ApexeConfig::default()) {
Ok(_) => panic!("an empty tag makes the filter unsatisfiable"),
Err(e) => e,
};
assert!(
err.to_string().contains("empty tag"),
"the refusal must name the stray comma: {err}"
);
}
fn scan_args(verify: bool) -> ScanArgs {
let cli = Cli::try_parse_from(
["apexe", "scan", "ls"]
.iter()
.copied()
.chain(verify.then_some("--verify")),
)
.unwrap();
match cli.command {
Commands::Scan(args) => args,
_ => panic!("expected Commands::Scan"),
}
}
#[test]
fn test_verify_turns_an_unverified_binding_into_a_failure() {
let unverified = |verify: bool| -> anyhow::Result<()> {
scan_args(verify)
.report_unverified(&["cli.broken: could not parse as YAML".to_string()])
};
assert!(
unverified(false).is_ok(),
"without the flag an unverified binding is a warning: one bad tool \
must not throw away the scan of the others"
);
let err = match unverified(true) {
Ok(()) => panic!("--verify must fail on an unverified binding"),
Err(e) => e.to_string(),
};
assert!(
err.contains("cli.broken"),
"the failure names the module: {err}"
);
assert!(
err.contains("could not parse as YAML"),
"and the reason: {err}"
);
}
#[test]
fn test_scan_args_verify_flag() {
let cli = Cli::try_parse_from(["apexe", "scan", "ls", "--verify"]).unwrap();
let Commands::Scan(args) = cli.command else {
panic!("expected Commands::Scan");
};
assert!(args.verify);
assert!(!args.dry_run, "the two flags are independent");
}
#[test]
fn test_scan_args_dry_run_flag() {
let cli = Cli::try_parse_from(["apexe", "scan", "ls", "--dry-run"]).unwrap();
let Commands::Scan(args) = cli.command else {
panic!("expected Commands::Scan");
};
assert!(args.dry_run);
assert!(!args.verify);
}
#[test]
fn test_scan_args_default_to_neither_flag() {
let cli = Cli::try_parse_from(["apexe", "scan", "ls"]).unwrap();
let Commands::Scan(args) = cli.command else {
panic!("expected Commands::Scan");
};
assert!(!args.verify, "a scan must not fail on a warning by default");
assert!(!args.dry_run, "a scan writes by default");
}
#[test]
fn test_global_timeout_flag() {
for argv in [
["apexe", "--timeout", "120", "scan", "ls"],
["apexe", "scan", "ls", "--timeout", "120"],
] {
let cli = Cli::try_parse_from(argv).unwrap();
assert_eq!(cli.timeout, Some(120), "{argv:?}");
}
}
#[test]
fn test_global_timeout_rejects_zero() {
assert!(Cli::try_parse_from(["apexe", "--timeout", "0", "scan", "ls"]).is_err());
}
#[test]
fn test_timeout_override_beats_the_config_file() {
let config = ApexeConfig {
default_timeout: 30,
..ApexeConfig::default()
};
assert_eq!(
config.clone().with_timeout_override(None).default_timeout,
30
);
assert_eq!(
config.with_timeout_override(Some(120)).default_timeout,
120,
"a CLI flag outranks config.yaml, matching --log-level"
);
}
#[test]
fn test_a2a_defaults() {
let cli = Cli::try_parse_from(["apexe", "a2a"]).unwrap();
if let Commands::A2a(args) = cli.command {
assert_eq!(args.url, "http://127.0.0.1:8000");
assert_eq!(args.execution_timeout, 300);
assert!(!args.explorer);
assert!(args.cors_origin.is_empty());
} else {
panic!("expected Commands::A2a");
}
}
#[test]
fn test_a2a_with_flags() {
let cli = Cli::try_parse_from([
"apexe",
"a2a",
"--url",
"http://0.0.0.0:9090",
"--explorer",
"--execution-timeout",
"600",
"--cors-origin",
"https://example.com",
"--cors-origin",
"https://foo.example.com",
])
.unwrap();
if let Commands::A2a(args) = cli.command {
assert_eq!(args.url, "http://0.0.0.0:9090");
assert!(args.explorer);
assert_eq!(args.execution_timeout, 600);
assert_eq!(
args.cors_origin,
vec!["https://example.com", "https://foo.example.com"]
);
} else {
panic!("expected Commands::A2a");
}
}
#[test]
fn test_a2a_resilience_flags_can_be_disabled() {
let cli =
Cli::try_parse_from(["apexe", "a2a", "--no-circuit-breaker", "--no-retry"]).unwrap();
if let Commands::A2a(args) = cli.command {
assert!(args.no_circuit_breaker);
assert!(args.no_retry);
} else {
panic!("expected Commands::A2a");
}
}
#[test]
fn test_list_default_format() {
let cli = Cli::try_parse_from(["apexe", "list"]).unwrap();
if let Commands::List(args) = cli.command {
assert_eq!(args.format, "table");
}
}
#[test]
fn test_list_format_json() {
let cli = Cli::try_parse_from(["apexe", "list", "--format", "json"]).unwrap();
if let Commands::List(args) = cli.command {
assert_eq!(args.format, "json");
}
}
#[test]
fn test_list_format_xml_fails() {
let result = Cli::try_parse_from(["apexe", "list", "--format", "xml"]);
assert!(result.is_err());
}
#[test]
fn test_config_show_flag() {
let cli = Cli::try_parse_from(["apexe", "config", "--show"]).unwrap();
if let Commands::Config(args) = cli.command {
assert!(args.show);
assert!(!args.init);
}
}
#[test]
fn test_config_init_flag() {
let cli = Cli::try_parse_from(["apexe", "config", "--init"]).unwrap();
if let Commands::Config(args) = cli.command {
assert!(!args.show);
assert!(args.init);
}
}
#[test]
fn test_config_no_flags_parses() {
let cli = Cli::try_parse_from(["apexe", "config"]).unwrap();
if let Commands::Config(args) = cli.command {
assert!(!args.show);
assert!(!args.init);
}
}
#[test]
fn test_config_no_flags_is_noop() {
let config = ApexeConfig::default();
let args = ConfigArgs {
show: false,
init: false,
};
let result = args.execute(&config);
assert!(result.is_ok());
}
#[test]
fn test_config_show_outputs_valid_yaml() {
let tmp = tempfile::TempDir::new().unwrap();
let config = ApexeConfig {
modules_dir: tmp.path().join("modules"),
cache_dir: tmp.path().join("cache"),
config_dir: tmp.path().to_path_buf(),
audit_log: tmp.path().join("audit.jsonl"),
log_level: "info".to_string(),
default_timeout: 30,
scan_depth: 2,
json_output_preference: true,
..ApexeConfig::default()
};
let yaml = serde_yaml::to_string(&config).unwrap();
let deserialized: ApexeConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(deserialized.log_level, "info");
assert_eq!(deserialized.default_timeout, 30);
}
#[test]
fn test_config_init_creates_file() {
let tmp = tempfile::TempDir::new().unwrap();
let config = ApexeConfig {
modules_dir: tmp.path().join("modules"),
cache_dir: tmp.path().join("cache"),
config_dir: tmp.path().to_path_buf(),
audit_log: tmp.path().join("audit.jsonl"),
log_level: "info".to_string(),
default_timeout: 30,
scan_depth: 2,
json_output_preference: true,
..ApexeConfig::default()
};
let args = ConfigArgs {
show: false,
init: true,
};
args.execute(&config).unwrap();
let config_path = tmp.path().join("config.yaml");
assert!(config_path.exists());
let contents = std::fs::read_to_string(&config_path).unwrap();
let parsed: ApexeConfig = serde_yaml::from_str(&contents).unwrap();
assert_eq!(parsed.log_level, "info");
}
#[test]
fn test_config_init_does_not_overwrite() {
let tmp = tempfile::TempDir::new().unwrap();
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, "existing content").unwrap();
let config = ApexeConfig {
modules_dir: tmp.path().join("modules"),
cache_dir: tmp.path().join("cache"),
config_dir: tmp.path().to_path_buf(),
audit_log: tmp.path().join("audit.jsonl"),
log_level: "info".to_string(),
default_timeout: 30,
scan_depth: 2,
json_output_preference: true,
..ApexeConfig::default()
};
let args = ConfigArgs {
show: false,
init: true,
};
args.execute(&config).unwrap();
let contents = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(contents, "existing content");
}
#[test]
fn test_scan_execute_nonexistent_tool_errors() {
let config = ApexeConfig::default();
let args = ScanArgs {
verify: false,
dry_run: false,
tools: vec!["nonexistent_tool_xyz_12345".to_string()],
output_dir: None,
depth: 2,
no_cache: false,
format: "table".to_string(),
skills_dir: None,
overlay: None,
};
let result = args.execute(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not found on PATH"),
"Expected 'not found on PATH' in error, got: {err_msg}"
);
}
#[test]
fn test_scan_write_bindings_surfaces_write_failure() {
let tmp = tempfile::TempDir::new().unwrap();
let file_path = tmp.path().join("iamafile");
std::fs::write(&file_path, "x").unwrap();
let bad_output = file_path.join("nested");
let args = ScanArgs {
verify: false,
dry_run: false,
tools: vec!["echo".to_string()],
output_dir: None,
depth: 2,
no_cache: false,
format: "table".to_string(),
skills_dir: None,
overlay: None,
};
let modules = vec![apcore_toolkit::ScannedModule::new(
"cli.echo".to_string(),
"Echo".to_string(),
serde_json::json!({"type": "object"}),
serde_json::json!({"type": "object"}),
vec!["cli".to_string()],
"exec:///bin/echo".to_string(),
)];
let result = args.write_bindings(&modules, &bad_output);
assert!(
result.is_err(),
"write_bindings must surface a write failure, not swallow it"
);
}
#[test]
fn test_write_acl_merges_with_an_existing_policy_instead_of_overwriting_it() {
let tmp = tempfile::TempDir::new().unwrap();
let config = ApexeConfig {
config_dir: tmp.path().to_path_buf(),
..ApexeConfig::default()
};
let args = ScanArgs {
verify: false,
dry_run: false,
tools: vec!["ls".to_string()],
output_dir: None,
depth: 1,
no_cache: true,
format: "table".to_string(),
skills_dir: None,
overlay: None,
};
let mut readonly_module = apcore_toolkit::ScannedModule::new(
"cli.ls".to_string(),
"List".to_string(),
serde_json::json!({"type": "object"}),
serde_json::json!({"type": "object"}),
vec!["cli".to_string()],
"exec:///bin/ls".to_string(),
);
readonly_module.annotations = Some(apcore::module::ModuleAnnotations {
readonly: true,
..Default::default()
});
args.write_acl(&[readonly_module], &config).unwrap();
let echo_module = apcore_toolkit::ScannedModule::new(
"cli.echo".to_string(),
"Echo".to_string(),
serde_json::json!({"type": "object"}),
serde_json::json!({"type": "object"}),
vec!["cli".to_string()],
"exec:///bin/echo".to_string(),
);
args.write_acl(&[echo_module], &config).unwrap();
let acl_yaml = std::fs::read_to_string(config.config_dir.join("acl.yaml")).unwrap();
assert!(
acl_yaml.contains("cli.ls"),
"the earlier scan's allow rule must survive a later scan: {acl_yaml}"
);
}
#[test]
fn test_list_load_modules_surfaces_corrupt_binding() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("bad.binding.yaml"),
"not: valid: binding: [[[",
)
.unwrap();
let args = ListArgs {
format: "table".to_string(),
modules_dir: Some(tmp.path().to_path_buf()),
};
let result = args.load_modules(tmp.path());
assert!(
result.is_err(),
"corrupt binding must surface, not collapse to an empty module list"
);
}
}