From f44d956e8b0d1ce3cde7e09cbb0c1c9b9270a457 Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Fri, 10 Apr 2026 21:14:55 -0700 Subject: [PATCH] temp/0 --- README.md | 77 ++++- src/hermes_cli.rs | 266 +++++++++++++++ src/main.rs | 624 +++++++++++++++++++++++------------ src/onboard/hermes_config.rs | 108 ++++++ src/onboard/interactive.rs | 154 ++++++++- src/onboard/mod.rs | 6 +- src/onboard/skills.rs | 26 +- src/onboard/test_support.rs | 6 +- src/onboard/types.rs | 29 +- src/openclaw_cli.rs | 4 +- 10 files changed, 1039 insertions(+), 261 deletions(-) create mode 100644 src/hermes_cli.rs create mode 100644 src/onboard/hermes_config.rs diff --git a/README.md b/README.md index 1915ea3..bd99352 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ ClawShell supports OAuth-based authentication as an alternative to static API ke ### 5. Seamless Integration -- **Drop-in Sidecar**: Deploys alongside OpenClaw without requiring re-install — the `clawshell onboard` command automatically configures OpenClaw to point at ClawShell's address and forwards all requests upstream. +- **Drop-in Sidecar**: The `clawshell onboard` wizard configures exactly one downstream LLM client per run — either OpenClaw or [Hermes Agent](https://github.com/NousResearch/hermes-agent) — to route all requests through ClawShell's proxy. See [Agent Target (pick one)](#agent-target-pick-one). - **No External Dependencies**: Uses Unix file system permissions to protect secrets. No IdP, Vault, or external key management service required. ### 6. Ultra Lightweight and Scalable @@ -140,12 +140,13 @@ cargo build --release --target x86_64-unknown-linux-musl The `onboard` command is an interactive setup wizard that must be run with `sudo`. It: -1. Creates the `clawshell` system user. -2. Creates and secures `/etc/clawshell` (mode 700) and `/var/log/clawshell`. -3. Walks you through provider selection, API key entry, and virtual key generation. -4. Writes the ClawShell config to `/etc/clawshell/clawshell.toml`. -5. Updates your OpenClaw configuration to route through ClawShell. -6. Starts the ClawShell daemon. +1. Asks which downstream agent to wire through ClawShell — **OpenClaw** or **Hermes Agent** (exactly one per run). +2. Creates the `clawshell` system user. +3. Creates and secures `/etc/clawshell` (mode 700) and `/var/log/clawshell`. +4. Walks you through provider selection, API key entry, and virtual key generation. +5. Writes the ClawShell config to `/etc/clawshell/clawshell.toml`. +6. Wires the chosen agent through ClawShell (patches `~/.openclaw/openclaw.json` for OpenClaw, or runs `hermes config set` for Hermes). +7. Starts the ClawShell daemon. ```bash sudo clawshell onboard @@ -295,6 +296,68 @@ sudo clawshell migrate-config --config /etc/clawshell/clawshell.toml See [`clawshell.example.toml`](clawshell.example.toml) for a full example. +### Agent Target (pick one) + +`sudo clawshell onboard` begins with a single, mandatory choice: + +``` +=== Agent Target === +? Which downstream agent should ClawShell wire through? + > OpenClaw + Hermes Agent +``` + +Each onboard run configures **exactly one** downstream client. There's no "also configure the other one" path — switching later means re-running `sudo clawshell onboard` and picking the other target. The prompt has no default preselection, so you pick explicitly every time. + +#### OpenClaw target + +When you pick OpenClaw, the wizard: + +- Backs up `~/.openclaw/openclaw.json` (numbered `.bak` files, mode 000). +- Shells out to `openclaw config set` to patch three paths: `env.CLAWSHELL_API_KEY`, `agents.defaults.models.clawshell/`, and `models.providers.clawshell`. +- Writes a `get-email-messages` skill bundle to `/skills/` when email integration is enabled. +- Offers to run `openclaw models set clawshell` and `openclaw gateway restart` at the end. + +This is the historical onboarding flow and is unchanged by the target-selection rework. + +#### Hermes Agent target + +When you pick [Hermes Agent](https://github.com/NousResearch/hermes-agent), the wizard: + +- Skips every OpenClaw step — `~/.openclaw/` is **not** touched. +- Writes a `get-email-messages` skill bundle to `~/.hermes/skills/` (owned by your invoking user, not root) when email integration is enabled. Hermes auto-discovers skills from that directory. +- Shells out to `hermes config set` to write: + + | Key | Value | + |---|---| + | `model.provider` | `custom` | + | `model.base_url` | `http://:/v1` | + | `model.default` | the model ID you chose during onboard | + | `model.api_key` | your ClawShell **virtual** key (never the real upstream key) | + +The `hermes` binary must be on your `PATH`. ClawShell drops root privileges before invoking it so writes land under your normal user account, not root's. + +#### Manual Hermes configuration + +If you'd rather skip the wizard's Hermes integration, run the equivalent commands from your user account (not root): + +```bash +hermes config set model.provider custom +hermes config set model.base_url http://127.0.0.1:18790/v1 +hermes config set model.default +hermes config set model.api_key +``` + +Then verify with `hermes config show`. + +#### Reverting Hermes + +Hermes has no `config unset` subcommand. To detach Hermes from ClawShell, set the provider back to auto-detect and Hermes will pick another upstream based on the credentials it still has: + +```bash +hermes config set model.provider auto +``` + ### Uninstall ```bash diff --git a/src/hermes_cli.rs b/src/hermes_cli.rs new file mode 100644 index 0000000..1ae7aad --- /dev/null +++ b/src/hermes_cli.rs @@ -0,0 +1,266 @@ +//! Minimal runner for shelling out to the Hermes Agent CLI during onboarding. +//! +//! Mirrors the shape of `openclaw_cli::OpenclawRunner`: a trait so tests can +//! inject a fake, and a `Real*` implementation that drops root privileges +//! when clawshell itself was invoked with `sudo` (Hermes lives under the +//! user's `~/.hermes/`, not root's). +//! +//! This runner is deliberately narrow — it only knows how to invoke +//! `hermes config set ` sequences built by +//! `crate::onboard::hermes_config_set_commands`. + +use crate::onboard; +use std::error::Error; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HermesCommandOutput { + pub success: bool, + pub status_code: Option, + pub stdout: String, + pub stderr: String, +} + +pub trait HermesRunner { + fn run(&mut self, args: &[String]) -> Result; +} + +#[derive(Debug, Default)] +pub struct RealHermesRunner; + +impl HermesRunner for RealHermesRunner { + fn run(&mut self, args: &[String]) -> Result { + let mut command = std::process::Command::new("hermes"); + command.args(args.iter().map(String::as_str)); + #[cfg(unix)] + { + if nix::unistd::geteuid().is_root() { + let (uid, gid) = resolve_non_root_ids()?; + command.uid(uid); + command.gid(gid); + let (username, home_dir) = resolve_non_root_user_env(uid)?; + command.env("HOME", home_dir); + command.env("USER", &username); + command.env("LOGNAME", &username); + } + } + let output = command + .output() + .map_err(|error| format!("failed to spawn `hermes`: {error}"))?; + Ok(HermesCommandOutput { + success: output.status.success(), + status_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +#[cfg(unix)] +fn resolve_non_root_ids() -> Result<(u32, u32), String> { + if let (Some(uid), Some(gid)) = (parse_env_u32("SUDO_UID"), parse_env_u32("SUDO_GID")) + && uid > 0 + && gid > 0 + { + return Ok((uid, gid)); + } + + if let Ok(user_name) = std::env::var("SUDO_USER") + && !user_name.trim().is_empty() + && user_name != "root" + { + match nix::unistd::User::from_name(&user_name) { + Ok(Some(user)) => { + let uid = user.uid.as_raw(); + let gid = user.gid.as_raw(); + if uid > 0 && gid > 0 { + return Ok((uid, gid)); + } + } + Ok(None) => {} + Err(error) => { + return Err(format!( + "failed to resolve SUDO_USER '{user_name}' for non-root hermes execution: {error}" + )); + } + } + } + + Err( + "refusing to run `hermes` as root; please run clawshell with sudo from a regular user account." + .to_string(), + ) +} + +#[cfg(unix)] +fn parse_env_u32(name: &str) -> Option { + std::env::var(name).ok()?.parse::().ok() +} + +#[cfg(unix)] +fn resolve_non_root_user_env(uid: u32) -> Result<(String, String), String> { + if let Ok(user_name) = std::env::var("SUDO_USER") + && !user_name.trim().is_empty() + && user_name != "root" + && let Ok(Some(user)) = nix::unistd::User::from_name(user_name.trim()) + && user.uid.as_raw() == uid + { + let home = user.dir.to_string_lossy().to_string(); + if !home.is_empty() { + return Ok((user.name, home)); + } + } + + match nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid)) { + Ok(Some(user)) => { + let home = user.dir.to_string_lossy().to_string(); + if home.is_empty() { + return Err(format!( + "failed to resolve home directory for uid {uid} when running `hermes`." + )); + } + Ok((user.name, home)) + } + Ok(None) => Err(format!( + "failed to resolve account metadata for uid {uid} when running `hermes`." + )), + Err(error) => Err(format!( + "failed to resolve uid {uid} for non-root hermes execution: {error}" + )), + } +} + +/// Apply the onboarding configuration to Hermes by running the sequence of +/// `hermes config set` commands built by `onboard::hermes_config_set_commands`. +/// Fails fast on the first non-zero exit. +pub fn apply_onboard_hermes_config( + runner: &mut R, + config: &onboard::OnboardConfig, +) -> Result<(), Box> { + let commands = onboard::hermes_config_set_commands(config); + for args in commands { + let human = format!("hermes {}", args.join(" ")); + let output = runner + .run(&args) + .map_err(|e| format!("failed to run `{human}`: {e}"))?; + if !output.success { + let status = output + .status_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let stderr = output.stderr.trim(); + return Err(format!("`{human}` exited with status {status}: {stderr}").into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onboard::{OnboardAuthMethod, OnboardConfig, OnboardTarget}; + use std::collections::VecDeque; + + fn test_config() -> OnboardConfig { + OnboardConfig { + provider: "openai".to_string(), + model: "gpt-5.2".to_string(), + auth_method: OnboardAuthMethod::StaticKey, + real_api_key: "sk-real-key-123".to_string(), + virtual_api_key: "{clawshell-virtual-key-openai}".to_string(), + target: OnboardTarget::Hermes, + server_host: "127.0.0.1".to_string(), + server_port: 18790, + email: None, + } + } + + #[derive(Default)] + struct FakeHermesRunner { + calls: Vec>, + responses: VecDeque>, + } + + impl HermesRunner for FakeHermesRunner { + fn run(&mut self, args: &[String]) -> Result { + self.calls.push(args.to_vec()); + self.responses.pop_front().unwrap_or_else(|| { + Ok(HermesCommandOutput { + success: true, + status_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }) + }) + } + } + + fn ok() -> HermesCommandOutput { + HermesCommandOutput { + success: true, + status_code: Some(0), + stdout: String::new(), + stderr: String::new(), + } + } + + #[test] + fn applies_all_four_config_set_calls_in_order() { + let mut runner = FakeHermesRunner::default(); + for _ in 0..4 { + runner.responses.push_back(Ok(ok())); + } + + apply_onboard_hermes_config(&mut runner, &test_config()).unwrap(); + + assert_eq!(runner.calls.len(), 4); + assert_eq!(runner.calls[0][..3], ["config", "set", "model.provider"]); + assert_eq!(runner.calls[0][3], "custom"); + assert_eq!(runner.calls[1][2], "model.base_url"); + assert_eq!(runner.calls[1][3], "http://127.0.0.1:18790/v1"); + assert_eq!(runner.calls[2][2], "model.default"); + assert_eq!(runner.calls[3][2], "model.api_key"); + assert_eq!(runner.calls[3][3], "{clawshell-virtual-key-openai}"); + } + + #[test] + fn stops_on_first_failure_and_reports_stderr() { + let mut runner = FakeHermesRunner::default(); + runner.responses.push_back(Ok(ok())); + runner.responses.push_back(Ok(HermesCommandOutput { + success: false, + status_code: Some(2), + stdout: String::new(), + stderr: "invalid key".to_string(), + })); + + let err = + apply_onboard_hermes_config(&mut runner, &test_config()).expect_err("should fail"); + let msg = err.to_string(); + assert!(msg.contains("status 2"), "msg: {msg}"); + assert!(msg.contains("invalid key"), "msg: {msg}"); + assert!( + msg.contains("hermes config set model.base_url"), + "msg: {msg}" + ); + + // First two calls ran; last two were never attempted. + assert_eq!(runner.calls.len(), 2); + } + + #[test] + fn propagates_spawn_errors() { + let mut runner = FakeHermesRunner::default(); + runner + .responses + .push_back(Err("no such binary: hermes".to_string())); + + let err = + apply_onboard_hermes_config(&mut runner, &test_config()).expect_err("should fail"); + let msg = err.to_string(); + assert!(msg.contains("failed to run"), "msg: {msg}"); + assert!(msg.contains("no such binary"), "msg: {msg}"); + } +} diff --git a/src/main.rs b/src/main.rs index 155c17c..3afec76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod cli; mod config; mod dlp; mod email; +mod hermes_cli; mod keys; mod migration; #[allow(dead_code)] @@ -129,12 +130,13 @@ struct WrittenOpenclawSkill { fn write_onboard_openclaw_skill( ob_config: &crate::onboard::OnboardConfig, + openclaw_config_path: &Path, ) -> Result, Box> { - let Some(skill) = onboard::render_openclaw_email_messages_skill(ob_config) else { + let Some(skill) = onboard::render_email_messages_skill(ob_config) else { return Ok(None); }; - let openclaw_root = onboard::openclaw_config_root(&ob_config.openclaw_config_path); + 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)?; @@ -152,10 +154,10 @@ fn write_onboard_openclaw_skill( 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, &ob_config.openclaw_config_path)? { + if !align_owner_with_openclaw_path(&skill_dir, openclaw_config_path)? { warn!( path = %skill_dir.display(), - openclaw_path = %ob_config.openclaw_config_path.display(), + openclaw_path = %openclaw_config_path.display(), "Could not determine OpenClaw file owner while writing skill files; will retry later" ); } @@ -166,6 +168,80 @@ fn write_onboard_openclaw_skill( })) } +/// Resolve the home directory and uid:gid of the user running onboarding. +/// When clawshell is invoked under sudo, prefer SUDO_USER over root so we +/// look at (and write to) the real user's `~/.hermes/`, not root's. +#[cfg(unix)] +fn resolve_hermes_target_user() -> Result<(PathBuf, u32, u32), Box> { + 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 { + "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> { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| -> Box { "HOME environment variable not set".into() })?; + Ok((home, 0, 0)) +} + +/// Write the ClawShell-managed email skill into the invoking user's +/// `~/.hermes/skills//` directory. Returns the install path +/// when the skill was written, or `None` when the skill render function +/// declined (e.g. email integration not configured). +/// +/// Unlike `write_onboard_openclaw_skill`, this doesn't upsert a +/// `managed_skills` manifest entry — Hermes discovers skills from its own +/// `~/.hermes/skills/` tree directly and doesn't share OpenClaw's +/// manifest bookkeeping. +fn write_onboard_hermes_skill( + ob_config: &crate::onboard::OnboardConfig, +) -> Result, Box> { + let Some(skill) = onboard::render_email_messages_skill(ob_config) else { + return Ok(None); + }; + + let (home_dir, uid, gid) = resolve_hermes_target_user()?; + 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)?; + } + + // Chown the skill dir (and contents) to the invoking user so Hermes — + // which runs as that user, not root — can read them. + 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(Some(skill_dir)) +} + fn resolve_openclaw_owner_spec(openclaw_path: &Path) -> Result, Box> { use std::os::unix::fs::MetadataExt; @@ -1001,10 +1077,251 @@ fn cmd_migrate_config( Ok(()) } +const ONBOARD_TOTAL_STEPS: usize = 9; + +/// Steps 6, 7, 8 of the onboarding wizard when the user picked OpenClaw as +/// the downstream agent target. Encapsulates the OpenClaw skill write, +/// backup, credential cleanup preview, and `openclaw config set` apply. +fn apply_openclaw_onboarding_steps( + ob_config: &crate::onboard::OnboardConfig, + openclaw_path: &Path, + config_file: &Path, +) -> Result<(), Box> { + const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS; + + // Step 6: Write OpenClaw skill files + tui::print_step(6, TOTAL_STEPS, "OpenClaw skill setup..."); + println!(); + let openclaw_skill = if onboard::should_setup_email_skill(ob_config) { + let openclaw_skill_edit_approved = + tui::prompt_confirm("Write OpenClaw skill files for email integration", true)?; + let openclaw_skill = if openclaw_skill_edit_approved { + write_onboard_openclaw_skill(ob_config, openclaw_path)? + } else { + None + }; + if openclaw_skill_edit_approved { + if let Some(skill) = openclaw_skill.as_ref() { + onboard::upsert_managed_skill_manifest_entry(config_file, &skill.manifest_entry)?; + tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written"); + tui::print_info("OpenClaw skill", &skill.path.display().to_string()); + } else { + tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills skipped"); + } + } else { + tui::print_step_done( + 6, + TOTAL_STEPS, + "OpenClaw skills skipped (approval not granted)", + ); + } + openclaw_skill + } else { + tui::print_step_done( + 6, + TOTAL_STEPS, + "OpenClaw skills skipped (email integration not configured)", + ); + None + }; + + // Step 7: Backup OpenClaw configuration file if present. + 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() + )); + } + + // Step 8: Remove legacy provider credentials and update OpenClaw config. + 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)?; + if let Some(skill) = openclaw_skill.as_ref() { + align_owner_with_openclaw_path(&skill.path, openclaw_path)?; + } + tui::print_step_done(8, TOTAL_STEPS, "OpenClaw config updated"); + Ok(()) +} + +/// Steps 6, 7, 8 of the onboarding wizard when the user picked Hermes as +/// the downstream agent target. Installs the email skill (if email is +/// enabled) into the user's `~/.hermes/skills/` tree, then runs +/// `hermes config set` to point Hermes at ClawShell. +/// +/// Steps 7 and 8a ("backup OpenClaw" / "preview OpenClaw edits") are +/// rendered as neutral info lines so the overall 9-step numbering stays +/// aligned with the OpenClaw flow. +fn apply_hermes_onboarding_steps( + ob_config: &crate::onboard::OnboardConfig, +) -> Result<(), Box> { + const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS; + + // Step 6: Write Hermes skill files into ~/.hermes/skills/ if email enabled. + tui::print_step(6, TOTAL_STEPS, "Hermes skill setup..."); + if onboard::should_setup_email_skill(ob_config) { + match write_onboard_hermes_skill(ob_config) { + Ok(Some(path)) => { + tui::print_step_done(6, TOTAL_STEPS, "Hermes skill written"); + tui::print_info("Hermes skill", &path.display().to_string()); + } + Ok(None) => { + tui::print_step_done(6, TOTAL_STEPS, "Hermes skill skipped"); + } + Err(error) => { + tui::print_error(&format!("Failed to write Hermes skill: {error}")); + tui::print_step_done( + 6, + TOTAL_STEPS, + "Hermes skill skipped (write failed — see error above)", + ); + } + } + } else { + tui::print_step_done( + 6, + TOTAL_STEPS, + "Hermes skills skipped (email integration not configured)", + ); + } + + // Step 7: Not applicable for Hermes (no backup needed — hermes config set + // is reversible and we never touch openclaw.json). + tui::print_step(7, TOTAL_STEPS, "Backup step..."); + tui::print_step_done( + 7, + TOTAL_STEPS, + "Backup not applicable for Hermes target (skipped)", + ); + + // Step 8: Apply Hermes config updates via `hermes config set`. + 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> { use crate::onboard; - const TOTAL_STEPS: usize = 9; + const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS; tui::print_banner("Onboarding"); @@ -1105,7 +1422,7 @@ fn cmd_onboard() -> Result<(), Box> { let toml_content = onboard::generate_clawshell_config(&ob_config); std::fs::write(&toml_config_path, &toml_content)?; - let config_json = match &ob_config.auth_method { + let mut config_json = match &ob_config.auth_method { crate::onboard::OnboardAuthMethod::OAuth { provider_id } => { serde_json::json!({ "auth_method": "oauth", @@ -1113,7 +1430,7 @@ fn cmd_onboard() -> Result<(), Box> { "virtual_api_key": ob_config.virtual_api_key, "provider": ob_config.provider, "model": ob_config.model, - "openclaw_config_path": ob_config.openclaw_config_path.to_string_lossy(), + "target": ob_config.target.as_str(), }) } crate::onboard::OnboardAuthMethod::StaticKey => { @@ -1122,10 +1439,14 @@ fn cmd_onboard() -> Result<(), Box> { "virtual_api_key": ob_config.virtual_api_key, "provider": ob_config.provider, "model": ob_config.model, - "openclaw_config_path": ob_config.openclaw_config_path.to_string_lossy(), + "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)?)?; // Set permissions on config files @@ -1159,165 +1480,19 @@ fn cmd_onboard() -> Result<(), Box> { } tui::print_step_done(5, TOTAL_STEPS, "Configuration written"); - // Step 6: Write OpenClaw skill files - tui::print_step(6, TOTAL_STEPS, "OpenClaw skill setup..."); - println!(); - let openclaw_skill = if onboard::should_setup_openclaw_email_skill(&ob_config) { - let openclaw_skill_edit_approved = - tui::prompt_confirm("Write OpenClaw skill files for email integration", true)?; - let openclaw_skill = if openclaw_skill_edit_approved { - write_onboard_openclaw_skill(&ob_config)? - } else { - None - }; - if openclaw_skill_edit_approved { - if let Some(skill) = openclaw_skill.as_ref() { - onboard::upsert_managed_skill_manifest_entry(&config_file, &skill.manifest_entry)?; - tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written"); - tui::print_info("OpenClaw skill", &skill.path.display().to_string()); - } else { - tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills skipped"); - } - } else { - tui::print_step_done( - 6, - TOTAL_STEPS, - "OpenClaw skills skipped (approval not granted)", - ); + // Steps 6, 7, 8 — target-specific. The downstream agent chosen during + // step 4 (OpenClaw or Hermes) drives which set of actions runs here. + match &ob_config.target { + crate::onboard::OnboardTarget::Openclaw { + config_path: openclaw_path, + } => { + apply_openclaw_onboarding_steps(&ob_config, openclaw_path, &config_file)?; } - openclaw_skill - } else { - tui::print_step_done( - 6, - TOTAL_STEPS, - "OpenClaw skills skipped (email integration not configured)", - ); - None - }; - - // OpenClaw config path was already asked in step 4 - let openclaw_path = &ob_config.openclaw_config_path; - - // Step 7: Backup OpenClaw configuration file if present. - 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() - )); - } - - // Step 8: Remove legacy provider credentials and update OpenClaw config. - 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) + crate::onboard::OnboardTarget::Hermes => { + apply_hermes_onboarding_steps(&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..."); - // Step status renders inline; break once before interactive OpenClaw approvals. - 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)?; - if let Some(skill) = openclaw_skill.as_ref() { - align_owner_with_openclaw_path(&skill.path, openclaw_path)?; - } - tui::print_step_done(8, TOTAL_STEPS, "OpenClaw config updated"); - // Auto-start service setup (ask before step 9 so we can start via service manager) let exe = std::env::current_exe()?; let service_path = std::path::Path::new(onboard::autostart_service_path()); @@ -1391,7 +1566,15 @@ fn cmd_onboard() -> Result<(), Box> { &format!("http://{}:{}", ob_config.server_host, ob_config.server_port), ); tui::print_info("Config", &toml_config_path.display().to_string()); - tui::print_info("OpenClaw", &openclaw_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."); @@ -1426,60 +1609,69 @@ fn cmd_onboard() -> Result<(), Box> { } } - // Ask whether to set default model to clawshell - 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); + // OpenClaw-only post-step prompts: set default model + restart gateway. + // These only apply when the onboard target was OpenClaw — skip entirely + // for the Hermes target. + 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}" - )), + 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", + ); } - } else { - tui::print_info( - "Skipped", - "You can set it later with: openclaw models set clawshell", - ); - } - // Ask whether to restart the gateway - let restart_gw = tui::prompt_confirm( - "Run `openclaw gateway restart` to apply the new configuration?", - true, - ) - .unwrap_or(false); + 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}" - )), + 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", + ); } - } else { - tui::print_info( - "Skipped", - "You can restart later with: openclaw gateway restart", - ); } Ok(()) @@ -1526,12 +1718,12 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { let openclaw_skill_dir = openclaw_path.as_ref().map(|path| { onboard::openclaw_config_root(path) .join("skills") - .join(onboard::OPENCLAW_EMAIL_MESSAGES_SKILL_NAME) + .join(onboard::EMAIL_MESSAGES_SKILL_NAME) }); let openclaw_skill_manifest = if clawshell_config_file.exists() { onboard::read_managed_skill_manifest_entry( &clawshell_config_file, - onboard::OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, + onboard::EMAIL_MESSAGES_SKILL_NAME, ) } else { None @@ -1539,7 +1731,7 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { let openclaw_skill_inspection = if let Some(skill_dir) = openclaw_skill_dir.as_ref() { onboard::inspect_managed_skill_for_uninstall( skill_dir, - onboard::OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, + onboard::EMAIL_MESSAGES_SKILL_NAME, openclaw_skill_manifest.as_ref(), ) } else { diff --git a/src/onboard/hermes_config.rs b/src/onboard/hermes_config.rs new file mode 100644 index 0000000..e45ebeb --- /dev/null +++ b/src/onboard/hermes_config.rs @@ -0,0 +1,108 @@ +//! Pure command builders for `hermes config set ...` invocations used when +//! onboarding wires Hermes Agent through ClawShell. +//! +//! Hermes Agent stores config in `~/.hermes/config.yaml` with API keys in +//! `~/.hermes/.env`; its `hermes config set ` CLI auto-routes +//! secrets to `.env` based on a hardcoded key-name list. `model.api_key` +//! isn't in that list, so it lands in `config.yaml` under `model:` — which +//! is fine here because a ClawShell virtual key is only meaningful against +//! `127.0.0.1:18790` and the real upstream credentials never leave +//! `/etc/clawshell/clawshell.toml`. +//! +//! Hermes has no `config unset` subcommand, so revert is done by setting +//! `model.provider` back to `auto` (its documented auto-detect default). + +use super::types::OnboardConfig; + +/// Build the list of `hermes config set ` argv lists to run in +/// order to point Hermes at ClawShell. +/// +/// The virtual API key (not the real upstream key) must be what ends up in +/// Hermes config — ClawShell will translate it at the proxy boundary. +pub fn hermes_config_set_commands(config: &OnboardConfig) -> Vec> { + let base_url = format!("http://{}:{}/v1", config.server_host, config.server_port); + vec![ + hermes_set(&["model.provider", "custom"]), + hermes_set(&["model.base_url", &base_url]), + hermes_set(&["model.default", &config.model]), + hermes_set(&["model.api_key", &config.virtual_api_key]), + ] +} + +fn hermes_set(key_value: &[&str; 2]) -> Vec { + vec![ + "config".to_string(), + "set".to_string(), + key_value[0].to_string(), + key_value[1].to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onboard::test_support::test_config; + + #[test] + fn builds_four_commands_in_documented_order() { + let config = test_config(); + let commands = hermes_config_set_commands(&config); + + assert_eq!(commands.len(), 4); + assert_eq!( + commands[0], + vec!["config", "set", "model.provider", "custom"] + ); + assert_eq!( + commands[1], + vec![ + "config", + "set", + "model.base_url", + "http://127.0.0.1:18790/v1" + ] + ); + assert_eq!( + commands[2], + vec!["config", "set", "model.default", "gpt-5.2"] + ); + assert_eq!( + commands[3], + vec![ + "config", + "set", + "model.api_key", + "{clawshell-virtual-key-openai}", + ] + ); + } + + #[test] + fn uses_virtual_key_not_real_key() { + let config = test_config(); + let commands = hermes_config_set_commands(&config); + let api_key_cmd = commands + .iter() + .find(|cmd| cmd.get(2).map(|k| k == "model.api_key").unwrap_or(false)) + .expect("model.api_key command present"); + assert_eq!(api_key_cmd[3], config.virtual_api_key); + assert_ne!(api_key_cmd[3], config.real_api_key); + } + + #[test] + fn base_url_reflects_custom_host_port() { + let mut config = test_config(); + config.server_host = "10.0.0.5".to_string(); + config.server_port = 9000; + let commands = hermes_config_set_commands(&config); + assert_eq!(commands[1][3], "http://10.0.0.5:9000/v1"); + } + + #[test] + fn model_name_propagates_from_onboard_config() { + let mut config = test_config(); + config.model = "claude-sonnet-4-5-20250929".to_string(); + let commands = hermes_config_set_commands(&config); + assert_eq!(commands[2][3], "claude-sonnet-4-5-20250929"); + } +} diff --git a/src/onboard/interactive.rs b/src/onboard/interactive.rs index 9826856..83b5bbd 100644 --- a/src/onboard/interactive.rs +++ b/src/onboard/interactive.rs @@ -1,6 +1,8 @@ use super::config_render::default_openclaw_config_path; use super::credentials::detect_openclaw_api_key_for_provider; -use super::types::{OnboardAuthMethod, OnboardConfig, OnboardEmailConfig, OnboardEmailMode}; +use super::types::{ + OnboardAuthMethod, OnboardConfig, OnboardEmailConfig, OnboardEmailMode, OnboardTarget, +}; use crate::email::{EmailAccountCredentials, ImapEmailService}; use crate::tui; @@ -77,6 +79,10 @@ fn load_existing_config_from_vfs(config_dir: &VfsPath) -> Option .get("oauth_provider") .and_then(|v| v.as_str()) .map(String::from); + existing.target = json + .get("target") + .and_then(|v| v.as_str()) + .map(String::from); } // Read clawshell.toml for server host/port and optional Email settings @@ -277,6 +283,10 @@ struct ExistingConfig { email_app_password: Option, email_imap_host: Option, email_imap_port: Option, + /// Target agent string from config.json ("openclaw" or "hermes"). None + /// when no prior onboarding run has been recorded, or when the config + /// predates the exclusive-target rework. + target: Option, } impl ExistingConfig { @@ -298,6 +308,7 @@ impl ExistingConfig { || self.email_app_password.is_some() || self.email_imap_host.is_some() || self.email_imap_port.is_some() + || self.target.is_some() } } @@ -422,6 +433,16 @@ pub fn collect_onboard_config_tui() -> Result Result Result Result OnboardTarget::Openclaw { + config_path: PathBuf::from( + openclaw_config_path + .expect("openclaw_config_path is populated when target is OpenClaw"), + ), + }, + TARGET_HERMES => OnboardTarget::Hermes, + other => unreachable!("unexpected target choice: {other}"), + }; + Ok(OnboardConfig { provider, model, auth_method, real_api_key, virtual_api_key, - openclaw_config_path: PathBuf::from(openclaw_config_path), + target, server_host, server_port, email, @@ -973,6 +1006,91 @@ mod tests { assert_eq!(existing.server_port.as_deref(), Some("9999")); } + #[test] + fn test_load_existing_config_reads_openclaw_target() { + let root = VfsPath::new(MemoryFS::new()); + let config_json = serde_json::json!({ + "target": "openclaw", + "provider": "openai", + "model": "gpt-5.2", + "virtual_api_key": "vk", + "real_api_key": "sk", + "openclaw_config_path": "/home/user/.openclaw/openclaw.json", + }); + vfs_write( + &root, + "etc/clawshell/config.json", + &serde_json::to_string_pretty(&config_json).unwrap(), + ); + vfs_write( + &root, + "etc/clawshell/clawshell.toml", + "[server]\nhost = \"127.0.0.1\"\nport = 18790\n", + ); + + let existing = load_existing_config_from_vfs(&root.join("etc/clawshell").unwrap()).unwrap(); + assert_eq!(existing.target.as_deref(), Some("openclaw")); + } + + #[test] + fn test_load_existing_config_reads_hermes_target() { + let root = VfsPath::new(MemoryFS::new()); + let config_json = serde_json::json!({ + "target": "hermes", + "provider": "openai", + "model": "gpt-5.2", + "virtual_api_key": "vk", + "real_api_key": "sk", + }); + vfs_write( + &root, + "etc/clawshell/config.json", + &serde_json::to_string_pretty(&config_json).unwrap(), + ); + vfs_write( + &root, + "etc/clawshell/clawshell.toml", + "[server]\nhost = \"127.0.0.1\"\nport = 18790\n", + ); + + let existing = load_existing_config_from_vfs(&root.join("etc/clawshell").unwrap()).unwrap(); + assert_eq!(existing.target.as_deref(), Some("hermes")); + // Hermes target has no openclaw_config_path persisted. + assert!(existing.openclaw_config_path.is_none()); + } + + #[test] + fn test_load_existing_config_missing_target_is_none_backward_compat() { + let root = VfsPath::new(MemoryFS::new()); + // Pre-rework config.json — no `target` key. Loader should return + // None and let the wizard re-prompt on next onboard run. + let config_json = serde_json::json!({ + "provider": "openai", + "model": "gpt-5.2", + "virtual_api_key": "vk", + "real_api_key": "sk", + "openclaw_config_path": "/home/user/.openclaw/openclaw.json", + }); + vfs_write( + &root, + "etc/clawshell/config.json", + &serde_json::to_string_pretty(&config_json).unwrap(), + ); + vfs_write( + &root, + "etc/clawshell/clawshell.toml", + "[server]\nhost = \"127.0.0.1\"\nport = 18790\n", + ); + + let existing = load_existing_config_from_vfs(&root.join("etc/clawshell").unwrap()).unwrap(); + assert!(existing.target.is_none()); + // Older openclaw_config_path key is still read for its own prompt default. + assert_eq!( + existing.openclaw_config_path.as_deref(), + Some("/home/user/.openclaw/openclaw.json") + ); + } + #[test] fn test_load_existing_config_reads_email_defaults() { let root = VfsPath::new(MemoryFS::new()); diff --git a/src/onboard/mod.rs b/src/onboard/mod.rs index 3a9edd4..1bf4469 100644 --- a/src/onboard/mod.rs +++ b/src/onboard/mod.rs @@ -2,6 +2,7 @@ mod autostart; mod backup; mod config_render; mod credentials; +mod hermes_config; mod interactive; mod managed_skills; mod openclaw_json; @@ -20,6 +21,7 @@ pub use config_render::generate_clawshell_config; pub use credentials::{ cleanup_openclaw_provider_credentials, preview_openclaw_provider_credential_cleanup, }; +pub use hermes_config::hermes_config_set_commands; pub use interactive::collect_onboard_config_tui; pub use managed_skills::{ ManagedSkillInspection, ManagedSkillManifestEntry, ManagedSkillUninstallState, @@ -28,8 +30,8 @@ pub use managed_skills::{ upsert_managed_skill_manifest_entry, write_managed_skill_metadata, }; pub use openclaw_json::{patch_openclaw_config_for_clawshell, remove_clawshell_openclaw_entries}; -pub use skills::{render_openclaw_email_messages_skill, should_setup_openclaw_email_skill}; +pub use skills::{render_email_messages_skill, should_setup_email_skill}; pub use types::{ - OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, OnboardAuthMethod, OnboardConfig, + EMAIL_MESSAGES_SKILL_NAME, OnboardAuthMethod, OnboardConfig, OnboardTarget, OpenclawFileRemovalPreview, }; diff --git a/src/onboard/skills.rs b/src/onboard/skills.rs index 0ef3fd7..d873f76 100644 --- a/src/onboard/skills.rs +++ b/src/onboard/skills.rs @@ -1,5 +1,5 @@ use super::types::{ - OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, OnboardConfig, OnboardSkillBundle, OnboardSkillFile, + EMAIL_MESSAGES_SKILL_NAME, OnboardConfig, OnboardSkillBundle, OnboardSkillFile, }; fn format_clawshell_base_url(host: &str, port: u16) -> String { @@ -11,11 +11,11 @@ fn format_clawshell_base_url(host: &str, port: u16) -> String { } } -pub fn should_setup_openclaw_email_skill(config: &OnboardConfig) -> bool { +pub fn should_setup_email_skill(config: &OnboardConfig) -> bool { config.email.is_some() } -pub fn render_openclaw_email_messages_skill(config: &OnboardConfig) -> Option { +pub fn render_email_messages_skill(config: &OnboardConfig) -> Option { config.email.as_ref()?; let base_url = format_clawshell_base_url(&config.server_host, config.server_port); @@ -144,7 +144,7 @@ Expected top-level fields: ); Some(OnboardSkillBundle { - name: OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, + name: EMAIL_MESSAGES_SKILL_NAME, files: vec![ OnboardSkillFile { relative_path: "SKILL.md", @@ -165,13 +165,13 @@ mod tests { use crate::onboard::types::{OnboardEmailConfig, OnboardEmailMode}; #[test] - fn test_should_setup_openclaw_email_skill_returns_false_without_email() { + fn test_should_setup_email_skill_returns_false_without_email() { let config = test_config(); - assert!(!should_setup_openclaw_email_skill(&config)); + assert!(!should_setup_email_skill(&config)); } #[test] - fn test_should_setup_openclaw_email_skill_returns_true_with_email() { + fn test_should_setup_email_skill_returns_true_with_email() { let mut config = test_config(); config.email = Some(OnboardEmailConfig { mode: OnboardEmailMode::Allowlist, @@ -183,17 +183,17 @@ mod tests { imap_port: 993, }); - assert!(should_setup_openclaw_email_skill(&config)); + assert!(should_setup_email_skill(&config)); } #[test] - fn test_render_openclaw_email_messages_skill_returns_none_without_email() { + fn test_render_email_messages_skill_returns_none_without_email() { let config = test_config(); - assert!(render_openclaw_email_messages_skill(&config).is_none()); + assert!(render_email_messages_skill(&config).is_none()); } #[test] - fn test_render_openclaw_email_messages_skill_renders_concrete_values() { + fn test_render_email_messages_skill_renders_concrete_values() { let mut config = test_config(); config.email = Some(OnboardEmailConfig { mode: OnboardEmailMode::Allowlist, @@ -205,8 +205,8 @@ mod tests { imap_port: 993, }); - let skill = render_openclaw_email_messages_skill(&config).unwrap(); - assert_eq!(skill.name, OPENCLAW_EMAIL_MESSAGES_SKILL_NAME); + let skill = render_email_messages_skill(&config).unwrap(); + assert_eq!(skill.name, EMAIL_MESSAGES_SKILL_NAME); assert_eq!(skill.files.len(), 2); let skill_md = skill diff --git a/src/onboard/test_support.rs b/src/onboard/test_support.rs index 7f8ecfd..7d64c6c 100644 --- a/src/onboard/test_support.rs +++ b/src/onboard/test_support.rs @@ -1,4 +1,4 @@ -use super::OnboardConfig; +use super::{OnboardConfig, OnboardTarget}; use std::path::PathBuf; use vfs::VfsPath; @@ -9,7 +9,9 @@ pub(super) fn test_config() -> OnboardConfig { auth_method: super::types::OnboardAuthMethod::StaticKey, real_api_key: "sk-real-key-123".to_string(), virtual_api_key: "{clawshell-virtual-key-openai}".to_string(), - openclaw_config_path: PathBuf::from("/tmp/test-openclaw.json"), + target: OnboardTarget::Openclaw { + config_path: PathBuf::from("/tmp/test-openclaw.json"), + }, server_host: "127.0.0.1".to_string(), server_port: 18790, email: None, diff --git a/src/onboard/types.rs b/src/onboard/types.rs index 6c3933d..c073ad6 100644 --- a/src/onboard/types.rs +++ b/src/onboard/types.rs @@ -54,6 +54,30 @@ pub enum OnboardAuthMethod { }, } +/// Which downstream LLM client ClawShell's onboarding wizard should wire +/// through the proxy. Exactly one target per onboard run — the previous +/// "configure Hermes on top of OpenClaw" additive mode has been removed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OnboardTarget { + /// Route OpenClaw through ClawShell by patching `~/.openclaw/openclaw.json`. + Openclaw { + /// Path to the OpenClaw configuration file (usually `~/.openclaw/openclaw.json`). + config_path: PathBuf, + }, + /// Route Hermes Agent through ClawShell by running `hermes config set`. + Hermes, +} + +impl OnboardTarget { + /// Stable short identifier used as a discriminator in `config.json`. + pub fn as_str(&self) -> &'static str { + match self { + OnboardTarget::Openclaw { .. } => "openclaw", + OnboardTarget::Hermes => "hermes", + } + } +} + /// Collected onboarding configuration from user prompts. #[derive(Debug, Clone)] pub struct OnboardConfig { @@ -63,7 +87,8 @@ pub struct OnboardConfig { /// Set for `StaticKey`; empty for `OAuth`. pub real_api_key: String, pub virtual_api_key: String, - pub openclaw_config_path: PathBuf, + /// Which downstream LLM client to wire through ClawShell. + pub target: OnboardTarget, pub server_host: String, pub server_port: u16, pub email: Option, @@ -93,7 +118,7 @@ pub struct OnboardSkillBundle { pub files: Vec, } -pub const OPENCLAW_EMAIL_MESSAGES_SKILL_NAME: &str = "get-email-messages"; +pub const EMAIL_MESSAGES_SKILL_NAME: &str = "get-email-messages"; /// Sender filtering mode for the Email endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/openclaw_cli.rs b/src/openclaw_cli.rs index 349157f..0196b4c 100644 --- a/src/openclaw_cli.rs +++ b/src/openclaw_cli.rs @@ -695,7 +695,9 @@ mod tests { auth_method: onboard::OnboardAuthMethod::StaticKey, real_api_key: "real_key".to_string(), virtual_api_key: "virtual_key".to_string(), - openclaw_config_path: PathBuf::from("/home/user/.openclaw/openclaw.json"), + target: onboard::OnboardTarget::Openclaw { + config_path: PathBuf::from("/home/user/.openclaw/openclaw.json"), + }, server_host: "127.0.0.1".to_string(), server_port: 18790, email: None,