//! Command-line interface for the OpenHuman core binary. //! //! This module handles argument parsing, subcommand dispatching, and help printing //! for the CLI. It supports commands for running the server, making RPC calls, //! and invoking domain-specific functionality across various namespaces. use anyhow::Result; use serde_json::{Map, Value}; use std::collections::BTreeMap; use crate::core::all; use crate::core::autocomplete_cli_adapter; use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params}; use crate::core::logging::CliLogDefault; use crate::core::{ControllerSchema, TypeSchema}; /// The ASCII banner displayed when the CLI starts. const CLI_BANNER: &str = r#" ▗▄▖ ▄▄▄▄ ▗▞▀▚▖▄▄▄▄ ▗▖ ▗▖█ ▐▌▄▄▄▄ ▗▞▀▜▌▄▄▄▄ ▐▌ ▐▌█ █ ▐▛▀▀▘█ █ ▐▌ ▐▌▀▄▄▞▘█ █ █ ▝▚▄▟▌█ █ ▐▌ ▐▌█▄▄▄▀ ▝▚▄▄▖█ █ ▐▛▀▜▌ █ █ █ █ ▝▚▄▞▘█ ▐▌ ▐▌ ▀ Contribute & Star us on GitHub: https://github.com/tinyhumansai/openhuman "#; /// Dispatches CLI commands based on arguments. /// /// This is the entry point for CLI argument handling. It performs the following: /// 1. Prints the ASCII welcome banner to stderr. /// 2. Resolves and groups available controller schemas. /// 3. Checks for global help requests. /// 4. Matches the first argument to a subcommand or a domain namespace. /// /// # Arguments /// /// * `args` - A slice of strings containing the command-line arguments. /// /// # Errors /// /// Returns an error if the command fails, parameters are invalid, or if /// the subcommand/namespace is unknown. pub fn run_from_cli_args(args: &[String]) -> Result<()> { // Print the welcome banner to stderr to keep stdout clean for JSON output. eprint!("{CLI_BANNER}"); load_dotenv_for_cli()?; let grouped = grouped_schemas(); if args.is_empty() || is_help(&args[0]) { print_general_help(&grouped); return Ok(()); } // Match on the first argument to determine the subcommand. match args[0].as_str() { "run" | "serve" => run_server_command(&args[1..]), "call" => run_call_command(&args[1..]), // Domain-specific CLI adapters that don't follow the generic namespace pattern. "screen-intelligence" => { crate::core::screen_intelligence_cli::run_screen_intelligence_command(&args[1..]) } "voice" | "dictate" => run_voice_server_command(&args[1..]), "text-input" => crate::core::text_input_cli::run_text_input_command(&args[1..]), "tree-summarizer" => { crate::core::tree_summarizer_cli::run_tree_summarizer_command(&args[1..]) } "memory" => crate::core::memory_cli::run_memory_command(&args[1..]), "agent" => { log::debug!( "[cli] dispatching to agent subcommand, args={:?}", &args[1..] ); crate::core::agent_cli::run_agent_command(&args[1..]) } "sentry-test" => run_sentry_test_command(&args[1..]), // Generic namespace dispatcher: `openhuman ...` namespace => run_namespace_command(namespace, &args[1..], &grouped), } } /// Handles the `sentry-test` subcommand used to verify Sentry wiring end-to-end. /// /// Captures an Error-level event against the currently initialized Sentry /// client (see `sentry::init` in the binary entry point), flushes the client, /// and prints the event UUID to stdout. Optional `--panic` flag additionally /// triggers a panic so the panic integration is exercised too. /// /// Requires a DSN resolvable at runtime — either via the `OPENHUMAN_SENTRY_DSN` /// env var or baked into the binary at build time via `option_env!`. Absent a /// DSN, the command exits non-zero with a diagnostic instead of silently /// producing no telemetry. fn run_sentry_test_command(args: &[String]) -> Result<()> { let mut message: Option = None; let mut do_panic = false; let mut i = 0usize; while i < args.len() { match args[i].as_str() { "--message" => { message = Some( args.get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --message"))? .clone(), ); i += 2; } "--panic" => { do_panic = true; i += 1; } "-h" | "--help" => { println!("Usage: openhuman sentry-test [--message ] [--panic]"); println!(); println!(" --message Body of the Error-level event sent to Sentry"); println!(" (default: \"openhuman sentry-test ping\")"); println!(" --panic After capturing the event, trigger a panic so the"); println!(" panic integration reports it as a separate event."); println!(); println!("Requires OPENHUMAN_SENTRY_DSN at runtime, or baked into the binary at"); println!( "build time via option_env!. On success, prints the event UUID to stdout." ); return Ok(()); } other => return Err(anyhow::anyhow!("unknown sentry-test arg: {other}")), } } let client = sentry::Hub::current().client(); let dsn_host = client .as_deref() .and_then(|c| c.dsn()) .map(|d| d.host().to_string()); match &dsn_host { Some(host) => eprintln!("[sentry-test] Sentry client active (dsn host: {host})"), None => { return Err(anyhow::anyhow!( "Sentry is not initialized in this binary — no DSN is resolvable. \ Set OPENHUMAN_SENTRY_DSN in the environment (or rebuild with it defined \ at compile time) and try again." )); } } let msg = message.unwrap_or_else(|| "openhuman sentry-test ping".to_string()); sentry::configure_scope(|scope| { scope.set_tag("test", "true"); scope.set_tag("source", "sentry-test-cli"); }); let event_id = sentry::capture_message(&msg, sentry::Level::Error); if let Some(c) = client { if !c.flush(Some(std::time::Duration::from_secs(5))) { eprintln!( "[sentry-test] WARNING: flush timed out after 5s — event may not have reached Sentry." ); } } println!("{event_id}"); if do_panic { eprintln!( "[sentry-test] Triggering panic as requested — the panic integration should capture it." ); panic!("openhuman sentry-test intentional panic"); } Ok(()) } /// Loads key/value pairs from a `.env` file into the process environment. /// /// This is used for all CLI entrypoints so direct namespace commands pick up /// the same repo-local configuration as `run` / `serve`. /// /// Precedence: /// 1. Variables already set in the process environment are **not** overwritten. /// 2. If `OPENHUMAN_DOTENV_PATH` is set, that file is loaded. /// 3. Otherwise, it searches for `.env` in the current working directory. fn load_dotenv_for_cli() -> Result<()> { match std::env::var("OPENHUMAN_DOTENV_PATH") { Ok(path) if !path.trim().is_empty() => { dotenvy::from_path(&path).map_err(|e| { anyhow::anyhow!("failed to load dotenv from OPENHUMAN_DOTENV_PATH={path}: {e}") })?; } _ => { let _ = dotenvy::dotenv(); } } Ok(()) } /// Handles the `run` subcommand to start the core HTTP/JSON-RPC server. /// /// This command boots the main application server, including its JSON-RPC /// endpoint, Socket.IO bridge, and background services (voice, vision, etc.). /// /// # Arguments /// /// * `args` - Command-line arguments for the `run` command (e.g., `--port`). fn run_server_command(args: &[String]) -> Result<()> { let mut port: Option = None; let mut host: Option = None; let mut socketio_enabled = true; let mut verbose = false; let mut log_scope = CliLogDefault::Global; let mut i = 0usize; // Manual argument parsing loop for specific flags. while i < args.len() { match args[i].as_str() { "--port" => { let raw = args .get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --port"))?; port = Some( raw.parse::() .map_err(|e| anyhow::anyhow!("invalid --port: {e}"))?, ); i += 2; } "--host" => { host = Some( args.get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --host"))? .clone(), ); i += 2; } "--jsonrpc-only" => { socketio_enabled = false; i += 1; } "-v" | "--verbose" => { verbose = true; i += 1; } other if autocomplete_cli_adapter::parse_run_scope_flag(other).is_some() => { log_scope = autocomplete_cli_adapter::parse_run_scope_flag(other) .unwrap_or(CliLogDefault::Global); i += 1; } "-h" | "--help" => { println!("Usage: openhuman run [--host ] [--port ] [--jsonrpc-only] [--autocomplete-logs] [-v|--verbose]"); println!(); println!( " --host Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)" ); println!( " --port Listen address port (default: 7788 or OPENHUMAN_CORE_PORT)" ); println!(" --jsonrpc-only HTTP JSON-RPC only; disable Socket.IO"); autocomplete_cli_adapter::print_run_scope_help_line(); println!(" -v, --verbose Shorthand for RUST_LOG=debug when RUST_LOG is unset"); println!(); println!("Logging: set RUST_LOG (e.g. RUST_LOG=debug openhuman run). Default level is info."); return Ok(()); } other => return Err(anyhow::anyhow!("unknown run arg: {other}")), } } crate::core::logging::init_for_cli_run(verbose, log_scope); // Initialize the Tokio multi-threaded runtime. let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; rt.block_on(async { crate::core::jsonrpc::run_server(host.as_deref(), port, socketio_enabled).await })?; Ok(()) } /// Handles the `call` subcommand to invoke a JSON-RPC method directly from the CLI. /// /// This is used for one-off commands and debugging, bypassing the HTTP transport /// and calling the internal `invoke_method` directly. /// /// # Arguments /// /// * `args` - Command-line arguments specifying the method and parameters. fn run_call_command(args: &[String]) -> Result<()> { let mut method: Option = None; let mut params = "{}".to_string(); let mut i = 0usize; while i < args.len() { match args[i].as_str() { "--method" => { method = Some( args.get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --method"))? .clone(), ); i += 2; } "--params" => { params = args .get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --params"))? .clone(); i += 2; } "-h" | "--help" => { println!("Usage: openhuman call --method [--params '']"); return Ok(()); } other => return Err(anyhow::anyhow!("unknown call arg: {other}")), } } let method = method.ok_or_else(|| anyhow::anyhow!("--method is required"))?; let params = parse_json_params(¶ms).map_err(anyhow::Error::msg)?; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; let value = rt .block_on(async { invoke_method(default_state(), &method, params).await }) .map_err(anyhow::Error::msg)?; // Output the result as pretty-printed JSON to stdout. println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) } /// Handles the `voice` subcommand to run the standalone voice dictation server. /// /// Listens for a hotkey, records audio, transcribes via whisper, and inserts /// the result into the active text field. fn run_voice_server_command(args: &[String]) -> Result<()> { use crate::openhuman::voice::hotkey::ActivationMode; use crate::openhuman::voice::server::{run_standalone, VoiceServerConfig}; let mut hotkey: Option = None; let mut mode: Option = None; let mut skip_cleanup = false; let mut verbose = false; let mut i = 0usize; while i < args.len() { match args[i].as_str() { "--hotkey" => { hotkey = Some( args.get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --hotkey"))? .clone(), ); i += 2; } "--mode" => { mode = Some( args.get(i + 1) .ok_or_else(|| anyhow::anyhow!("missing value for --mode"))? .clone(), ); i += 2; } "--skip-cleanup" => { skip_cleanup = true; i += 1; } "-v" | "--verbose" => { verbose = true; i += 1; } "-h" | "--help" => { println!("Usage: openhuman voice [--hotkey ] [--mode ] [--skip-cleanup] [-v]"); println!(); println!(" --hotkey Key combination (default: fn)"); println!( " --mode Activation: tap to toggle, push to hold (default: push)" ); println!(" --skip-cleanup Skip LLM post-processing on transcriptions"); println!(" -v, --verbose Enable debug logging"); println!(); println!("Standalone voice dictation server. Press the hotkey to dictate,"); println!("transcribed text is inserted into the active text field."); return Ok(()); } other => return Err(anyhow::anyhow!("unknown voice arg: {other}")), } } crate::core::logging::init_for_cli_run(verbose, CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; rt.block_on(async { let mut config = crate::openhuman::config::Config::load_or_init() .await .unwrap_or_default(); config.apply_env_overrides(); let activation_mode = match mode.as_deref() { Some("tap") => ActivationMode::Tap, _ => ActivationMode::Push, }; let server_config = VoiceServerConfig { hotkey: hotkey.unwrap_or_else(|| config.voice_server.hotkey.clone()), activation_mode, skip_cleanup, context: None, min_duration_secs: config.voice_server.min_duration_secs, silence_threshold: config.voice_server.silence_threshold, custom_dictionary: config.voice_server.custom_dictionary.clone(), }; run_standalone(config, server_config) .await .map_err(anyhow::Error::msg) })?; Ok(()) } /// Dispatches commands that fall under a specific namespace (e.g., `openhuman `). /// /// It looks up the function schema for validation and executes the request. /// /// # Arguments /// /// * `namespace` - The namespace for the command. /// * `args` - Arguments for the function within the namespace. /// * `grouped` - A map of available schemas grouped by namespace. fn run_namespace_command( namespace: &str, args: &[String], grouped: &BTreeMap>, ) -> Result<()> { let Some(schemas) = grouped.get(namespace) else { return Err(anyhow::anyhow!( "unknown namespace '{namespace}'. Run `openhuman --help` to see available namespaces." )); }; let preparsed = autocomplete_cli_adapter::preparse_namespace(namespace, args); let args: &[String] = &preparsed.args; if let Some((verbose, scope)) = preparsed.init_logging { crate::core::logging::init_for_cli_run(verbose, scope); } if args.is_empty() || is_help(&args[0]) { print_namespace_help(namespace, schemas); return Ok(()); } let function = args[0].as_str(); let Some(schema) = schemas.iter().find(|s| s.function == function).cloned() else { return Err(anyhow::anyhow!( "unknown function '{namespace} {function}'. Run `openhuman {namespace} --help`." )); }; // Domain adapters can intercept specific namespace/function combinations. if args.len() > 1 && is_help(&args[1]) && autocomplete_cli_adapter::maybe_print_start_help(namespace, function) { return Ok(()); } if let Some(value) = autocomplete_cli_adapter::maybe_handle_namespace_start(namespace, function, &args[1..])? { println!("{}", serde_json::to_string_pretty(&value)?); return Ok(()); } if args.len() > 1 && is_help(&args[1]) { print_function_help(namespace, &schema); return Ok(()); } // Generic parameter parsing and validation based on schema. let params = parse_function_params(&schema, &args[1..]).map_err(anyhow::Error::msg)?; let method = all::rpc_method_from_parts(namespace, function) .ok_or_else(|| anyhow::anyhow!("unregistered controller '{namespace}.{function}'"))?; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; let value = rt .block_on(async { invoke_method(default_state(), &method, Value::Object(params)).await }) .map_err(anyhow::Error::msg)?; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) } /// Parses command-line arguments into a JSON map based on a function's schema. /// /// # Arguments /// /// * `schema` - The schema defining expected inputs. /// * `args` - The command-line arguments to parse. /// /// # Errors /// /// Returns an error if arguments are malformed, unknown, or fail validation. fn parse_function_params( schema: &ControllerSchema, args: &[String], ) -> Result, String> { let mut out = Map::new(); let mut i = 0usize; while i < args.len() { let raw = &args[i]; if !raw.starts_with("--") { return Err(format!("invalid arg '{raw}', expected -- ")); } let key = raw.trim_start_matches("--").replace('-', "_"); let Some(spec) = schema.inputs.iter().find(|input| input.name == key) else { return Err(format!( "unknown param '{key}' for {}.{}", schema.namespace, schema.function )); }; let raw_value = args .get(i + 1) .ok_or_else(|| format!("missing value for --{key}"))?; let value = parse_input_value(&spec.ty, raw_value)?; out.insert(key, value); i += 2; } all::validate_params(schema, &out)?; Ok(out) } /// Parses a raw string value into a JSON `Value` based on the target `TypeSchema`. /// /// Supports basic types like string, bool, and numbers, as well as complex JSON /// structures for advanced types. /// /// # Arguments /// /// * `ty` - The expected type schema. /// * `raw` - The raw string value from the command line. fn parse_input_value(ty: &TypeSchema, raw: &str) -> Result { match ty { TypeSchema::String => Ok(Value::String(raw.to_string())), TypeSchema::Bool => raw .parse::() .map(Value::Bool) .map_err(|e| format!("expected bool, got '{raw}': {e}")), TypeSchema::I64 => raw .parse::() .map(|n| Value::Number(n.into())) .map_err(|e| format!("expected i64, got '{raw}': {e}")), TypeSchema::U64 => raw .parse::() .map(|n| Value::Number(n.into())) .map_err(|e| format!("expected u64, got '{raw}': {e}")), TypeSchema::F64 => { let n = raw .parse::() .map_err(|e| format!("expected f64, got '{raw}': {e}"))?; serde_json::Number::from_f64(n) .map(Value::Number) .ok_or_else(|| format!("invalid f64 '{raw}'")) } TypeSchema::Option(inner) => parse_input_value(inner, raw), TypeSchema::Enum { .. } => Ok(Value::String(raw.to_string())), TypeSchema::Json | TypeSchema::Array(_) | TypeSchema::Map(_) | TypeSchema::Object { .. } | TypeSchema::Ref(_) | TypeSchema::Bytes => parse_json_params(raw), } } /// Aggregates all registered controller schemas and groups them by namespace. fn grouped_schemas() -> BTreeMap> { let mut grouped: BTreeMap> = BTreeMap::new(); for schema in all::all_controller_schemas() { grouped .entry(schema.namespace.to_string()) .or_default() .push(schema); } // Sort functions within each namespace for consistent help output. for schemas in grouped.values_mut() { schemas.sort_by_key(|s| s.function); } grouped } /// Prints the general help message listing available commands and namespaces. fn print_general_help(grouped: &BTreeMap>) { println!("OpenHuman core CLI\n"); println!("Usage:"); println!(" openhuman run [--host ] [--port ] [--jsonrpc-only] [--verbose]"); println!(" openhuman call --method [--params '']"); println!(" openhuman skills [options] (skill development runtime)"); println!(" openhuman agent [options] (inspect agent definitions & prompts)"); println!(" openhuman voice [--hotkey ] [--mode ] (voice dictation server)"); println!(" openhuman tree-summarizer [options] (summary tree CLI)"); println!(" openhuman sentry-test [--message ] [--panic] (verify Sentry wiring)"); println!(" openhuman [--param value ...]\n"); println!("Available namespaces:"); for namespace in grouped.keys() { let description = all::namespace_description(namespace.as_str()) .unwrap_or("No namespace description available."); println!(" {namespace} - {description}"); } println!("\nUse `openhuman --help` to see functions."); } /// Prints help for a specific namespace, listing its functions. fn print_namespace_help(namespace: &str, schemas: &[ControllerSchema]) { println!("Namespace: {namespace}\n"); if let Some(description) = all::namespace_description(namespace) { println!("{description}\n"); } println!("Functions:"); for schema in schemas { println!(" {} - {}", schema.function, schema.description); } println!("\nUse `openhuman {namespace} --help` for parameters."); autocomplete_cli_adapter::maybe_print_namespace_help_footer(namespace); } /// Prints detailed help for a specific function, including its parameters and description. fn print_function_help(namespace: &str, schema: &ControllerSchema) { println!("{} {}\n", namespace, schema.function); println!("{}", schema.description); println!("\nParameters:"); if schema.inputs.is_empty() { println!(" none"); } else { for input in &schema.inputs { let required = if input.required { "required" } else { "optional" }; println!(" --{} ({}) - {}", input.name, required, input.comment); } } } /// Checks if a string represents a help flag. fn is_help(value: &str) -> bool { matches!(value, "-h" | "--help" | "help") } #[cfg(test)] mod tests { use super::{grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use std::sync::{Mutex, OnceLock}; use tempfile::tempdir; static CLI_ENV_LOCK: OnceLock> = OnceLock::new(); fn env_lock() -> std::sync::MutexGuard<'static, ()> { CLI_ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } #[test] fn grouped_schemas_contains_migrated_namespaces() { let grouped = grouped_schemas(); assert!(grouped.contains_key("health")); assert!(grouped.contains_key("doctor")); assert!(grouped.contains_key("encrypt")); assert!(grouped.contains_key("decrypt")); assert!(grouped.contains_key("autocomplete")); assert!(grouped.contains_key("config")); assert!(grouped.contains_key("auth")); assert!(grouped.contains_key("service")); assert!(grouped.contains_key("migrate")); assert!(grouped.contains_key("local_ai")); } #[test] fn parse_function_params_rejects_unknown_param() { let schema = ControllerSchema { namespace: "test", function: "echo", description: "test schema", inputs: vec![FieldSchema { name: "message", ty: TypeSchema::String, required: true, comment: "message text", }], outputs: vec![FieldSchema { name: "result", ty: TypeSchema::String, required: true, comment: "echo response", }], }; let args = vec!["--unknown".to_string(), "value".to_string()]; let err = parse_function_params(&schema, &args).expect_err("unknown param should fail"); assert!(err.contains("unknown param")); } #[test] fn parse_input_value_rejects_invalid_bool() { let err = parse_input_value(&TypeSchema::Bool, "not-a-bool") .expect_err("invalid bool should fail"); assert!(err.contains("expected bool")); } #[test] fn load_dotenv_for_cli_reads_cwd_dotenv_without_overwriting_existing_env() { let _guard = env_lock(); let tmp = tempdir().expect("tempdir"); let env_path = tmp.path().join(".env"); std::fs::write( &env_path, "BACKEND_URL=https://staging-api.example.test\nOPENHUMAN_APP_ENV=staging\n", ) .expect("write .env"); let original_dir = std::env::current_dir().expect("current dir"); let prior_backend = std::env::var("BACKEND_URL").ok(); let prior_app_env = std::env::var("OPENHUMAN_APP_ENV").ok(); let prior_dotenv_path = std::env::var("OPENHUMAN_DOTENV_PATH").ok(); unsafe { std::env::remove_var("BACKEND_URL"); std::env::set_var("OPENHUMAN_APP_ENV", "production"); std::env::remove_var("OPENHUMAN_DOTENV_PATH"); } std::env::set_current_dir(tmp.path()).expect("set current dir"); let result = load_dotenv_for_cli(); let loaded_backend = std::env::var("BACKEND_URL").ok(); let loaded_app_env = std::env::var("OPENHUMAN_APP_ENV").ok(); std::env::set_current_dir(&original_dir).expect("restore current dir"); unsafe { match prior_backend { Some(value) => std::env::set_var("BACKEND_URL", value), None => std::env::remove_var("BACKEND_URL"), } match prior_app_env { Some(value) => std::env::set_var("OPENHUMAN_APP_ENV", value), None => std::env::remove_var("OPENHUMAN_APP_ENV"), } match prior_dotenv_path { Some(value) => std::env::set_var("OPENHUMAN_DOTENV_PATH", value), None => std::env::remove_var("OPENHUMAN_DOTENV_PATH"), } } result.expect("dotenv load should succeed"); assert_eq!( loaded_backend.as_deref(), Some("https://staging-api.example.test") ); assert_eq!(loaded_app_env.as_deref(), Some("production")); } }