#![deny(warnings)]
#![deny(unsafe_code)] #![deny(missing_debug_implementations)]
mod app;
mod cli;
mod config;
mod dlp;
mod email;
mod hermes_cli;
mod keys;
mod migration;
#[allow(dead_code)]
mod oauth;
mod onboard;
mod openclaw_cli;
mod platform;
mod process;
mod proxy;
mod stats;
mod translate;
mod tui;
use clap::Parser;
use std::error::Error;
use std::io::{BufRead, BufReader};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use tokio::signal;
use tracing::{debug, info, warn};
use crate::app::{AppState, build_router};
use crate::cli::{Cli, Commands, OnAmbiguousOption};
use crate::config::Config;
use crate::migration::core::{
AmbiguityResolutionError, AmbiguityResolver, AmbiguousChoice, MigrationIssue,
};
use crate::migration::orchestrator;
use crate::migration::target::MigrationTarget;
use crate::migration::targets::clawshell_toml::{self, ClawshellTomlTarget, VersionGateStatus};
#[derive(Debug)]
struct InteractiveAmbiguityResolver;
impl AmbiguityResolver for InteractiveAmbiguityResolver {
fn resolve(
&mut self,
issue: &MigrationIssue,
) -> Result<AmbiguousChoice, AmbiguityResolutionError> {
tui::print_warning(&format!(
"Ambiguous migration step for target '{}' ({}): {}",
issue.target, issue.step_id, issue.message
));
tui::print_info("Recommended", &issue.recommended.to_string());
let choice = tui::prompt_select(
"How should migration proceed?",
vec![
"Apply recommended".to_string(),
"Skip this step".to_string(),
"Abort migration".to_string(),
],
)
.map_err(|e| AmbiguityResolutionError::Message(e.to_string()))?;
let decision = match choice.as_str() {
"Apply recommended" => AmbiguousChoice::ApplyRecommended,
"Skip this step" => AmbiguousChoice::Skip,
_ => AmbiguousChoice::Abort,
};
Ok(decision)
}
}
#[derive(Debug)]
struct FailOnAmbiguousResolver;
impl AmbiguityResolver for FailOnAmbiguousResolver {
fn resolve(
&mut self,
issue: &MigrationIssue,
) -> Result<AmbiguousChoice, AmbiguityResolutionError> {
Err(AmbiguityResolutionError::Message(format!(
"Ambiguous migration step '{}' for target '{}': {}. Re-run without --on-ambiguous fail to resolve interactively.",
issue.step_id, issue.target, issue.message
)))
}
}
fn ensure_config_migrated(path: &std::path::Path) -> Result<(), Box<dyn Error>> {
clawshell_toml::ensure_current_version(path).map_err(|e| {
format!(
"{}. Run 'sudo clawshell migrate-config --config {}' to migrate to the current schema.",
e,
path.display()
)
.into()
})
}
fn ensure_default_config_migrated_if_present() -> Result<(), Box<dyn Error>> {
let path = process::default_config_path();
if path.exists() {
ensure_config_migrated(&path)?;
}
Ok(())
}
fn canonicalize_or_original(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn paths_equivalent(left: &Path, right: &Path) -> bool {
canonicalize_or_original(left) == canonicalize_or_original(right)
}
fn try_read_openclaw_config_path(clawshell_config_file: &Path) -> Option<PathBuf> {
let config_content = std::fs::read_to_string(clawshell_config_file).ok()?;
let config_json = serde_json::from_str::<serde_json::Value>(&config_content).ok()?;
config_json
.get("openclaw_config_path")
.and_then(|v| v.as_str())
.map(PathBuf::from)
}
#[derive(Debug, Clone)]
struct WrittenOpenclawSkill {
path: PathBuf,
manifest_entry: onboard::ManagedSkillManifestEntry,
}
fn write_openclaw_skill_bundle(
skill: onboard::OnboardSkillBundle,
openclaw_config_path: &Path,
) -> Result<WrittenOpenclawSkill, Box<dyn Error>> {
let openclaw_root = onboard::openclaw_config_root(openclaw_config_path);
let skill_dir = openclaw_root.join("skills").join(skill.name);
std::fs::create_dir_all(&skill_dir)?;
let mut managed_files: Vec<(String, String)> = Vec::new();
for file in skill.files {
let path = skill_dir.join(file.relative_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &file.content)?;
managed_files.push((file.relative_path.to_string(), file.content));
}
let metadata = onboard::build_managed_skill_metadata(skill.name, &managed_files);
onboard::write_managed_skill_metadata(&skill_dir, &metadata)?;
let manifest_entry = onboard::build_managed_skill_manifest_entry(&skill_dir, &metadata);
if !align_owner_with_openclaw_path(&skill_dir, openclaw_config_path)? {
warn!(
path = %skill_dir.display(),
openclaw_path = %openclaw_config_path.display(),
"Could not determine OpenClaw file owner while writing skill files; will retry later"
);
}
Ok(WrittenOpenclawSkill {
path: skill_dir,
manifest_entry,
})
}
fn write_onboard_openclaw_skill(
ob_config: &crate::onboard::OnboardConfig,
openclaw_config_path: &Path,
) -> Result<Vec<WrittenOpenclawSkill>, Box<dyn Error>> {
let mut written = Vec::new();
let stats_skill = onboard::render_admin_stats_skill(ob_config);
written.push(write_openclaw_skill_bundle(
stats_skill,
openclaw_config_path,
)?);
if let Some(email_skill) = onboard::render_email_messages_skill(ob_config) {
written.push(write_openclaw_skill_bundle(
email_skill,
openclaw_config_path,
)?);
}
Ok(written)
}
#[cfg(unix)]
fn resolve_hermes_target_user() -> Result<(PathBuf, u32, u32), Box<dyn Error>> {
if let Ok(user_name) = std::env::var("SUDO_USER")
&& !user_name.trim().is_empty()
&& user_name.trim() != "root"
&& let Ok(Some(user)) = nix::unistd::User::from_name(user_name.trim())
{
return Ok((user.dir, user.uid.as_raw(), user.gid.as_raw()));
}
let uid = nix::unistd::getuid().as_raw();
let gid = nix::unistd::getgid().as_raw();
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| -> Box<dyn Error> {
"failed to resolve target user home directory for Hermes skill install (HOME unset)"
.into()
})?;
Ok((home, uid, gid))
}
#[cfg(not(unix))]
fn resolve_hermes_target_user() -> Result<(PathBuf, u32, u32), Box<dyn Error>> {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| -> Box<dyn Error> { "HOME environment variable not set".into() })?;
Ok((home, 0, 0))
}
fn write_hermes_skill_bundle(
skill: onboard::OnboardSkillBundle,
home_dir: &Path,
uid: u32,
gid: u32,
) -> Result<PathBuf, Box<dyn Error>> {
let skill_dir = home_dir.join(".hermes").join("skills").join(skill.name);
std::fs::create_dir_all(&skill_dir)?;
for file in skill.files {
let path = skill_dir.join(file.relative_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &file.content)?;
}
let owner_spec = format!("{uid}:{gid}");
if let Err(error) = chown_path(&skill_dir, &owner_spec, true) {
warn!(
error = %error,
path = %skill_dir.display(),
"Failed to chown Hermes skill dir to invoking user"
);
}
Ok(skill_dir)
}
fn write_onboard_hermes_skill(
ob_config: &crate::onboard::OnboardConfig,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let (home_dir, uid, gid) = resolve_hermes_target_user()?;
let mut written = Vec::new();
let stats_skill = onboard::render_admin_stats_skill(ob_config);
written.push(write_hermes_skill_bundle(stats_skill, &home_dir, uid, gid)?);
if let Some(email_skill) = onboard::render_email_messages_skill(ob_config) {
written.push(write_hermes_skill_bundle(email_skill, &home_dir, uid, gid)?);
}
Ok(written)
}
fn resolve_openclaw_owner_spec(openclaw_path: &Path) -> Result<Option<String>, Box<dyn Error>> {
use std::os::unix::fs::MetadataExt;
let read_owner = |path: &Path| -> Result<(u32, u32), Box<dyn Error>> {
let metadata = std::fs::metadata(path)?;
Ok((metadata.uid(), metadata.gid()))
};
let file_owner = if openclaw_path.exists() {
Some(read_owner(openclaw_path)?)
} else {
None
};
let parent_owner = openclaw_path
.parent()
.filter(|parent| parent.exists())
.map(read_owner)
.transpose()?;
let root_entry_owner = {
let root = onboard::openclaw_config_root(openclaw_path);
if root.exists() {
let mut found = None;
for entry in std::fs::read_dir(root)? {
let entry = entry?;
found = Some(read_owner(&entry.path())?);
if let Some((uid, _)) = found
&& uid != 0
{
break;
}
}
found
} else {
None
}
};
let preferred = file_owner
.filter(|(uid, _)| *uid != 0)
.or_else(|| root_entry_owner.filter(|(uid, _)| *uid != 0))
.or_else(|| parent_owner.filter(|(uid, _)| *uid != 0))
.or(file_owner)
.or(root_entry_owner)
.or(parent_owner);
Ok(preferred.map(|(uid, gid)| format!("{uid}:{gid}")))
}
fn chown_path(path: &Path, owner_spec: &str, recursive: bool) -> Result<(), Box<dyn Error>> {
let mut command = std::process::Command::new("chown");
if recursive {
command.arg("-R");
}
let path_arg = path.to_string_lossy().into_owned();
command.args([owner_spec, path_arg.as_str()]);
let output = command.output()?;
if output.status.success() {
return Ok(());
}
Err(format!(
"chown failed for '{}' with owner '{}': status={}, stderr={}",
path.display(),
owner_spec,
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)
.into())
}
fn align_owner_with_openclaw_path(
path: &Path,
openclaw_path: &Path,
) -> Result<bool, Box<dyn Error>> {
let Some(owner_spec) = resolve_openclaw_owner_spec(openclaw_path)? else {
return Ok(false);
};
chown_path(path, &owner_spec, true)?;
Ok(true)
}
fn print_openclaw_recovery_notice(openclaw_path: &Path, backup_path: &Path) {
let bak = backup_path.display().to_string();
let orig = openclaw_path.display().to_string();
let mut lines = vec![
"If anything goes wrong, restore from the backup:".to_string(),
String::new(),
format!(" $ sudo chmod 600 {bak}"),
format!(" $ sudo cp {bak} {orig}"),
format!(" $ sudo chmod 600 {orig}"),
];
let bak1 = openclaw_path.with_file_name("openclaw.json.clawshell.bak.1");
if bak1.exists() {
lines.push(String::new());
lines.push("Multiple backups exist — higher numbers are more recent.".to_string());
lines.push("All backups are owned by 'clawshell' with mode 000 (no access).".to_string());
lines.push("OpenClaw cannot read them — use sudo to restore.".to_string());
}
let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
tui::print_callout("Recovery", &line_refs);
}
fn preview_matching_openclaw_config_env_removals(
openclaw_path: &Path,
mapped_real_key: &str,
) -> Result<Vec<String>, Box<dyn Error>> {
if !openclaw_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(openclaw_path)?;
let json: serde_json::Value = serde_json::from_str(&content)?;
let Some(env) = json.get("env").and_then(serde_json::Value::as_object) else {
return Ok(Vec::new());
};
let mut removals: Vec<String> = env
.iter()
.filter_map(|(key, value)| {
let is_legacy = matches!(
key.as_str(),
"OPENAI_API_KEY" | "ANTHROPIC_API_KEY" | "ANTHROPIC_OAUTH_TOKEN"
);
if !is_legacy {
return None;
}
value
.as_str()
.is_some_and(|v| v.trim() == mapped_real_key)
.then(|| format!("env.{key}"))
})
.collect();
removals.sort_unstable();
Ok(removals)
}
#[derive(Debug, Clone)]
struct OpenclawConfigMutationPreview {
env_after: serde_json::Value,
default_models_after: serde_json::Value,
providers_after: serde_json::Value,
env_removals: Vec<String>,
}
fn json_object_at_pointer_or_empty(json: &serde_json::Value, pointer: &str) -> serde_json::Value {
json.pointer(pointer)
.filter(|value| value.is_object())
.cloned()
.unwrap_or_else(|| serde_json::json!({}))
}
fn build_openclaw_config_mutation_preview(
openclaw_path: &Path,
ob_config: &onboard::OnboardConfig,
) -> Result<OpenclawConfigMutationPreview, Box<dyn Error>> {
let current_json = if openclaw_path.exists() {
let content = std::fs::read_to_string(openclaw_path)?;
serde_json::from_str::<serde_json::Value>(&content)?
} else {
serde_json::json!({})
};
let partial_json = serde_json::json!({
"env": json_object_at_pointer_or_empty(¤t_json, "/env"),
"agents": {
"defaults": {
"models": json_object_at_pointer_or_empty(¤t_json, "/agents/defaults/models")
}
},
"models": {
"providers": json_object_at_pointer_or_empty(¤t_json, "/models/providers")
}
});
let partial_content = serde_json::to_string(&partial_json)?;
let modified_content =
onboard::patch_openclaw_config_for_clawshell(&partial_content, ob_config)?;
let modified_json: serde_json::Value = serde_json::from_str(&modified_content)?;
Ok(OpenclawConfigMutationPreview {
env_after: json_object_at_pointer_or_empty(&modified_json, "/env"),
default_models_after: json_object_at_pointer_or_empty(
&modified_json,
"/agents/defaults/models",
),
providers_after: json_object_at_pointer_or_empty(&modified_json, "/models/providers"),
env_removals: preview_matching_openclaw_config_env_removals(
openclaw_path,
&ob_config.real_api_key,
)?,
})
}
fn fallback_openclaw_config_mutation_preview(
ob_config: &onboard::OnboardConfig,
) -> OpenclawConfigMutationPreview {
let model_key = format!("clawshell/{}", ob_config.model);
let base_url = format!(
"http://{}:{}/v1",
ob_config.server_host, ob_config.server_port
);
OpenclawConfigMutationPreview {
env_after: serde_json::json!({
"CLAWSHELL_API_KEY": ob_config.virtual_api_key
}),
default_models_after: serde_json::json!({
model_key: {
"alias": "clawshell"
}
}),
providers_after: serde_json::json!({
"clawshell": {
"baseUrl": base_url,
"api": "openai-completions",
"apiKey": "${CLAWSHELL_API_KEY}",
"models": [
{
"id": ob_config.model,
"name": ob_config.model,
}
]
}
}),
env_removals: Vec::new(),
}
}
fn print_openclaw_config_mutation_preview(
preview: &OpenclawConfigMutationPreview,
) -> Result<(), Box<dyn Error>> {
let print_json = |label: &str, value: &serde_json::Value| -> Result<(), Box<dyn Error>> {
tui::print_info(label, "");
let pretty = serde_json::to_string_pretty(value)?;
for line in pretty.lines() {
println!(" {line}");
}
Ok(())
};
print_json("Set env", &preview.env_after)?;
print_json("Set agents.defaults.models", &preview.default_models_after)?;
print_json("Set models.providers", &preview.providers_after)?;
if preview.env_removals.is_empty() {
tui::print_info(
"Remove from config",
"none (no mapped legacy env key in openclaw.json)",
);
} else {
for removal in &preview.env_removals {
tui::print_info("Remove from config", removal);
}
}
Ok(())
}
fn print_openclaw_cleanup_file_preview(preview: &onboard::OpenclawFileRemovalPreview) {
tui::print_info("Edit file", &preview.path.display().to_string());
for removal in &preview.removals {
tui::print_info("Remove", removal);
}
tui::print_info("Backup", &preview.backup_path.display().to_string());
}
fn ensure_service_installed_for_lifecycle() -> Result<(), Box<dyn Error>> {
if platform::service_exists()? {
return Ok(());
}
Err(format!(
"ClawShell service is not installed at '{}'. Run 'sudo clawshell onboard' to install it.",
onboard::autostart_service_path()
)
.into())
}
fn ensure_service_config_matches(requested_config: &Path) -> Result<(), Box<dyn Error>> {
ensure_service_installed_for_lifecycle()?;
let configured = platform::service_config_path()?.ok_or_else(|| {
format!(
"Could not determine service config path from '{}'. Reinstall the service with 'sudo clawshell onboard'.",
onboard::autostart_service_path()
)
})?;
if paths_equivalent(&configured, requested_config) {
return Ok(());
}
Err(format!(
"Service is configured with '{}', but command requested '{}'. Reinstall the auto-start service to change the config path.",
configured.display(),
requested_config.display()
)
.into())
}
fn print_migration_status(path: &str, status: &VersionGateStatus) {
match status {
VersionGateStatus::Current(version) => {
tui::print_info("Schema version", &version.to_string());
}
VersionGateStatus::Missing => {
tui::print_warning("Schema version: missing (migration required)");
tui::print_info(
"Run",
&format!("sudo clawshell migrate-config --config {path}"),
);
}
VersionGateStatus::Mismatch { found } => {
tui::print_warning(&format!(
"Schema version mismatch: found {}, expected {}",
found,
crate::migration::core::ConfigVersion::current()
));
tui::print_info(
"Run",
&format!("sudo clawshell migrate-config --config {path}"),
);
}
}
}
fn ensure_rustls_crypto_provider() -> Result<(), Box<dyn Error>> {
if rustls::crypto::CryptoProvider::get_default().is_some() {
return Ok(());
}
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| std::io::Error::other("failed to install rustls ring CryptoProvider"))?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
ensure_rustls_crypto_provider()?;
let cli = Cli::parse();
match cli.command {
Commands::Start { config, foreground } => cmd_start(&config, foreground).await?,
Commands::Stop => cmd_stop()?,
Commands::Status => cmd_status()?,
Commands::Restart { config } => cmd_restart(&config).await?,
Commands::Logs {
level,
filter,
num,
follow,
} => cmd_logs(level, filter, num, follow).await?,
Commands::Config { config, edit } => cmd_config(&config, edit)?,
Commands::MigrateConfig {
config,
on_ambiguous,
} => cmd_migrate_config(&config, on_ambiguous)?,
Commands::Onboard => cmd_onboard()?,
Commands::Uninstall { yes } => cmd_uninstall(yes)?,
Commands::Version => cmd_version(),
}
Ok(())
}
async fn cmd_start(config_path: &str, foreground: bool) -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Start");
if foreground {
return cmd_start_inner(config_path).await;
}
let path = PathBuf::from(config_path);
ensure_config_migrated(&path)?;
Config::from_file(&path)
.map_err(|e| format!("Failed to load configuration from '{}': {}", config_path, e))?;
ensure_service_config_matches(&path)?;
tui::print_info("Config", config_path);
println!("Starting ClawShell via service manager...");
platform::service_start()?;
tui::print_success("ClawShell started successfully.");
tui::print_info("Logs", &process::log_file_path().display().to_string());
Ok(())
}
async fn cmd_start_inner(config_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let path = PathBuf::from(config_path);
ensure_config_migrated(&path)?;
let config = Config::from_file(&path)
.map_err(|e| format!("Failed to load configuration from '{}': {}", config_path, e))?;
let oauth_registry = build_oauth_registry(&config).await?;
let app_state = AppState::from_config_with_registry(&config, Some(oauth_registry))
.map_err(|e| format!("Failed to initialize app state: {e}"))?;
tui::print_success("Configuration validated successfully.");
process::ensure_runtime_dirs()?;
let env_filter: tracing_subscriber::EnvFilter = config
.log_level
.parse()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(true)
.init();
let listen_addr = config
.resolved_listen_addr()
.map_err(|e| format!("invalid server environment override: {e}"))?;
info!(
listen = %listen_addr,
upstream = config.upstream.openai_base_url,
keys = config.keys.len(),
oauth_providers = config.oauth_providers.len(),
"ClawShell starting"
);
debug!(
dlp_patterns = config.dlp.patterns.len(),
scan_responses = config.dlp.scan_responses,
log_level = %config.log_level,
"Configuration loaded"
);
let cancel = tokio_util::sync::CancellationToken::new();
app_state.oauth_registry.spawn_refresh_tasks(cancel.clone());
let stats_for_task = app_state.stats.clone();
let stats_cancel = cancel.clone();
tokio::spawn(async move {
let interval = std::time::Duration::from_secs(30);
loop {
tokio::select! {
_ = stats_cancel.cancelled() => break,
_ = tokio::time::sleep(interval) => {
if let Err(err) = stats_for_task.persist() {
warn!(error = %err, "failed to persist stats");
}
}
}
}
});
let addr: SocketAddr = listen_addr.parse()?;
let stats_for_shutdown = app_state.stats.clone();
let app = build_router(app_state);
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Listening on {}", addr);
process::drop_privileges()?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let ctrl_c = signal::ctrl_c();
#[cfg(unix)]
let mut term = signal::unix::signal(signal::unix::SignalKind::terminate()).unwrap();
#[cfg(unix)]
tokio::select! { _ = ctrl_c => {}, _ = term.recv() => {} };
#[cfg(not(unix))]
ctrl_c.await.ok();
info!("Shutdown signal received");
})
.await?;
cancel.cancel();
if let Err(err) = stats_for_shutdown.persist() {
warn!(error = %err, "failed to persist stats on shutdown");
}
info!("ClawShell shut down");
Ok(())
}
async fn build_oauth_registry(
config: &Config,
) -> Result<crate::oauth::OAuthRegistry, Box<dyn std::error::Error>> {
use crate::oauth::{OAuthRegistry, TokenStorage, codex::CodexProvider};
use std::sync::Arc;
let storage = TokenStorage::new(PathBuf::from("/etc/clawshell/oauth"));
let mut registry = OAuthRegistry::new(storage);
for provider_config in &config.oauth_providers {
if !provider_config.enabled {
continue;
}
match provider_config.provider.as_str() {
"codex" => {
let provider = CodexProvider::from_config(provider_config);
registry.register(Arc::new(provider));
}
other => {
return Err(format!("Unknown OAuth provider type: '{other}'").into());
}
}
}
registry.load_tokens().await?;
Ok(registry)
}
fn cmd_stop() -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Stop");
ensure_default_config_migrated_if_present()?;
ensure_service_installed_for_lifecycle()?;
if !platform::service_is_running()? {
tui::print_warning("ClawShell is not running.");
return Ok(());
}
println!("Stopping ClawShell via service manager...");
platform::service_stop()?;
tui::print_success("ClawShell stopped successfully.");
tui::print_info("Logs", &process::log_file_path().display().to_string());
Ok(())
}
fn cmd_status() -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Status");
ensure_service_installed_for_lifecycle()?;
if platform::service_is_running()? {
tui::print_success("ClawShell is running.");
} else {
tui::print_warning("ClawShell is not running.");
}
Ok(())
}
async fn cmd_restart(config_path: &str) -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Restart");
let path = PathBuf::from(config_path);
ensure_config_migrated(&path)?;
Config::from_file(&path)
.map_err(|e| format!("Failed to load configuration from '{}': {}", config_path, e))?;
ensure_service_config_matches(&path)?;
tui::print_info("Config", config_path);
println!("Restarting ClawShell via service manager...");
platform::service_restart()?;
tui::print_success("ClawShell restarted successfully.");
tui::print_info("Logs", &process::log_file_path().display().to_string());
Ok(())
}
async fn cmd_logs(
level: Option<String>,
filter: Option<String>,
num: usize,
follow: bool,
) -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Logs");
let log_path = process::log_file_path();
if !log_path.exists() {
tui::print_warning(&format!(
"No logs available. Log file not found at: {}",
log_path.display()
));
return Ok(());
}
let file = std::fs::File::open(&log_path)?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader
.lines()
.map_while(Result::ok)
.filter(|line| {
if let Some(ref lvl) = level {
let lvl_upper = lvl.to_uppercase();
line.to_uppercase().contains(&lvl_upper)
} else {
true
}
})
.filter(|line| {
if let Some(ref keyword) = filter {
line.contains(keyword)
} else {
true
}
})
.collect();
if lines.is_empty() {
tui::print_warning("No matching log entries found.");
return Ok(());
}
let start = if lines.len() > num {
lines.len() - num
} else {
0
};
for line in &lines[start..] {
println!("{}", line);
}
if follow {
tui::print_section("Following log output (Ctrl+C to stop)");
let mut pos = std::fs::metadata(&log_path)?.len();
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let current_len = std::fs::metadata(&log_path)?.len();
if current_len > pos {
let mut file = std::fs::File::open(&log_path)?;
std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(pos))?;
let reader = BufReader::new(file);
for line in reader.lines().map_while(Result::ok) {
let show = level
.as_ref()
.is_none_or(|lvl| line.to_uppercase().contains(&lvl.to_uppercase()));
let show = show && filter.as_ref().is_none_or(|kw| line.contains(kw));
if show {
println!("{}", line);
}
}
pos = current_len;
}
}
}
Ok(())
}
fn cmd_config(config_path: &str, edit: bool) -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Configuration");
let path = PathBuf::from(config_path);
if edit {
if !path.exists() {
tui::print_error(&format!("Configuration file not found: {config_path}"));
std::process::exit(1);
}
ensure_config_migrated(&path)?;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
if path.exists()
&& let Err(e) = Config::from_file(&path)
{
tui::print_warning(&format!("Current configuration has errors: {e}"));
}
let status = std::process::Command::new(&editor)
.arg(config_path)
.status()?;
if !status.success() {
tui::print_error("Editor exited with non-zero status.");
return Ok(());
}
match Config::from_file(&path) {
Ok(config) => {
tui::print_success("Configuration is valid.");
tui::print_info("Server", &config.listen_addr());
tui::print_info("Keys", &config.keys.len().to_string());
tui::print_info("DLP patterns", &config.dlp.patterns.len().to_string());
tui::print_warning(
"Changes will take effect after restarting ClawShell (clawshell restart).",
);
}
Err(e) => {
tui::print_error(&format!("Configuration validation failed: {e}"));
tui::print_warning("Please fix the errors before restarting ClawShell.");
}
}
return Ok(());
}
if !path.exists() {
tui::print_error(&format!("Configuration file not found: {config_path}"));
tui::print_warning(&format!(
"Run 'clawshell config --edit -f {}' to create one.",
config_path
));
std::process::exit(1);
}
let content = std::fs::read_to_string(&path)?;
let version_status = clawshell_toml::version_gate_status(&path);
match Config::from_file(&path) {
Ok(config) => {
tui::print_info("File", config_path);
tui::print_success("Status: Valid");
match &version_status {
Ok(status) => print_migration_status(config_path, status),
Err(e) => tui::print_warning(&format!(
"Could not determine migration status from version field: {}",
e
)),
}
println!();
tui::print_section("Server");
tui::print_info("Listen", &config.listen_addr());
tui::print_info("Log level", &config.log_level);
tui::print_info("Upstream (OpenAI)", &config.upstream.openai_base_url);
tui::print_info(
"Upstream (OpenRouter)",
config
.upstream
.openrouter_base_url
.as_deref()
.unwrap_or("https://openrouter.ai/api (default)"),
);
tui::print_info(
"Upstream (Anthropic)",
config
.upstream
.anthropic_base_url
.as_deref()
.unwrap_or("https://api.anthropic.com (default)"),
);
tui::print_info(
"Upstream (MiniMax)",
config
.upstream
.minimax_base_url
.as_deref()
.unwrap_or("https://api.minimax.io (default)"),
);
tui::print_section("Keys");
println!(" {} configured", config.keys.len());
for key in &config.keys {
println!(
" {} {} (provider: {:?})",
tui::theme_style().apply_to("▸"),
key.virtual_key,
key.provider,
);
}
tui::print_section("DLP");
tui::print_info("Patterns", &config.dlp.patterns.len().to_string());
tui::print_info(
"Response scanning",
if config.dlp.scan_responses {
"enabled"
} else {
"disabled"
},
);
for p in &config.dlp.patterns {
println!(
" {} {} (action: {:?})",
tui::theme_style().apply_to("▸"),
p.name,
p.action
);
}
}
Err(e) => {
tui::print_info("File", config_path);
tui::print_error(&format!("Status: INVALID - {e}"));
if let Ok(status) = &version_status {
print_migration_status(config_path, status);
}
println!();
tui::print_section("Raw content");
println!("{}", content);
}
}
Ok(())
}
fn cmd_migrate_config(
config_path: &str,
on_ambiguous: Option<OnAmbiguousOption>,
) -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Migrate Config");
if !nix::unistd::getuid().is_root() {
tui::print_callout(
"Administrative Privileges Required",
&[
"Configuration migration updates protected files under /etc/clawshell.",
"",
"Please re-run with sudo:",
"",
&format!(" $ sudo clawshell migrate-config --config {config_path}"),
],
);
std::process::exit(1);
}
let path = PathBuf::from(config_path);
if !path.exists() {
tui::print_error(&format!("Configuration file not found: {}", path.display()));
std::process::exit(1);
}
let targets: Vec<Box<dyn MigrationTarget>> = vec![Box::new(ClawshellTomlTarget::new(path))];
let mut resolver: Box<dyn AmbiguityResolver> = match on_ambiguous {
Some(OnAmbiguousOption::Fail) => Box::new(FailOnAmbiguousResolver),
None => Box::new(InteractiveAmbiguityResolver),
};
let report = orchestrator::migrate_targets(&targets, resolver.as_mut())?;
tui::print_info("Target version", &report.to_version.to_string());
for target in report.targets {
println!();
tui::print_section(&format!("Target: {}", target.target_name));
tui::print_info("File", &target.path.display().to_string());
tui::print_info("From", &target.from_version.to_string());
tui::print_info("To", &target.to_version.to_string());
if target.changed {
tui::print_success("Migration applied.");
if let Some(backup) = target.backup_path {
tui::print_info("Backup", &backup.display().to_string());
}
} else {
tui::print_success("Already up to date.");
}
for step in target.applied_steps {
tui::print_info("Step", &step);
}
for warning in target.warnings {
tui::print_warning(&warning);
}
}
println!();
tui::print_success("Migration completed.");
Ok(())
}
const ONBOARD_TOTAL_STEPS: usize = 9;
fn apply_openclaw_onboarding_steps(
ob_config: &crate::onboard::OnboardConfig,
openclaw_path: &Path,
config_file: &Path,
) -> Result<(), Box<dyn Error>> {
const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS;
tui::print_step(6, TOTAL_STEPS, "OpenClaw skill setup...");
println!();
let openclaw_skill_edit_approved = tui::prompt_confirm("Write OpenClaw skill files", true)?;
let openclaw_skills: Vec<WrittenOpenclawSkill> = if openclaw_skill_edit_approved {
let written = write_onboard_openclaw_skill(ob_config, openclaw_path)?;
for skill in &written {
onboard::upsert_managed_skill_manifest_entry(config_file, &skill.manifest_entry)?;
tui::print_info("OpenClaw skill", &skill.path.display().to_string());
}
let mut openclaw_runner = openclaw_cli::RealOpenclawRunner;
let channel = openclaw_cli::detect_openclaw_channel(&mut openclaw_runner);
match openclaw_cli::setup_openclaw_stats_cron(&mut openclaw_runner, channel.as_deref()) {
Ok(()) => {
let dest = channel.as_deref().unwrap_or("log only");
tui::print_info(
"Cron job",
&format!("clawshell-weekly-stats (Mon 09:00, deliver: {dest})"),
);
}
Err(err) => {
tui::print_warning(&format!("Failed to set up weekly stats cron job: {err}"))
}
}
tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written");
written
} else {
tui::print_step_done(
6,
TOTAL_STEPS,
"OpenClaw skills skipped (approval not granted)",
);
Vec::new()
};
tui::print_step(7, TOTAL_STEPS, "Backing up OpenClaw configuration...");
if openclaw_path.exists() {
let backup = onboard::backup_openclaw_config(openclaw_path)?;
tui::print_step_done(7, TOTAL_STEPS, "OpenClaw config backed up");
tui::print_info("Backup", &backup.display().to_string());
print_openclaw_recovery_notice(openclaw_path, &backup);
} else {
tui::print_step_done(7, TOTAL_STEPS, "OpenClaw config backup skipped");
tui::print_warning(&format!(
"OpenClaw config not found at: {}",
openclaw_path.display()
));
}
tui::print_step(8, TOTAL_STEPS, "OpenClaw update setup...");
let openclaw_state_dir = onboard::openclaw_config_root(openclaw_path);
println!();
let cleanup_preview = onboard::preview_openclaw_provider_credential_cleanup(
&openclaw_state_dir,
&ob_config.real_api_key,
)?;
let config_mutation_preview = match build_openclaw_config_mutation_preview(
openclaw_path,
ob_config,
) {
Ok(preview) => preview,
Err(error) => {
warn!(
error = %error,
path = %openclaw_path.display(),
"Failed to build exact OpenClaw config mutation preview; showing fallback payload"
);
fallback_openclaw_config_mutation_preview(ob_config)
}
};
tui::print_info(
"OpenClaw state dir",
&openclaw_state_dir.display().to_string(),
);
tui::print_info("OpenClaw config path", &openclaw_path.display().to_string());
tui::print_info(
"Mapped-key policy",
"Only entries matching the mapped virtual-key target will be removed",
);
if cleanup_preview.state_dir_exists {
if let Some(dot_env) = cleanup_preview.dot_env.as_ref() {
print_openclaw_cleanup_file_preview(dot_env);
}
for auth_profile in &cleanup_preview.auth_profiles {
print_openclaw_cleanup_file_preview(auth_profile);
}
if let Some(oauth) = cleanup_preview.oauth.as_ref() {
print_openclaw_cleanup_file_preview(oauth);
}
if !cleanup_preview.has_changes() {
tui::print_info("State-dir edits", "none (no mapped-key match)");
}
} else {
tui::print_warning(&format!(
"OpenClaw state dir not found: {}",
openclaw_state_dir.display()
));
}
print_openclaw_config_mutation_preview(&config_mutation_preview)?;
let openclaw_edit_approved = tui::prompt_confirm(
"Proceed with the exact OpenClaw edits shown above (backups first)",
true,
)?;
if !openclaw_edit_approved {
tui::print_step_done(
8,
TOTAL_STEPS,
"OpenClaw update skipped (approval not granted)",
);
return Err("Onboarding aborted: OpenClaw edit approval was not granted.".into());
}
tui::print_step(8, TOTAL_STEPS, "Applying OpenClaw updates...");
println!();
let cleanup = onboard::cleanup_openclaw_provider_credentials(
&openclaw_state_dir,
&ob_config.real_api_key,
)?;
if cleanup.has_changes() {
tui::print_info("Legacy credential cleanup", "applied");
tui::print_info(
"Env entries removed",
&cleanup.dot_env_entries_removed.to_string(),
);
tui::print_info(
"Auth profiles updated",
&cleanup.auth_profile_files_updated.to_string(),
);
tui::print_info(
"Auth profile entries removed",
&cleanup.auth_profile_entries_removed.to_string(),
);
tui::print_info(
"OAuth entries removed",
&cleanup.oauth_entries_removed.to_string(),
);
tui::print_info(
"Backup files created",
&cleanup.backup_files_created.to_string(),
);
}
let mut openclaw_runner = openclaw_cli::RealOpenclawRunner;
tui::print_info(
"OpenClaw workaround",
"Temporarily setting `gateway.reload.mode` to `off` during config updates, then restoring `hybrid`.",
);
openclaw_cli::apply_onboard_openclaw_config(&mut openclaw_runner, ob_config)?;
for skill in &openclaw_skills {
align_owner_with_openclaw_path(&skill.path, openclaw_path)?;
}
tui::print_step_done(8, TOTAL_STEPS, "OpenClaw config updated");
Ok(())
}
fn apply_hermes_onboarding_steps(
ob_config: &crate::onboard::OnboardConfig,
) -> Result<(), Box<dyn Error>> {
const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS;
tui::print_step(6, TOTAL_STEPS, "Hermes skill setup...");
match write_onboard_hermes_skill(ob_config) {
Ok(paths) if paths.is_empty() => {
tui::print_step_done(6, TOTAL_STEPS, "Hermes skills skipped");
}
Ok(paths) => {
for path in &paths {
tui::print_info("Hermes skill", &path.display().to_string());
}
let (home_dir, _, _) = resolve_hermes_target_user()?;
let channel = hermes_cli::detect_hermes_channel(&home_dir);
let mut hermes_runner = hermes_cli::RealHermesRunner;
match hermes_cli::setup_hermes_stats_cron(&mut hermes_runner, channel.as_deref()) {
Ok(()) => {
let dest = channel.as_deref().unwrap_or("log only");
tui::print_info(
"Cron job",
&format!("clawshell-weekly-stats (Mon 09:00, deliver: {dest})"),
);
}
Err(err) => {
tui::print_warning(&format!("Failed to set up weekly stats cron job: {err}"))
}
}
tui::print_step_done(6, TOTAL_STEPS, "Hermes skills written");
}
Err(error) => {
tui::print_error(&format!("Failed to write Hermes skills: {error}"));
tui::print_step_done(
6,
TOTAL_STEPS,
"Hermes skills skipped (write failed — see error above)",
);
}
}
tui::print_step(7, TOTAL_STEPS, "Backup step...");
tui::print_step_done(
7,
TOTAL_STEPS,
"Backup not applicable for Hermes target (skipped)",
);
tui::print_step(8, TOTAL_STEPS, "Applying Hermes updates...");
tui::print_info("Hermes Agent", "configuring via `hermes config set`...");
let mut hermes_runner = hermes_cli::RealHermesRunner;
match hermes_cli::apply_onboard_hermes_config(&mut hermes_runner, ob_config) {
Ok(()) => {
tui::print_step_done(8, TOTAL_STEPS, "Hermes config updated");
}
Err(error) => {
tui::print_error(&format!(
"Failed to configure Hermes Agent: {error}. \
You can retry later with `hermes config set model.provider custom` \
and related keys."
));
return Err(
format!("Hermes onboarding failed during `hermes config set`: {error}").into(),
);
}
}
Ok(())
}
fn cmd_onboard() -> Result<(), Box<dyn std::error::Error>> {
use crate::onboard;
const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS;
tui::print_banner("Onboarding");
if !nix::unistd::getuid().is_root() {
tui::print_callout(
"Administrative Privileges Required",
&[
"This process needs to set secure permissions on sensitive",
"files such as API keys and configuration.",
"",
"Please re-run with sudo:",
"",
" $ sudo clawshell onboard",
],
);
std::process::exit(1);
}
let existing_config = process::default_config_path();
if existing_config.exists() {
ensure_config_migrated(&existing_config)?;
}
tui::print_warning("Administrative privileges in use — securing sensitive files.");
println!();
tui::print_step(1, TOTAL_STEPS, "Checking for 'clawshell' system user...");
let user_exists = std::process::Command::new("id")
.arg("clawshell")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if user_exists {
tui::print_step_done(1, TOTAL_STEPS, "System user already exists");
} else {
if let Err(error) = platform::create_system_user("clawshell") {
tui::print_error(&format!("Failed to create 'clawshell' user: {error}"));
std::process::exit(1);
}
tui::print_step_done(1, TOTAL_STEPS, "System user created");
}
let config_dir = PathBuf::from("/etc/clawshell");
let log_dir_path = PathBuf::from("/var/log/clawshell");
tui::print_step(2, TOTAL_STEPS, "Setting up directories...");
std::fs::create_dir_all(&config_dir)?;
std::fs::create_dir_all(&log_dir_path)?;
tui::print_step_done(2, TOTAL_STEPS, "Directories created");
tui::print_step(3, TOTAL_STEPS, "Setting permissions and ownership...");
if let Err(error) = platform::set_mode(&config_dir, 0o700) {
warn!(
error = %error,
path = %config_dir.display(),
"Failed to set config directory permissions"
);
}
if let Err(error) = platform::set_owner(&config_dir, true) {
warn!(
error = %error,
path = %config_dir.display(),
"Failed to set config directory owner"
);
}
if let Err(error) = platform::set_owner(&log_dir_path, true) {
warn!(
error = %error,
path = %log_dir_path.display(),
"Failed to set log directory owner"
);
}
tui::print_step_done(3, TOTAL_STEPS, "Permissions set");
tui::print_step(4, TOTAL_STEPS, "Collecting configuration...");
println!();
let ob_config = onboard::collect_onboard_config_tui()?;
tui::print_step_done(4, TOTAL_STEPS, "Configuration collected");
tui::print_step(5, TOTAL_STEPS, "Writing ClawShell configuration...");
let config_file = config_dir.join("config.json");
let toml_config_path = config_dir.join("clawshell.toml");
let toml_content = onboard::generate_clawshell_config(&ob_config);
std::fs::write(&toml_config_path, &toml_content)?;
let mut config_json = match &ob_config.auth_method {
crate::onboard::OnboardAuthMethod::OAuth { provider_id } => {
serde_json::json!({
"auth_method": "oauth",
"oauth_provider": provider_id,
"virtual_api_key": ob_config.virtual_api_key,
"provider": ob_config.provider,
"model": ob_config.model,
"target": ob_config.target.as_str(),
})
}
crate::onboard::OnboardAuthMethod::StaticKey => {
serde_json::json!({
"real_api_key": ob_config.real_api_key,
"virtual_api_key": ob_config.virtual_api_key,
"provider": ob_config.provider,
"model": ob_config.model,
"target": ob_config.target.as_str(),
})
}
};
if let crate::onboard::OnboardTarget::Openclaw { config_path } = &ob_config.target {
config_json["openclaw_config_path"] =
serde_json::Value::String(config_path.to_string_lossy().into_owned());
}
std::fs::write(&config_file, serde_json::to_string_pretty(&config_json)?)?;
if let Err(error) = platform::set_mode(&config_file, 0o600) {
warn!(
error = %error,
path = %config_file.display(),
"Failed to set config.json permissions"
);
}
if let Err(error) = platform::set_mode(&toml_config_path, 0o600) {
warn!(
error = %error,
path = %toml_config_path.display(),
"Failed to set clawshell.toml permissions"
);
}
if let Err(error) = platform::set_owner(&config_file, false) {
warn!(
error = %error,
path = %config_file.display(),
"Failed to set config.json owner"
);
}
if let Err(error) = platform::set_owner(&toml_config_path, false) {
warn!(
error = %error,
path = %toml_config_path.display(),
"Failed to set clawshell.toml owner"
);
}
tui::print_step_done(5, TOTAL_STEPS, "Configuration written");
match &ob_config.target {
crate::onboard::OnboardTarget::Openclaw {
config_path: openclaw_path,
} => {
apply_openclaw_onboarding_steps(&ob_config, openclaw_path, &config_file)?;
}
crate::onboard::OnboardTarget::Hermes => {
apply_hermes_onboarding_steps(&ob_config)?;
}
}
let exe = std::env::current_exe()?;
let service_path = std::path::Path::new(onboard::autostart_service_path());
let service_exists = service_path.exists();
let prompt_msg = if service_exists {
"Auto-start service already exists. Reinstall it?"
} else {
"Install auto-start service so ClawShell starts on boot?"
};
let installed_service = tui::prompt_confirm(prompt_msg, !service_exists).unwrap_or(false);
if installed_service {
match onboard::install_autostart_service(&exe, &toml_config_path) {
Ok(()) => {
tui::print_success("Auto-start service installed.");
tui::print_info("Service", onboard::autostart_service_path());
}
Err(e) => {
tui::print_error(&format!("Failed to install auto-start service: {e}"));
}
}
} else {
tui::print_info(
"Skipped",
&format!(
"You can install later by placing a service file at: {}",
onboard::autostart_service_path()
),
);
}
let already_running = platform::service_is_running().unwrap_or(false);
if already_running {
tui::print_step_done(9, TOTAL_STEPS, "ClawShell already running (skipped)");
} else {
tui::print_step(9, TOTAL_STEPS, "Starting ClawShell...");
if installed_service {
match onboard::start_autostart_service() {
Ok(()) => {
tui::print_step_done(9, TOTAL_STEPS, "ClawShell started via service manager");
}
Err(e) => {
tui::print_error(&format!("Failed to start via service manager: {e}"));
}
}
} else {
start_clawshell_direct(&toml_config_path)?;
tui::print_step_done(9, TOTAL_STEPS, "ClawShell started");
}
tui::print_info("Logs", &process::log_file_path().display().to_string());
}
tui::print_section("Setup Summary");
tui::print_info("Provider", &ob_config.provider);
tui::print_info("Model", &ob_config.model);
tui::print_info("Virtual Key", &ob_config.virtual_api_key);
tui::print_info(
"Email",
if ob_config.email.is_some() {
"configured"
} else {
"not configured"
},
);
tui::print_info(
"Server",
&format!("http://{}:{}", ob_config.server_host, ob_config.server_port),
);
tui::print_info("Config", &toml_config_path.display().to_string());
match &ob_config.target {
crate::onboard::OnboardTarget::Openclaw { config_path } => {
tui::print_info("Target", "OpenClaw");
tui::print_info("OpenClaw config", &config_path.display().to_string());
}
crate::onboard::OnboardTarget::Hermes => {
tui::print_info("Target", "Hermes Agent");
}
}
println!();
if already_running {
tui::print_success("ClawShell configuration updated.");
} else {
tui::print_success("ClawShell is installed and running.");
}
if already_running {
let restart_self = tui::prompt_confirm(
"ClawShell is already running. Run `sudo clawshell restart` to apply the new configuration?",
true,
)
.unwrap_or(false);
if restart_self {
let exe = std::env::current_exe()?;
let status = std::process::Command::new("sudo")
.args([exe.to_string_lossy().as_ref(), "restart"])
.status();
match status {
Ok(s) if s.success() => tui::print_success("ClawShell restarted."),
Ok(s) => tui::print_error(&format!(
"Failed to restart ClawShell (exit code {}).",
s.code().unwrap_or(-1)
)),
Err(e) => tui::print_error(&format!("Failed to run 'sudo clawshell restart': {e}")),
}
} else {
tui::print_info(
"Skipped",
"You can restart later with: sudo clawshell restart",
);
}
}
if matches!(
ob_config.target,
crate::onboard::OnboardTarget::Openclaw { .. }
) {
println!();
let set_model = tui::prompt_confirm(
"Run `openclaw models set clawshell` to set the default model to the ClawShell proxy?",
true,
)
.unwrap_or(false);
if set_model {
let mut openclaw_runner = openclaw_cli::RealOpenclawRunner;
match openclaw_cli::run_openclaw_command(
&mut openclaw_runner,
&["models", "set", "clawshell"],
) {
Ok(output) if output.success => {
tui::print_success("Default model set to clawshell.")
}
Ok(output) => tui::print_error(&format!(
"Failed to set default model (exit code {}).",
output.status_code.unwrap_or(-1)
)),
Err(error) => tui::print_error(&format!(
"Failed to run 'openclaw models set clawshell': {error}"
)),
}
} else {
tui::print_info(
"Skipped",
"You can set it later with: openclaw models set clawshell",
);
}
let restart_gw = tui::prompt_confirm(
"Run `openclaw gateway restart` to apply the new configuration?",
true,
)
.unwrap_or(false);
if restart_gw {
let mut openclaw_runner = openclaw_cli::RealOpenclawRunner;
match openclaw_cli::run_openclaw_command(&mut openclaw_runner, &["gateway", "restart"])
{
Ok(output) if output.success => tui::print_success("OpenClaw gateway restarted."),
Ok(output) => tui::print_error(&format!(
"Failed to restart gateway (exit code {}).",
output.status_code.unwrap_or(-1)
)),
Err(error) => tui::print_error(&format!(
"Failed to run 'openclaw gateway restart': {error}"
)),
}
} else {
tui::print_info(
"Skipped",
"You can restart later with: openclaw gateway restart",
);
}
}
Ok(())
}
fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
tui::print_banner("Uninstall");
if !nix::unistd::getuid().is_root() {
tui::print_callout(
"Administrative Privileges Required",
&[
"This process needs to remove secured files and the",
"system user safely.",
"",
"Please re-run with sudo:",
"",
" $ sudo clawshell uninstall",
],
);
std::process::exit(1);
}
ensure_default_config_migrated_if_present()?;
tui::print_warning("Administrative privileges in use — removing secured files safely.");
println!();
let exe_path = std::env::current_exe()?;
let config_dir = PathBuf::from(process::CONFIG_DIR);
let log_dir = process::log_file_path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("/var/log/clawshell"));
let service_path = std::path::Path::new(crate::onboard::autostart_service_path());
let service_exists = service_path.exists();
let clawshell_config_file = config_dir.join("config.json");
let openclaw_path = if clawshell_config_file.exists() {
try_read_openclaw_config_path(&clawshell_config_file)
} else {
None
};
let openclaw_skill_entries: Vec<(&'static str, PathBuf, onboard::ManagedSkillInspection)> =
if let Some(openclaw_path) = openclaw_path.as_ref() {
let skills_root = onboard::openclaw_config_root(openclaw_path).join("skills");
[
onboard::ADMIN_STATS_SKILL_NAME,
onboard::EMAIL_MESSAGES_SKILL_NAME,
]
.into_iter()
.map(|name| {
let skill_dir = skills_root.join(name);
let manifest = if clawshell_config_file.exists() {
onboard::read_managed_skill_manifest_entry(&clawshell_config_file, name)
} else {
None
};
let inspection = onboard::inspect_managed_skill_for_uninstall(
&skill_dir,
name,
manifest.as_ref(),
);
(name, skill_dir, inspection)
})
.collect()
} else {
Vec::new()
};
let hermes_home_dir: Option<PathBuf> =
resolve_hermes_target_user().ok().map(|(home, _, _)| home);
let hermes_skill_dirs: Vec<(&'static str, PathBuf)> = hermes_home_dir
.as_ref()
.map(|home| {
let skills_root = home.join(".hermes").join("skills");
[
onboard::ADMIN_STATS_SKILL_NAME,
onboard::EMAIL_MESSAGES_SKILL_NAME,
]
.into_iter()
.filter_map(|name| {
let dir = skills_root.join(name);
if dir.exists() {
Some((name, dir))
} else {
None
}
})
.collect()
})
.unwrap_or_default();
tui::print_warning("This will remove the following:");
tui::print_info("ClawShell", "Stop if running");
tui::print_info("Config dir", &config_dir.display().to_string());
tui::print_info("Log dir", &log_dir.display().to_string());
if service_exists {
tui::print_info("Service", &service_path.display().to_string());
}
for (_skill_name, skill_dir, inspection) in &openclaw_skill_entries {
if !skill_dir.exists() {
continue;
}
match inspection.state {
onboard::ManagedSkillUninstallState::ManagedUnchanged => {
tui::print_info(
"OpenClaw skill",
&format!("{} (managed)", skill_dir.display()),
);
}
onboard::ManagedSkillUninstallState::ManagedModified => {
tui::print_info(
"OpenClaw skill",
&format!("{} (managed, modified)", skill_dir.display()),
);
tui::print_warning(&format!(
"Managed OpenClaw skill has local modifications: {}",
inspection.detail
));
}
onboard::ManagedSkillUninstallState::Unmanaged => {
tui::print_info(
"OpenClaw skill",
&format!("{} (legacy/unverified)", skill_dir.display()),
);
tui::print_warning(&format!(
"Skill ownership is not verified: {}",
inspection.detail
));
}
onboard::ManagedSkillUninstallState::Missing => {}
}
}
for (name, dir) in &hermes_skill_dirs {
tui::print_info("Hermes skill", &format!("{} ({})", dir.display(), name));
}
tui::print_info("Binary", &format!("{} (preserved)", exe_path.display()));
tui::print_info("System user", "clawshell");
println!();
if !skip_confirm {
let confirmed =
tui::prompt_confirm("Are you sure you want to uninstall ClawShell?", false)?;
if !confirmed {
tui::print_warning("Uninstall cancelled.");
return Ok(());
}
println!();
}
if let Some(openclaw_path) = openclaw_path.as_ref()
&& openclaw_path.exists()
{
tui::print_info("Action", "Cleaning up OpenClaw configuration...");
let mut openclaw_runner = openclaw_cli::RealOpenclawRunner;
tui::print_info(
"OpenClaw workaround",
"Temporarily setting `gateway.reload.mode` to `off` during uninstall cleanup, then restoring `hybrid`.",
);
let approval_mode = if skip_confirm {
openclaw_cli::OpenclawApprovalMode::AutoApprove
} else {
openclaw_cli::OpenclawApprovalMode::PromptUser
};
match openclaw_cli::cleanup_openclaw_for_uninstall(&mut openclaw_runner, approval_mode)? {
openclaw_cli::UninstallCleanupOutcome::BlockedByDefaultModel => {
tui::print_error(
"ClawShell model is currently set as the default model in OpenClaw.",
);
tui::print_error(
"Please change the default model (for example, with `openclaw models set <model>`) before uninstalling.",
);
std::process::exit(1);
}
openclaw_cli::UninstallCleanupOutcome::Cleaned => {
tui::print_success("OpenClaw configuration cleaned up.");
}
}
}
if openclaw_path.as_ref().is_some_and(|p| p.exists()) {
let mut runner = openclaw_cli::RealOpenclawRunner;
if let Err(err) = openclaw_cli::remove_openclaw_stats_cron(&mut runner) {
warn!(error = %err, "Failed to remove stats cron job during uninstall");
}
}
let remove_skill_dir = |path: &Path| match std::fs::remove_dir_all(path) {
Ok(()) => tui::print_success(&format!("OpenClaw skill removed: {}", path.display())),
Err(error) => tui::print_warning(&format!(
"Failed to remove OpenClaw skill at {}: {error}",
path.display()
)),
};
for (_skill_name, skill_dir, inspection) in &openclaw_skill_entries {
if !skill_dir.exists() {
continue;
}
match inspection.state {
onboard::ManagedSkillUninstallState::ManagedUnchanged => {
let remove_skill = if skip_confirm {
true
} else {
let prompt =
format!("Remove managed OpenClaw skill at {}?", skill_dir.display());
tui::prompt_confirm(&prompt, true)?
};
if remove_skill {
remove_skill_dir(skill_dir);
} else {
tui::print_info(
"Skipped",
&format!("OpenClaw skill preserved at {}", skill_dir.display()),
);
}
}
onboard::ManagedSkillUninstallState::ManagedModified => {
if skip_confirm {
tui::print_warning(&format!(
"Preserving managed-but-modified OpenClaw skill at {} (--yes does not force-delete modified skills).",
skill_dir.display()
));
} else {
let prompt = format!(
"Managed OpenClaw skill at {} was modified. Delete anyway?",
skill_dir.display()
);
let remove_skill = tui::prompt_confirm(&prompt, false)?;
if remove_skill {
remove_skill_dir(skill_dir);
} else {
tui::print_info(
"Skipped",
&format!("OpenClaw skill preserved at {}", skill_dir.display()),
);
}
}
}
onboard::ManagedSkillUninstallState::Unmanaged => {
if skip_confirm {
tui::print_warning(&format!(
"Preserving unverified OpenClaw skill at {} (--yes does not force-delete unverified skills).",
skill_dir.display()
));
} else {
let prompt = format!(
"No ClawShell ownership marker/manifest match at {}. Delete anyway? (high risk)",
skill_dir.display()
);
let remove_skill = tui::prompt_confirm(&prompt, false)?;
if remove_skill {
remove_skill_dir(skill_dir);
} else {
tui::print_info(
"Skipped",
&format!("OpenClaw skill preserved at {}", skill_dir.display()),
);
}
}
}
onboard::ManagedSkillUninstallState::Missing => {}
}
}
for (name, dir) in &hermes_skill_dirs {
let remove = if skip_confirm {
true
} else {
tui::prompt_confirm(
&format!("Remove Hermes skill '{}' at {}?", name, dir.display()),
true,
)?
};
if remove {
match std::fs::remove_dir_all(dir) {
Ok(()) => tui::print_success(&format!("Hermes skill removed: {}", dir.display())),
Err(e) => tui::print_warning(&format!(
"Failed to remove Hermes skill at {}: {e}",
dir.display()
)),
}
}
}
if let Some(home) = hermes_home_dir.as_ref() {
let mut runner = hermes_cli::RealHermesRunner;
match hermes_cli::remove_hermes_stats_cron(&mut runner, home) {
Ok(n) if n > 0 => {
tui::print_success(&format!("Hermes stats cron job removed ({n} job(s))."))
}
Ok(_) => tui::print_info("Hermes cron", "no clawshell-weekly-stats job found"),
Err(err) => {
tui::print_warning(&format!("Failed to remove Hermes stats cron job: {err}"))
}
}
}
if service_exists {
tui::print_info("Action", "Stopping and removing auto-start service...");
match crate::onboard::remove_autostart_service() {
Ok(()) => tui::print_success("Auto-start service stopped and removed."),
Err(e) => tui::print_warning(&format!("Failed to remove auto-start service: {e}")),
}
}
if log_dir.exists() {
std::fs::remove_dir_all(&log_dir)?;
tui::print_success("Log directory removed.");
}
if config_dir.exists() {
std::fs::remove_dir_all(&config_dir)?;
tui::print_success("Configuration directory removed.");
}
let user_exists = std::process::Command::new("id")
.arg("clawshell")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if user_exists {
if let Err(error) = platform::delete_system_user("clawshell") {
tui::print_warning(&format!("Failed to remove system user: {error}"));
} else {
tui::print_success("System user removed.");
}
}
if exe_path.exists() {
tui::print_info("Binary", &format!("Preserved at {}", exe_path.display()));
} else {
tui::print_warning("Binary path no longer exists; skipping binary preservation check.");
}
println!();
tui::print_success("ClawShell has been uninstalled.");
Ok(())
}
fn cmd_version() {
let version = env!("CARGO_PKG_VERSION");
tui::print_banner(&format!("v{version}"));
println!();
println!("{}", tui::theme_bold().apply_to("Features:"));
let bullet = tui::theme_style().apply_to("▸");
println!(" {bullet} Virtual key to real key mapping");
println!(" {bullet} Multi-provider support (OpenAI, OpenRouter, Anthropic)");
println!(" {bullet} DLP scanning with block/redact actions");
println!(" {bullet} Response PII scanning");
println!(" {bullet} Streaming support (SSE pass-through)");
}
fn start_clawshell_direct(
toml_config_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let exe = std::env::current_exe()?;
let log_path = process::log_file_path();
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let log_stderr = log_file.try_clone()?;
let child = std::process::Command::new(exe)
.args([
"start",
"--config",
&toml_config_path.to_string_lossy(),
"--foreground",
])
.stdout(log_file)
.stderr(log_stderr)
.stdin(std::process::Stdio::null())
.spawn()?;
let pid = child.id();
tui::print_info("Process ID", &pid.to_string());
Ok(())
}