Files
openhuman/src/core/cli.rs
T
CodeGhost21andGitHub bdbb83772e feat(observability): Sentry release tracking, source maps, and end-to-end DSN plumbing (#734)
* ci(release): bake Sentry DSN into shipped tauri bundle

Released builds weren't reporting anything to Sentry. Root cause: the
tauri.conf.json `beforeBuildCommand` re-runs `vite build` inside
`cargo tauri build`. The prior `yarn build` step set `VITE_SENTRY_DSN`
for its own run, but the tauri step did not — so the rebuild produced
a DSN-less `dist/` that overwrote the good one, and the shipped web UI
initialized Sentry with an empty DSN (`initSentry` returns early when
`!SENTRY_DSN`).

Fix:

- `release.yml` / `build-desktop` — declare `VITE_SENTRY_DSN` and
  `VITE_DEBUG` on the tauri-build step so the `beforeBuildCommand`
  rebuild bakes them into the final bundle.
- `release-packages.yml` / `build-cli-linux-arm64` — guard against a
  missing `vars.OPENHUMAN_SENTRY_DSN` so the Linux arm64 CLI tarball
  cannot ship without error reporting baked in via `option_env!`.

The core sidecar's `option_env!("OPENHUMAN_SENTRY_DSN")` already gets
the value from the dedicated "Build sidecar core binary" step; the
tauri shell doesn't rebuild it (separate crate, not a workspace dep),
so the baked DSN survives into the bundled installer.

* feat(observability): Sentry release tracking + source maps (#405)

Tags every Sentry event with a canonical release identifier shared by
the frontend and Rust core, uploads source maps so stack traces are
symbolicated in the dashboard, and adds a CLI probe for repeatable
verification of any future release.

Release identifier

  openhuman@<semver>[+<short_git_sha>]

- Frontend (`app/src/utils/config.ts::SENTRY_RELEASE`) builds the tag
  from `VITE_BUILD_SHA`.
- Core sidecar (`src/main.rs::build_release_tag`) builds the same tag
  from `option_env!("OPENHUMAN_BUILD_SHA")`, so events from both
  surfaces group under one release. Cargo's fingerprint already tracks
  `option_env!` changes.

Environment separation

- Frontend: new `APP_ENVIRONMENT` export (`development` | `staging` |
  `production`) derived from `VITE_OPENHUMAN_APP_ENV`, passed to
  `Sentry.init`.
- Core: `resolve_environment` honors `OPENHUMAN_APP_ENV` at runtime,
  falling back to `debug_assertions` detection.

Source-map upload

- `@sentry/vite-plugin` added as an app devDependency.
- `vite.config.ts` emits source maps unconditionally and registers the
  plugin only when `SENTRY_AUTH_TOKEN` is present, so local dev skips
  silently. The plugin uploads `dist/**/*.js{,.map}` under the
  canonical release name and then deletes the on-disk `.map` files so
  they never ship to end users.

CI wiring (`release.yml` + `release-packages.yml`)

- `Build frontend` and `Build and package Tauri app` both receive
  `VITE_BUILD_SHA`, `SENTRY_RELEASE`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`,
  `SENTRY_PROJECT_FRONTEND`. The tauri step needs the same env because
  its `beforeBuildCommand` re-runs `vite build`.
- `Build sidecar core binary` receives `OPENHUMAN_BUILD_SHA` so
  `option_env!` bakes the short SHA into the release tag.
- `build-cli-linux-arm64` mirrors `OPENHUMAN_BUILD_SHA` and
  `OPENHUMAN_APP_ENV` for the arm64 CLI tarball.

Verification support

- New `openhuman sentry-test` CLI subcommand captures an `Error`-level
  event against the currently-initialized client, flushes, and prints
  the event UUID. Optional `--panic` flag exercises the panic
  integration. Requires a DSN resolvable at runtime or baked in at
  compile time; exits non-zero otherwise so misconfiguration is loud.
- `src/main.rs` now loads `.env` before `sentry::init`, so a DSN
  defined only in the repo-local dotenv file (common dev case) is
  honored by the startup-time Sentry client.

Docs

- `docs/sentry.md` covers the release identifier, environment table,
  source-map pipeline, required CI variables, and a verification
  runbook with troubleshooting tips.
- `.env.example` + `app/.env.example` document the new build-time vars.
2026-04-21 22:48:20 -07:00

776 lines
29 KiB
Rust

//! 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> <function> ...`
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<String> = 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 <text>] [--panic]");
println!();
println!(" --message <text> 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<u16> = None;
let mut host: Option<String> = 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::<u16>()
.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 <addr>] [--port <u16>] [--jsonrpc-only] [--autocomplete-logs] [-v|--verbose]");
println!();
println!(
" --host <addr> Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)"
);
println!(
" --port <u16> 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<String> = 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 <name> [--params '<json>']");
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(&params).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<String> = None;
let mut mode: Option<String> = 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 <combo>] [--mode <tap|push>] [--skip-cleanup] [-v]");
println!();
println!(" --hotkey <combo> Key combination (default: fn)");
println!(
" --mode <tap|push> 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 <namespace> <function>`).
///
/// 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<String, Vec<ControllerSchema>>,
) -> 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<Map<String, Value>, 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 --<param> <value>"));
}
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<Value, String> {
match ty {
TypeSchema::String => Ok(Value::String(raw.to_string())),
TypeSchema::Bool => raw
.parse::<bool>()
.map(Value::Bool)
.map_err(|e| format!("expected bool, got '{raw}': {e}")),
TypeSchema::I64 => raw
.parse::<i64>()
.map(|n| Value::Number(n.into()))
.map_err(|e| format!("expected i64, got '{raw}': {e}")),
TypeSchema::U64 => raw
.parse::<u64>()
.map(|n| Value::Number(n.into()))
.map_err(|e| format!("expected u64, got '{raw}': {e}")),
TypeSchema::F64 => {
let n = raw
.parse::<f64>()
.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<String, Vec<ControllerSchema>> {
let mut grouped: BTreeMap<String, Vec<ControllerSchema>> = 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<String, Vec<ControllerSchema>>) {
println!("OpenHuman core CLI\n");
println!("Usage:");
println!(" openhuman run [--host <addr>] [--port <u16>] [--jsonrpc-only] [--verbose]");
println!(" openhuman call --method <name> [--params '<json>']");
println!(" openhuman skills <subcommand> [options] (skill development runtime)");
println!(" openhuman agent <subcommand> [options] (inspect agent definitions & prompts)");
println!(" openhuman voice [--hotkey <combo>] [--mode <tap|push>] (voice dictation server)");
println!(" openhuman tree-summarizer <subcommand> [options] (summary tree CLI)");
println!(" openhuman sentry-test [--message <text>] [--panic] (verify Sentry wiring)");
println!(" openhuman <namespace> <function> [--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 <namespace> --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} <function> --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<Mutex<()>> = 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"));
}
}