diff --git a/Cargo.lock b/Cargo.lock index 01d3590f..f3e97a41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,6 +936,27 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -943,7 +964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -1932,6 +1953,7 @@ dependencies = [ "clap", "crossterm", "deadpool-postgres", + "dirs", "dotenvy", "futures", "hkdf", @@ -2043,6 +2065,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2514,6 +2542,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" diff --git a/Cargo.toml b/Cargo.toml index 80b086fc..21d7c5ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,9 @@ tower-http = { version = "0.6", features = ["trace", "cors"] } regex = "1" aho-corasick = "1" +# Filesystem paths +dirs = "6" + # Secrecy for sensitive values secrecy = { version = "0.10", features = ["serde"] } diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 00000000..5c9dfbb1 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,53 @@ +//! CLI command handling. +//! +//! Provides subcommands for: +//! - Running the agent (`run`) +//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`) +//! - Managing secrets (`secret set`, `secret list`, `secret remove`) + +mod tool; + +pub use tool::{ToolCommand, run_tool_command}; + +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command(name = "near-agent")] +#[command(about = "LLM-powered autonomous agent for the NEAR AI marketplace")] +#[command(version)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + /// Run in interactive CLI mode only (disable other channels) + #[arg(long, global = true)] + pub cli_only: bool, + + /// Skip database connection (for testing) + #[arg(long, global = true)] + pub no_db: bool, + + /// Configuration file path (optional, uses env vars by default) + #[arg(short, long, global = true)] + pub config: Option, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Run the agent (default if no subcommand given) + Run, + + /// Manage WASM tools + #[command(subcommand)] + Tool(ToolCommand), + // Future: Secret management + // #[command(subcommand)] + // Secret(SecretCommand), +} + +impl Cli { + /// Check if we should run the agent (default behavior or explicit `run` command). + pub fn should_run_agent(&self) -> bool { + matches!(self.command, None | Some(Command::Run)) + } +} diff --git a/src/cli/tool.rs b/src/cli/tool.rs new file mode 100644 index 00000000..3161eb98 --- /dev/null +++ b/src/cli/tool.rs @@ -0,0 +1,639 @@ +//! Tool management CLI commands. +//! +//! Commands for installing, listing, and removing WASM tools. + +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; + +use clap::Subcommand; +use tokio::fs; + +use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash}; + +/// Default tools directory. +fn default_tools_dir() -> PathBuf { + dirs::home_dir() + .map(|h| h.join(".near-agent").join("tools")) + .unwrap_or_else(|| PathBuf::from(".near-agent/tools")) +} + +#[derive(Subcommand, Debug, Clone)] +pub enum ToolCommand { + /// Install a WASM tool from source directory or .wasm file + Install { + /// Path to tool source directory (with Cargo.toml) or .wasm file + path: PathBuf, + + /// Tool name (defaults to directory/file name) + #[arg(short, long)] + name: Option, + + /// Path to capabilities JSON file (auto-detected if not specified) + #[arg(long)] + capabilities: Option, + + /// Target directory for installation (default: ~/.near-agent/tools/) + #[arg(short, long)] + target: Option, + + /// Build in release mode (default: true) + #[arg(long, default_value = "true")] + release: bool, + + /// Skip compilation (use existing .wasm file) + #[arg(long)] + skip_build: bool, + + /// Force overwrite if tool already exists + #[arg(short, long)] + force: bool, + }, + + /// List installed tools + List { + /// Directory to list tools from (default: ~/.near-agent/tools/) + #[arg(short, long)] + dir: Option, + + /// Show detailed information + #[arg(short, long)] + verbose: bool, + }, + + /// Remove an installed tool + Remove { + /// Name of the tool to remove + name: String, + + /// Directory to remove tool from (default: ~/.near-agent/tools/) + #[arg(short, long)] + dir: Option, + }, + + /// Show information about a tool + Info { + /// Name of the tool or path to .wasm file + name_or_path: String, + + /// Directory to look for tool (default: ~/.near-agent/tools/) + #[arg(short, long)] + dir: Option, + }, +} + +/// Run a tool command. +pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> { + match cmd { + ToolCommand::Install { + path, + name, + capabilities, + target, + release, + skip_build, + force, + } => install_tool(path, name, capabilities, target, release, skip_build, force).await, + ToolCommand::List { dir, verbose } => list_tools(dir, verbose).await, + ToolCommand::Remove { name, dir } => remove_tool(name, dir).await, + ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await, + } +} + +/// Install a WASM tool. +async fn install_tool( + path: PathBuf, + name: Option, + capabilities: Option, + target: Option, + release: bool, + skip_build: bool, + force: bool, +) -> anyhow::Result<()> { + let target_dir = target.unwrap_or_else(default_tools_dir); + + // Determine if path is a directory (source) or .wasm file + let metadata = fs::metadata(&path).await?; + + let (wasm_path, tool_name, caps_path) = if metadata.is_dir() { + // Source directory, need to build + let cargo_toml = path.join("Cargo.toml"); + if !cargo_toml.exists() { + anyhow::bail!( + "No Cargo.toml found in {}. Expected a Rust WASM tool source directory.", + path.display() + ); + } + + // Extract tool name from Cargo.toml or use provided name + let tool_name = if let Some(n) = name { + n + } else { + extract_crate_name(&cargo_toml).await? + }; + + // Build the WASM component if not skipping + let wasm_path = if skip_build { + // Look for existing wasm file + find_wasm_artifact(&path, &tool_name, release)? + } else { + build_wasm_component(&path, release)? + }; + + // Look for capabilities file + let caps_path = capabilities.or_else(|| { + let candidates = [ + path.join(format!("{}.capabilities.json", tool_name)), + path.join("capabilities.json"), + ]; + candidates.into_iter().find(|p| p.exists()) + }); + + (wasm_path, tool_name, caps_path) + } else if path.extension().map(|e| e == "wasm").unwrap_or(false) { + // Direct .wasm file + let tool_name = name.unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() + }); + + // Look for capabilities file next to wasm + let caps_path = capabilities.or_else(|| { + let candidates = [ + path.with_extension("capabilities.json"), + path.parent() + .map(|p| p.join(format!("{}.capabilities.json", tool_name))) + .unwrap_or_default(), + ]; + candidates.into_iter().find(|p| p.exists()) + }); + + (path, tool_name, caps_path) + } else { + anyhow::bail!( + "Expected a directory with Cargo.toml or a .wasm file, got: {}", + path.display() + ); + }; + + // Ensure target directory exists + fs::create_dir_all(&target_dir).await?; + + // Target paths + let target_wasm = target_dir.join(format!("{}.wasm", tool_name)); + let target_caps = target_dir.join(format!("{}.capabilities.json", tool_name)); + + // Check if already exists + if target_wasm.exists() && !force { + anyhow::bail!( + "Tool '{}' already exists at {}. Use --force to overwrite.", + tool_name, + target_wasm.display() + ); + } + + // Validate capabilities file if provided + if let Some(ref caps) = caps_path { + let content = fs::read_to_string(caps).await?; + CapabilitiesFile::from_json(&content) + .map_err(|e| anyhow::anyhow!("Invalid capabilities file {}: {}", caps.display(), e))?; + } + + // Copy WASM file + println!("Installing {} to {}", tool_name, target_wasm.display()); + fs::copy(&wasm_path, &target_wasm).await?; + + // Copy capabilities file if present + if let Some(caps) = caps_path { + println!(" Copying capabilities from {}", caps.display()); + fs::copy(&caps, &target_caps).await?; + } else { + println!(" Warning: No capabilities file found. Tool will have no permissions."); + } + + // Calculate and display hash + let wasm_bytes = fs::read(&target_wasm).await?; + let hash = compute_binary_hash(&wasm_bytes); + let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); + + println!("\nInstalled successfully:"); + println!(" Name: {}", tool_name); + println!(" WASM: {}", target_wasm.display()); + println!(" Size: {} bytes", wasm_bytes.len()); + println!(" Hash: {}", &hash_hex[..16]); // Show first 16 chars + + if target_caps.exists() { + println!(" Caps: {}", target_caps.display()); + } + + Ok(()) +} + +/// Build a WASM component using cargo-component. +fn build_wasm_component(source_dir: &Path, release: bool) -> anyhow::Result { + println!("Building WASM component in {}...", source_dir.display()); + + // Check if cargo-component is available + let check = ProcessCommand::new("cargo") + .args(["component", "--version"]) + .output(); + + if check.is_err() || !check.unwrap().status.success() { + anyhow::bail!( + "cargo-component not found. Install with: cargo install cargo-component\n\ + Or use --skip-build with an existing .wasm file." + ); + } + + // Build command + let mut cmd = ProcessCommand::new("cargo"); + cmd.current_dir(source_dir).args(["component", "build"]); + + if release { + cmd.arg("--release"); + } + + println!( + " Running: cargo component build{}", + if release { " --release" } else { "" } + ); + + let output = cmd.output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Build failed:\n{}", stderr); + } + + // Find the output wasm file + let profile = if release { "release" } else { "debug" }; + let target_dir = source_dir + .join("target") + .join("wasm32-wasip2") + .join(profile); + + // Look for .wasm files in target dir + let entries: Vec<_> = std::fs::read_dir(&target_dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .map(|ext| ext == "wasm") + .unwrap_or(false) + }) + .collect(); + + if entries.is_empty() { + anyhow::bail!( + "No .wasm file found in {}. Build may have failed.", + target_dir.display() + ); + } + + if entries.len() > 1 { + println!( + " Warning: Multiple .wasm files found, using first: {}", + entries[0].path().display() + ); + } + + let wasm_path = entries[0].path(); + println!(" Built: {}", wasm_path.display()); + + Ok(wasm_path) +} + +/// Find an existing WASM artifact without building. +fn find_wasm_artifact(source_dir: &Path, name: &str, release: bool) -> anyhow::Result { + let profile = if release { "release" } else { "debug" }; + let target_dir = source_dir + .join("target") + .join("wasm32-wasip2") + .join(profile); + + // Try exact name match first + let snake_name = name.replace('-', "_"); + let candidates = [ + target_dir.join(format!("{}.wasm", name)), + target_dir.join(format!("{}.wasm", snake_name)), + ]; + + for candidate in &candidates { + if candidate.exists() { + return Ok(candidate.clone()); + } + } + + // Fall back to any .wasm file + let entries: Vec<_> = std::fs::read_dir(&target_dir) + .map_err(|_| { + anyhow::anyhow!( + "Target directory not found: {}. Run without --skip-build.", + target_dir.display() + ) + })? + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .map(|ext| ext == "wasm") + .unwrap_or(false) + }) + .collect(); + + if entries.is_empty() { + anyhow::bail!( + "No .wasm file found in {}. Build the project first or remove --skip-build.", + target_dir.display() + ); + } + + Ok(entries[0].path()) +} + +/// Extract crate name from Cargo.toml. +async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result { + let content = fs::read_to_string(cargo_toml).await?; + + // Simple TOML parsing for [package] name + for line in content.lines() { + let line = line.trim(); + if line.starts_with("name") { + if let Some((_, value)) = line.split_once('=') { + let name = value.trim().trim_matches('"').trim_matches('\''); + return Ok(name.to_string()); + } + } + } + + anyhow::bail!( + "Could not extract package name from {}", + cargo_toml.display() + ) +} + +/// List installed tools. +async fn list_tools(dir: Option, verbose: bool) -> anyhow::Result<()> { + let tools_dir = dir.unwrap_or_else(default_tools_dir); + + if !tools_dir.exists() { + println!("No tools directory found at {}", tools_dir.display()); + println!("Install a tool with: near-agent tool install "); + return Ok(()); + } + + let mut entries = fs::read_dir(&tools_dir).await?; + let mut tools = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().map(|e| e == "wasm").unwrap_or(false) { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let caps_path = path.with_extension("capabilities.json"); + let has_caps = caps_path.exists(); + + let size = fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0); + + tools.push((name, path, has_caps, size)); + } + } + + if tools.is_empty() { + println!("No tools installed in {}", tools_dir.display()); + return Ok(()); + } + + tools.sort_by(|a, b| a.0.cmp(&b.0)); + + println!("Installed tools in {}:", tools_dir.display()); + println!(); + + for (name, path, has_caps, size) in tools { + if verbose { + let wasm_bytes = fs::read(&path).await?; + let hash = compute_binary_hash(&wasm_bytes); + let hash_hex: String = hash.iter().take(8).map(|b| format!("{:02x}", b)).collect(); + + println!(" {} ({})", name, format_size(size)); + println!(" Path: {}", path.display()); + println!(" Hash: {}", hash_hex); + println!(" Caps: {}", if has_caps { "yes" } else { "no" }); + + if has_caps { + let caps_path = path.with_extension("capabilities.json"); + if let Ok(content) = fs::read_to_string(&caps_path).await { + if let Ok(caps) = CapabilitiesFile::from_json(&content) { + print_capabilities_summary(&caps); + } + } + } + println!(); + } else { + let caps_indicator = if has_caps { "✓" } else { "✗" }; + println!( + " {} ({}, caps: {})", + name, + format_size(size), + caps_indicator + ); + } + } + + Ok(()) +} + +/// Remove an installed tool. +async fn remove_tool(name: String, dir: Option) -> anyhow::Result<()> { + let tools_dir = dir.unwrap_or_else(default_tools_dir); + + let wasm_path = tools_dir.join(format!("{}.wasm", name)); + let caps_path = tools_dir.join(format!("{}.capabilities.json", name)); + + if !wasm_path.exists() { + anyhow::bail!("Tool '{}' not found in {}", name, tools_dir.display()); + } + + fs::remove_file(&wasm_path).await?; + println!("Removed {}", wasm_path.display()); + + if caps_path.exists() { + fs::remove_file(&caps_path).await?; + println!("Removed {}", caps_path.display()); + } + + println!("\nTool '{}' removed.", name); + Ok(()) +} + +/// Show information about a tool. +async fn show_tool_info(name_or_path: String, dir: Option) -> anyhow::Result<()> { + let wasm_path = if name_or_path.ends_with(".wasm") { + PathBuf::from(&name_or_path) + } else { + let tools_dir = dir.unwrap_or_else(default_tools_dir); + tools_dir.join(format!("{}.wasm", name_or_path)) + }; + + if !wasm_path.exists() { + anyhow::bail!("Tool not found: {}", wasm_path.display()); + } + + let wasm_bytes = fs::read(&wasm_path).await?; + let hash = compute_binary_hash(&wasm_bytes); + let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); + + let name = wasm_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + println!("Tool: {}", name); + println!("Path: {}", wasm_path.display()); + println!( + "Size: {} bytes ({})", + wasm_bytes.len(), + format_size(wasm_bytes.len() as u64) + ); + println!("Hash: {}", hash_hex); + + let caps_path = wasm_path.with_extension("capabilities.json"); + if caps_path.exists() { + println!("\nCapabilities ({}):", caps_path.display()); + let content = fs::read_to_string(&caps_path).await?; + match CapabilitiesFile::from_json(&content) { + Ok(caps) => print_capabilities_detail(&caps), + Err(e) => println!(" Error parsing: {}", e), + } + } else { + println!("\nNo capabilities file found."); + println!("Tool will have no permissions (default deny)."); + } + + Ok(()) +} + +/// Format bytes as human-readable size. +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + + if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +/// Print a brief capabilities summary. +fn print_capabilities_summary(caps: &CapabilitiesFile) { + let mut parts = Vec::new(); + + if let Some(ref http) = caps.http { + let hosts: Vec<_> = http.allowlist.iter().map(|e| e.host.as_str()).collect(); + if !hosts.is_empty() { + parts.push(format!("http: {}", hosts.join(", "))); + } + } + + if let Some(ref secrets) = caps.secrets { + if !secrets.allowed_names.is_empty() { + parts.push(format!("secrets: {}", secrets.allowed_names.len())); + } + } + + if let Some(ref ws) = caps.workspace { + if !ws.allowed_prefixes.is_empty() { + parts.push("workspace: read".to_string()); + } + } + + if !parts.is_empty() { + println!(" Perms: {}", parts.join(", ")); + } +} + +/// Print detailed capabilities. +fn print_capabilities_detail(caps: &CapabilitiesFile) { + if let Some(ref http) = caps.http { + println!(" HTTP:"); + for endpoint in &http.allowlist { + let methods = if endpoint.methods.is_empty() { + "*".to_string() + } else { + endpoint.methods.join(", ") + }; + let path = endpoint.path_prefix.as_deref().unwrap_or("/*"); + println!(" {} {} {}", methods, endpoint.host, path); + } + + if !http.credentials.is_empty() { + println!(" Credentials:"); + for (key, cred) in &http.credentials { + println!(" {}: {} -> {:?}", key, cred.secret_name, cred.location); + } + } + + if let Some(ref rate) = http.rate_limit { + println!( + " Rate limit: {}/min, {}/hour", + rate.requests_per_minute, rate.requests_per_hour + ); + } + } + + if let Some(ref secrets) = caps.secrets { + if !secrets.allowed_names.is_empty() { + println!(" Secrets (existence check only):"); + for name in &secrets.allowed_names { + println!(" {}", name); + } + } + } + + if let Some(ref tool_invoke) = caps.tool_invoke { + if !tool_invoke.aliases.is_empty() { + println!(" Tool aliases:"); + for (alias, real_name) in &tool_invoke.aliases { + println!(" {} -> {}", alias, real_name); + } + } + } + + if let Some(ref ws) = caps.workspace { + if !ws.allowed_prefixes.is_empty() { + println!(" Workspace read prefixes:"); + for prefix in &ws.allowed_prefixes { + println!(" {}", prefix); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_size() { + assert_eq!(format_size(500), "500 B"); + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1536), "1.5 KB"); + assert_eq!(format_size(1048576), "1.0 MB"); + assert_eq!(format_size(2621440), "2.5 MB"); + } + + #[test] + fn test_default_tools_dir() { + let dir = default_tools_dir(); + assert!(dir.to_string_lossy().contains(".near-agent")); + assert!(dir.to_string_lossy().contains("tools")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 26ac69b6..2869dc85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,7 @@ pub mod agent; pub mod channels; +pub mod cli; pub mod config; pub mod context; pub mod error; diff --git a/src/main.rs b/src/main.rs index 45f3b024..0c411ed9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use near_agent::{ agent::Agent, channels::{ChannelManager, HttpChannel, TuiChannel}, + cli::{Cli, Command, run_tool_command}, config::Config, history::Store, llm::create_llm_provider, @@ -15,27 +16,26 @@ use near_agent::{ tools::ToolRegistry, }; -#[derive(Parser, Debug)] -#[command(name = "near-agent")] -#[command(about = "LLM-powered autonomous agent for the NEAR AI marketplace")] -#[command(version)] -struct Args { - /// Run in interactive CLI mode only (disable other channels) - #[arg(long)] - cli_only: bool, - - /// Skip database connection (for testing) - #[arg(long)] - no_db: bool, - - /// Configuration file path (optional, uses env vars by default) - #[arg(short, long)] - config: Option, -} - #[tokio::main] async fn main() -> anyhow::Result<()> { - let args = Args::parse(); + let cli = Cli::parse(); + + // Handle non-agent commands first (they don't need TUI/full setup) + match &cli.command { + Some(Command::Tool(tool_cmd)) => { + // Simple logging for CLI commands + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + ) + .init(); + + return run_tool_command(tool_cmd.clone()).await; + } + None | Some(Command::Run) => { + // Continue to run agent + } + } // Create TUI channel early so we can hook up logging // (channel is created but not started until agent.run()) @@ -65,7 +65,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Loaded configuration for agent: {}", config.agent.name); // Initialize database store (optional for testing) - let store = if args.no_db { + let store = if cli.no_db { tracing::warn!("Running without database connection"); None } else { @@ -98,7 +98,7 @@ async fn main() -> anyhow::Result<()> { } // Add HTTP channel if configured and not CLI-only mode - if !args.cli_only { + if !cli.cli_only { if let Some(ref http_config) = config.channels.http { channels.add(Box::new(HttpChannel::new(http_config.clone()))); tracing::info!(