From 265f77fdc2a69ca7722a1c7bd44decf268dd0dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=8F=9C=E4=B8=80=E7=A2=9F?= <1904791939@qq.com> Date: Sat, 30 May 2026 11:07:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20per-project=20workspace=20isolati?= =?UTF-8?q?on=20(v1.1=E2=80=93v1.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register projects with isolated Hermes profiles, Claude/Codex/OpenClaw state, MCP/skills snapshots, shell hooks, direnv, auto-fix, and desktop switching. Co-authored-by: Cursor --- cli/README.md | 2 +- cli/src/commands/mod.rs | 1 + cli/src/commands/workspace.rs | 298 ++++++++ cli/src/main.rs | 144 ++++ crates/agent-doctor-core/src/lib.rs | 12 + .../src/workspace/backends.rs | 480 ++++++++++++ .../agent-doctor-core/src/workspace/backup.rs | 113 +++ crates/agent-doctor-core/src/workspace/fix.rs | 193 +++++ .../src/workspace/gateway.rs | 149 ++++ crates/agent-doctor-core/src/workspace/mod.rs | 690 ++++++++++++++++++ .../agent-doctor-core/src/workspace/path.rs | 116 +++ .../agent-doctor-core/src/workspace/shell.rs | 299 ++++++++ .../src/workspace/snapshot.rs | 165 +++++ desktop/README.md | 3 +- desktop/index.html | 31 + desktop/src-tauri/src/lib.rs | 51 +- desktop/src/i18n.ts | 22 + desktop/src/main.ts | 177 ++++- docs/ROADMAP.md | 15 +- docs/workspace.md | 93 +++ 20 files changed, 3041 insertions(+), 13 deletions(-) create mode 100644 cli/src/commands/workspace.rs create mode 100644 crates/agent-doctor-core/src/workspace/backends.rs create mode 100644 crates/agent-doctor-core/src/workspace/backup.rs create mode 100644 crates/agent-doctor-core/src/workspace/fix.rs create mode 100644 crates/agent-doctor-core/src/workspace/gateway.rs create mode 100644 crates/agent-doctor-core/src/workspace/mod.rs create mode 100644 crates/agent-doctor-core/src/workspace/path.rs create mode 100644 crates/agent-doctor-core/src/workspace/shell.rs create mode 100644 crates/agent-doctor-core/src/workspace/snapshot.rs create mode 100644 docs/workspace.md diff --git a/cli/README.md b/cli/README.md index cc31226..e533336 100644 --- a/cli/README.md +++ b/cli/README.md @@ -21,7 +21,7 @@ cargo run -p agent-doctor -- doctor --json | `install ` | All registered runtimes: rule install when available; else / on failure → AI install | | `profile list/init/use` | Implemented (Hermes model switching) | | `config show` | Implemented (Hermes) | -| `repair ` | Probes + preview; Hermes/OpenClaw auto-fix with `--apply`; `--explain` for AI diagnosis | +| `workspace init/list/use/status/doctor/fix/direnv` | Per-project isolation for Hermes, Claude Code, Codex, OpenClaw | | `setup --url --key` | Company gateway profile → profile.env + Hermes/OpenClaw/Claude/Codex configs | | `sync` | Stub | | `policy pull` | Stub | diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index ea04df0..82b9547 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod profile; pub mod repair; pub mod setup; pub mod sync; +pub mod workspace; pub use install::print_explain_report; diff --git a/cli/src/commands/workspace.rs b/cli/src/commands/workspace.rs new file mode 100644 index 0000000..b285deb --- /dev/null +++ b/cli/src/commands/workspace.rs @@ -0,0 +1,298 @@ +use agent_doctor_core::{ + enter_workspace, init_workspace, install_bash_hook, install_fish_hook, install_zsh_hook, + load_workspaces, match_workspace_for_path, remove_workspace, render_direnv_envrc, + render_shell_env_for_name, use_workspace_with_options, workspace_doctor, workspace_fix, + workspace_status, write_direnv_envrc, UseWorkspaceOptions, WorkspaceCheckStatus, + WorkspacesDocument, +}; +use anyhow::{bail, Result}; + +pub fn init(path: Option, name: Option, git_root: bool) -> Result<()> { + let report = init_workspace(path, name, git_root)?; + println!("Created workspace: {}", report.name); + println!(" project: {}", report.path.display()); + println!(" config: {}", report.config_path.display()); + println!(); + for binding in &report.bindings { + println!( + "✓ {} — {} ({})", + binding.runtime_id, binding.action, binding.isolation_tier + ); + println!(" {}", binding.detail); + } + println!(); + println!("Next:"); + println!(" agent-doctor workspace enter"); + Ok(()) +} + +pub fn list(json: bool) -> Result<()> { + let doc = load_workspaces()?; + if json { + println!("{}", serde_json::to_string_pretty(&doc)?); + return Ok(()); + } + print_document(&doc); + Ok(()) +} + +pub fn activate(name: &str, backup: bool, restart_gateways: bool) -> Result<()> { + let report = use_workspace_with_options( + name, + &UseWorkspaceOptions { + backup, + restart_gateways, + }, + )?; + print_use_report(&report); + Ok(()) +} + +pub fn enter(path: Option, git_root: bool) -> Result<()> { + let report = enter_workspace(path, git_root)?; + if report.switched { + println!("Switched to workspace: {}", report.name); + } else { + println!("Workspace already active: {}", report.name); + } + if let Some(backup_id) = &report.use_report.backup_id { + println!(" backup: {backup_id}"); + } + println!(" project: {}", report.path.display()); + println!(); + println!("Run in this shell:"); + println!( + " eval \"$(agent-doctor workspace env --shell zsh --name {})\"", + report.name + ); + println!(" cd {}", report.path.display()); + Ok(()) +} + +pub fn r#match(path: Option, git_root: bool) -> Result<()> { + match match_workspace_for_path(path, git_root)? { + Some(name) => { + println!("{name}"); + Ok(()) + } + None => bail!("no workspace registered for this path"), + } +} + +pub fn env(shell: &str, name: Option<&str>) -> Result<()> { + let doc = load_workspaces()?; + let workspace_name = match name { + Some(name) => name.to_string(), + None => doc.active.clone().ok_or_else(|| { + anyhow::anyhow!("no active workspace — pass --name or run workspace use") + })?, + }; + print!("{}", render_shell_env_for_name(&workspace_name, shell)?); + Ok(()) +} + +pub fn hook_install(shell: &str) -> Result<()> { + match shell { + "zsh" => { + let path = install_zsh_hook()?; + print_hook_instructions("zsh", &path); + } + "bash" => { + let path = install_bash_hook()?; + print_hook_instructions("bash", &path); + } + "fish" => { + let path = install_fish_hook()?; + print_hook_instructions("fish", &path); + } + "all" => { + let zsh = install_zsh_hook()?; + let bash = install_bash_hook()?; + let fish = install_fish_hook()?; + print_hook_instructions("zsh", &zsh); + println!(); + print_hook_instructions("bash", &bash); + println!(); + print_hook_instructions("fish", &fish); + } + other => bail!("unsupported shell '{other}' — use zsh, bash, fish, or all"), + } + Ok(()) +} + +fn print_hook_instructions(shell: &str, path: &std::path::Path) { + println!("Installed {shell} hook: {}", path.display()); + println!(); + let rc = match shell { + "zsh" => "~/.zshrc", + "fish" => "~/.config/fish/config.fish", + _ => "~/.bashrc", + }; + println!("Add to {rc}:"); + println!(" source \"{}\"", path.display()); +} + +pub fn status(path: Option, json: bool) -> Result<()> { + let report = workspace_status(path)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + println!( + "Active workspace: {}", + report.active.as_deref().unwrap_or("(none)") + ); + println!("Current cwd: {}", report.cwd.display()); + if let Some(name) = &report.matched_workspace { + println!("Cwd matches: {name}"); + } else { + println!("Cwd matches: (no registered workspace)"); + } + println!(); + for runtime in &report.runtimes { + let marker = if runtime.aligned { "✓" } else { "!" }; + println!( + "{marker} {} [{}] expected={} actual={}", + runtime.runtime_id, runtime.isolation_tier, runtime.expected, runtime.actual + ); + if !runtime.aligned { + println!(" hint: {}", runtime.hint); + } + } + Ok(()) +} + +pub fn doctor(json: bool) -> Result<()> { + let report = workspace_doctor()?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + println!( + "Workspace doctor (active: {})\n", + report.active.as_deref().unwrap_or("(none)") + ); + for check in &report.checks { + let marker = match check.status { + WorkspaceCheckStatus::Pass => "✓", + WorkspaceCheckStatus::Warn => "!", + WorkspaceCheckStatus::Fail => "✗", + }; + println!("{marker} {} — {}", check.id, check.title); + println!(" {}", check.detail); + } + Ok(()) +} + +pub fn fix(dry_run: bool, restart_gateways: bool, json: bool) -> Result<()> { + let report = workspace_fix(dry_run, restart_gateways)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + let mode = if dry_run { "dry-run" } else { "apply" }; + println!( + "Workspace fix ({mode}, active: {})\n", + report.active.as_deref().unwrap_or("(none)") + ); + for action in &report.actions { + let marker = if action.applied { "✓" } else { "·" }; + println!("{marker} {} — {}", action.id, action.title); + println!(" {}", action.detail); + } + if dry_run { + println!(); + println!("Run without --dry-run to apply fixes."); + } + Ok(()) +} + +pub fn remove(name: &str, purge: bool) -> Result<()> { + remove_workspace(name, purge)?; + println!("Removed workspace: {name}"); + if purge { + println!(" purged data under ~/.config/agent-doctor/workspaces/{name}/"); + } + Ok(()) +} + +pub fn direnv(name: Option<&str>, write: bool) -> Result<()> { + let doc = load_workspaces()?; + let workspace_name = match name { + Some(name) => name.to_string(), + None => doc.active.clone().ok_or_else(|| { + anyhow::anyhow!("no active workspace — pass --name or run workspace use") + })?, + }; + + if write { + let path = write_direnv_envrc(&workspace_name)?; + println!("Wrote {}", path.display()); + println!("Run: direnv allow {}", path.parent().unwrap().display()); + return Ok(()); + } + + print!("{}", render_direnv_envrc(&workspace_name)?); + Ok(()) +} + +fn print_use_report(report: &agent_doctor_core::UseWorkspaceReport) { + println!("Active workspace: {}\n", report.name); + println!(" project: {}", report.path.display()); + println!(" env: {}", report.env_file.display()); + if let Some(backup_id) = &report.backup_id { + println!(" backup: {backup_id}"); + } + println!(); + for binding in &report.bindings { + println!( + "✓ {} — {} ({})", + binding.runtime_id, binding.action, binding.isolation_tier + ); + println!(" {}", binding.detail); + } + for restart in &report.gateway_restarts { + if restart.attempted { + let marker = if restart.success { "✓" } else { "!" }; + println!( + "{marker} gateway {} — {}", + restart.runtime_id, restart.detail + ); + } + } + println!(); + println!("Apply in this shell:"); + println!( + " eval \"$(agent-doctor workspace env --shell zsh --name {})\"", + report.name + ); + println!(" cd {}", report.path.display()); +} + +fn print_document(doc: &WorkspacesDocument) { + let path = agent_doctor_core::workspaces_path().ok(); + if let Some(path) = path { + println!("Workspaces ({})", path.display()); + } + println!("Active: {}\n", doc.active.as_deref().unwrap_or("(none)")); + if doc.workspaces.is_empty() { + println!("No workspaces. Create one with: agent-doctor workspace init"); + return; + } + for (name, entry) in &doc.workspaces { + let marker = if doc.active.as_deref() == Some(name.as_str()) { + "*" + } else { + " " + }; + println!( + "{marker} {name} — {}\n hermes={} codex={}", + entry.path.display(), + entry.hermes_profile, + entry.codex_home.display() + ); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index f2955cb..5948b2e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -103,6 +103,11 @@ enum Commands { #[command(subcommand)] action: PolicyAction, }, + /// Per-project workspace isolation (Hermes, Claude Code, Codex, OpenClaw) + Workspace { + #[command(subcommand)] + action: WorkspaceAction, + }, } #[derive(Subcommand)] @@ -141,6 +146,107 @@ enum ConfigAction { }, } +#[derive(Subcommand)] +enum WorkspaceAction { + /// Register a project directory as an isolated workspace + Init { + /// Project path (default: current directory) + path: Option, + /// Workspace name (default: directory name) + #[arg(long)] + name: Option, + /// Resolve git repository root instead of the given directory + #[arg(long)] + git_root: bool, + }, + /// List registered workspaces + List { + #[arg(long)] + json: bool, + }, + /// Activate a workspace and write active-workspace.env + Use { + name: String, + /// Skip config backup before switching + #[arg(long)] + no_backup: bool, + /// Restart Hermes/OpenClaw gateways after switching + #[arg(long)] + restart_gateways: bool, + }, + /// Match cwd to a registered workspace (prints name) + Match { + path: Option, + #[arg(long)] + git_root: bool, + }, + /// Print shell exports for a workspace (eval "$(agent-doctor workspace env --shell zsh)") + Env { + #[arg(long, default_value = "zsh")] + shell: String, + #[arg(long)] + name: Option, + }, + /// Activate workspace for path, backup, and print eval snippet + Enter { + path: Option, + #[arg(long)] + git_root: bool, + }, + /// Print or write a direnv .envrc for a workspace + Direnv { + #[arg(long)] + name: Option, + /// Write .envrc into the project directory + #[arg(long)] + write: bool, + }, + /// Install shell cd hooks for auto workspace env alignment + Hook { + #[command(subcommand)] + action: WorkspaceHookAction, + }, + /// Show active workspace and runtime alignment + Status { + path: Option, + #[arg(long)] + json: bool, + }, + /// Detect memory/config bleed risks for the active workspace + Doctor { + #[arg(long)] + json: bool, + }, + /// Auto-fix alignment issues detected by workspace doctor + Fix { + /// Preview fixes without applying + #[arg(long)] + dry_run: bool, + /// Restart Hermes/OpenClaw gateways when fixing gateway mismatch + #[arg(long)] + restart_gateways: bool, + #[arg(long)] + json: bool, + }, + /// Remove a registered workspace + Remove { + name: String, + /// Delete ~/.config/agent-doctor/workspaces// data + #[arg(long)] + purge: bool, + }, +} + +#[derive(Subcommand)] +enum WorkspaceHookAction { + /// Install shell cd hooks (zsh and/or bash) + Install { + /// Shell hook to install: zsh, bash, fish, or all + #[arg(long, default_value = "all")] + shell: String, + }, +} + #[derive(Subcommand)] enum PolicyAction { Pull, @@ -204,6 +310,44 @@ fn main() -> Result<()> { Commands::Policy { action } => match action { PolicyAction::Pull => commands::policy::pull()?, }, + Commands::Workspace { action } => match action { + WorkspaceAction::Init { + path, + name, + git_root, + } => commands::workspace::init(path, name, git_root)?, + WorkspaceAction::List { json } => commands::workspace::list(json)?, + WorkspaceAction::Use { + name, + no_backup, + restart_gateways, + } => commands::workspace::activate(&name, !no_backup, restart_gateways)?, + WorkspaceAction::Match { path, git_root } => { + commands::workspace::r#match(path, git_root)? + } + WorkspaceAction::Env { shell, name } => { + commands::workspace::env(&shell, name.as_deref())? + } + WorkspaceAction::Enter { path, git_root } => { + commands::workspace::enter(path, git_root)? + } + WorkspaceAction::Direnv { name, write } => { + commands::workspace::direnv(name.as_deref(), write)? + } + WorkspaceAction::Hook { action } => match action { + WorkspaceHookAction::Install { shell } => { + commands::workspace::hook_install(&shell)? + } + }, + WorkspaceAction::Status { path, json } => commands::workspace::status(path, json)?, + WorkspaceAction::Doctor { json } => commands::workspace::doctor(json)?, + WorkspaceAction::Fix { + dry_run, + restart_gateways, + json, + } => commands::workspace::fix(dry_run, restart_gateways, json)?, + WorkspaceAction::Remove { name, purge } => commands::workspace::remove(&name, purge)?, + }, } Ok(()) } diff --git a/crates/agent-doctor-core/src/lib.rs b/crates/agent-doctor-core/src/lib.rs index 21a4bd2..aa7a185 100644 --- a/crates/agent-doctor-core/src/lib.rs +++ b/crates/agent-doctor-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod profile; pub mod repair; pub mod runtime; pub mod setup; +pub mod workspace; pub use adapter::{ AdapterDiscovery, ApplyReport, RuntimeAdapter, RuntimeModelPreset, RuntimeModelState, @@ -54,3 +55,14 @@ pub use runtime::{ suggest_runtime_repairs, RuntimeDescriptor, RuntimeLifecycleAction, RuntimeProbeSpec, }; pub use setup::{execute_setup, RuntimeSetupResult, SetupOptions, SetupReport}; +pub use workspace::{ + active_env_path, bash_hook_file_path, enter_workspace, fish_hook_file_path, hook_file_path, + init_workspace, install_bash_hook, install_fish_hook, install_zsh_hook, load_workspaces, + match_workspace_for_path, remove_workspace, render_direnv_envrc, render_shell_env, + render_shell_env_for_name, save_workspaces, use_workspace, use_workspace_with_options, + workspace_doctor, workspace_fix, workspace_status, workspaces_path, write_direnv_envrc, + EnterWorkspaceReport, GatewayRestartReport, InitWorkspaceReport, UseWorkspaceOptions, + UseWorkspaceReport, WorkspaceCheck, WorkspaceCheckStatus, WorkspaceDoctorReport, + WorkspaceEntry, WorkspaceFixAction, WorkspaceFixReport, WorkspaceStatusReport, + WorkspacesDocument, +}; diff --git a/crates/agent-doctor-core/src/workspace/backends.rs b/crates/agent-doctor-core/src/workspace/backends.rs new file mode 100644 index 0000000..e51f205 --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/backends.rs @@ -0,0 +1,480 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use serde_json::{json, Value as JsonValue}; +use serde_yaml::{Mapping, Value as YamlValue}; + +use crate::adapters::util::{find_binary, home_join}; +use crate::lifecycle::ShellCapture; + +use super::path::paths_equal; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct RuntimeBindReport { + pub runtime_id: &'static str, + pub action: String, + pub detail: String, + pub isolation_tier: &'static str, +} + +pub fn bind_hermes(profile: &str, project_path: &Path) -> Result { + let profile_dir = home_join(".hermes/profiles").join(profile); + if !profile_dir.exists() { + if find_binary("hermes").is_some() { + let create = run_hermes(&["profile", "create", profile])?; + if !create.success { + fs::create_dir_all(&profile_dir) + .with_context(|| format!("create {}", profile_dir.display()))?; + } + } else { + fs::create_dir_all(&profile_dir) + .with_context(|| format!("create {}", profile_dir.display()))?; + } + } + + write_hermes_terminal_cwd(&profile_dir, project_path)?; + activate_hermes_profile(profile)?; + + Ok(RuntimeBindReport { + runtime_id: "hermes", + action: "bind profile".to_string(), + detail: format!( + "profile={profile} home={} cwd={}", + profile_dir.display(), + project_path.display() + ), + isolation_tier: "L3 (profile memory/sessions/skills)", + }) +} + +pub fn bind_claude_code(project_path: &Path) -> Result { + let claude_dir = project_path.join(".claude"); + fs::create_dir_all(&claude_dir).with_context(|| format!("create {}", claude_dir.display()))?; + + let settings = claude_dir.join("settings.json"); + if !settings.exists() { + fs::write(&settings, "{}\n").with_context(|| format!("create {}", settings.display()))?; + } + + Ok(RuntimeBindReport { + runtime_id: "claude-code", + action: "ensure project scope".to_string(), + detail: format!( + "project={} claude_dir={} (memory under ~/.claude/projects//)", + project_path.display(), + claude_dir.display() + ), + isolation_tier: "L3 (project hash memory)", + }) +} + +pub fn bind_codex(codex_home: &Path) -> Result { + fs::create_dir_all(codex_home).with_context(|| format!("create {}", codex_home.display()))?; + + let default_home = home_join(".codex"); + let default_config = default_home.join("config.toml"); + let target_config = codex_home.join("config.toml"); + if default_config.exists() && !target_config.exists() { + fs::copy(&default_config, &target_config).with_context(|| { + format!( + "seed {} from {}", + target_config.display(), + default_config.display() + ) + })?; + } + + let default_auth = default_home.join("auth.json"); + let target_auth = codex_home.join("auth.json"); + if default_auth.exists() && !target_auth.exists() { + fs::copy(&default_auth, &target_auth).with_context(|| { + format!( + "seed {} from {}", + target_auth.display(), + default_auth.display() + ) + })?; + } + + fs::create_dir_all(codex_home.join("memories")).ok(); + + Ok(RuntimeBindReport { + runtime_id: "codex", + action: "isolate CODEX_HOME".to_string(), + detail: format!("CODEX_HOME={}", codex_home.display()), + isolation_tier: "L2 (CODEX_HOME overlay — not native per-repo memory)", + }) +} + +pub fn bind_openclaw(agent_id: &str, workspace_path: &Path) -> Result { + fs::create_dir_all(workspace_path) + .with_context(|| format!("create {}", workspace_path.display()))?; + + seed_openclaw_workspace_files(workspace_path)?; + upsert_openclaw_agent(agent_id, workspace_path)?; + + Ok(RuntimeBindReport { + runtime_id: "openclaw", + action: "bind agent workspace".to_string(), + detail: format!("agent_id={agent_id} workspace={}", workspace_path.display()), + isolation_tier: "L2 (agent workspace — configure routing separately)", + }) +} + +#[derive(Debug, Clone, Default)] +pub struct ClaudeMcpSummary { + pub user_scope_servers: usize, + pub claude_json_servers: usize, + pub claude_json_project_servers: usize, + pub project_mcp_file_servers: usize, +} + +pub fn claude_mcp_summary_for_project(project_path: &Path) -> ClaudeMcpSummary { + let mut summary = claude_global_mcp_summary(); + + let project_mcp = project_path.join(".mcp.json"); + if project_mcp.exists() { + if let Ok(raw) = fs::read_to_string(&project_mcp) { + if let Ok(value) = serde_json::from_str::(&raw) { + summary.project_mcp_file_servers = value + .get("mcpServers") + .and_then(JsonValue::as_object) + .map(|map| map.len()) + .unwrap_or(0); + } + } + } + + let claude_json = home_join(".claude.json"); + if !claude_json.exists() { + return summary; + } + let Ok(raw) = fs::read_to_string(&claude_json) else { + return summary; + }; + let Ok(value) = serde_json::from_str::(&raw) else { + return summary; + }; + + if let Some(projects) = value.get("projects").and_then(JsonValue::as_object) { + for (key, project) in projects { + let key_path = PathBuf::from(key); + if paths_equal(&key_path, project_path) || project_path.starts_with(&key_path) { + summary.claude_json_project_servers = project + .get("mcpServers") + .and_then(JsonValue::as_object) + .map(|map| map.len()) + .unwrap_or(0); + break; + } + } + } + + summary +} + +pub fn claude_global_mcp_summary() -> ClaudeMcpSummary { + let mut summary = ClaudeMcpSummary::default(); + let settings = home_join(".claude/settings.json"); + if settings.exists() { + if let Ok(raw) = fs::read_to_string(&settings) { + if let Ok(value) = serde_json::from_str::(&raw) { + summary.user_scope_servers = value + .get("mcpServers") + .and_then(JsonValue::as_object) + .map(|map| map.len()) + .unwrap_or(0); + } + } + } + + let claude_json = home_join(".claude.json"); + if claude_json.exists() { + if let Ok(raw) = fs::read_to_string(&claude_json) { + if let Ok(value) = serde_json::from_str::(&raw) { + summary.claude_json_servers = value + .get("mcpServers") + .and_then(JsonValue::as_object) + .map(|map| map.len()) + .unwrap_or(0); + } + } + } + + summary +} + +pub fn hermes_gateway_profiles() -> Vec { + let profiles_root = home_join(".hermes/profiles"); + let Ok(entries) = fs::read_dir(&profiles_root) else { + return Vec::new(); + }; + + let mut profiles = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if path.join("gateway.lock").exists() { + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + profiles.push(name.to_string()); + } + } + } + profiles.sort(); + profiles +} + +pub fn hermes_active_profile() -> Option { + if let Some(profile) = read_hermes_sticky_profile_file() { + return Some(profile); + } + if find_binary("hermes").is_none() { + return infer_hermes_profile_from_home_env(); + } + let capture = run_hermes(&["profile", "list"]).ok()?; + if !capture.success { + return infer_hermes_profile_from_home_env(); + } + parse_hermes_profile_list(&capture.stdout) + .or_else(|| parse_hermes_profile_list(&capture.stderr)) + .or_else(read_hermes_sticky_profile_file) +} + +fn read_hermes_sticky_profile_file() -> Option { + for relative in [".hermes/active_profile", ".hermes/profile"] { + let path = home_join(relative); + if !path.exists() { + continue; + } + let raw = fs::read_to_string(&path).ok()?; + let name = raw.lines().next()?.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + None +} + +pub fn hermes_profile_cwd(profile: &str) -> Option { + let config = home_join(".hermes/profiles") + .join(profile) + .join("config.yaml"); + if !config.exists() { + return None; + } + let raw = fs::read_to_string(&config).ok()?; + let value: Mapping = serde_yaml::from_str(&raw).ok()?; + value + .get("terminal") + .and_then(|terminal| terminal.get("cwd")) + .and_then(|cwd| cwd.as_str()) + .map(PathBuf::from) +} + +pub fn codex_home_from_env() -> PathBuf { + std::env::var("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_join(".codex")) +} + +pub fn openclaw_agent_workspace(agent_id: &str) -> Option { + let config_path = home_join(".openclaw/openclaw.json"); + if !config_path.exists() { + return None; + } + let raw = fs::read_to_string(&config_path).ok()?; + let value: JsonValue = serde_json::from_str(&raw).ok()?; + let agents = value.pointer("/agents/list")?.as_array()?; + for agent in agents { + if agent.get("id").and_then(JsonValue::as_str) == Some(agent_id) { + return agent + .get("workspace") + .and_then(JsonValue::as_str) + .map(PathBuf::from); + } + } + None +} + +fn activate_hermes_profile(profile: &str) -> Result<()> { + if find_binary("hermes").is_some() { + let capture = run_hermes(&["profile", "use", profile])?; + if capture.success { + return Ok(()); + } + } + write_hermes_sticky_profile(profile) +} + +fn write_hermes_sticky_profile(profile: &str) -> Result<()> { + let sticky = home_join(".hermes/active_profile"); + if let Some(parent) = sticky.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&sticky, format!("{profile}\n")) + .with_context(|| format!("write {}", sticky.display())) +} + +fn write_hermes_terminal_cwd(profile_dir: &Path, project_path: &Path) -> Result<()> { + let config_path = profile_dir.join("config.yaml"); + let mut mapping = if config_path.exists() { + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("read {}", config_path.display()))?; + serde_yaml::from_str::(&raw).unwrap_or_default() + } else { + Mapping::new() + }; + + let terminal = mapping + .entry("terminal".into()) + .or_insert_with(|| YamlValue::Mapping(Mapping::new())); + if let YamlValue::Mapping(terminal_map) = terminal { + terminal_map.insert("backend".into(), YamlValue::String("local".into())); + terminal_map.insert( + "cwd".into(), + YamlValue::String(project_path.display().to_string()), + ); + } + + let raw = serde_yaml::to_string(&mapping)?; + fs::write(&config_path, raw).with_context(|| format!("write {}", config_path.display())) +} + +fn seed_openclaw_workspace_files(workspace_path: &Path) -> Result<()> { + let memory = workspace_path.join("MEMORY.md"); + if !memory.exists() { + fs::write( + &memory, + "# Project memory (OpenClaw workspace)\n\nManaged by Agent Doctor workspace.\n", + )?; + } + let agents = workspace_path.join("AGENTS.md"); + if !agents.exists() { + fs::write( + &agents, + "# Agents (OpenClaw workspace)\n\nManaged by Agent Doctor workspace.\n", + )?; + } + fs::create_dir_all(workspace_path.join("memory")).ok(); + Ok(()) +} + +fn upsert_openclaw_agent(agent_id: &str, workspace_path: &Path) -> Result<()> { + let config_path = home_join(".openclaw/openclaw.json"); + if !config_path.exists() { + return Ok(()); + } + + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("read {}", config_path.display()))?; + let mut value: JsonValue = + serde_json::from_str(&raw).with_context(|| format!("parse {}", config_path.display()))?; + + let agents = value + .pointer_mut("/agents/list") + .and_then(JsonValue::as_array_mut); + let Some(agents) = agents else { + return Ok(()); + }; + + let workspace_str = workspace_path.display().to_string(); + if let Some(existing) = agents + .iter_mut() + .find(|agent| agent.get("id").and_then(JsonValue::as_str) == Some(agent_id)) + { + if let Some(obj) = existing.as_object_mut() { + obj.insert("workspace".to_string(), json!(workspace_str)); + } + } else { + agents.push(json!({ + "id": agent_id, + "name": agent_id, + "workspace": workspace_str, + })); + } + + let updated = serde_json::to_string_pretty(&value)?; + fs::write(&config_path, format!("{updated}\n")) + .with_context(|| format!("write {}", config_path.display())) +} + +pub(crate) fn run_openclaw(args: &[&str]) -> Result { + let Some(binary) = find_binary("openclaw") else { + anyhow::bail!("openclaw binary not found"); + }; + let output = Command::new(binary) + .args(args) + .output() + .context("run openclaw")?; + Ok(ShellCapture { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code(), + }) +} + +pub(crate) fn run_hermes(args: &[&str]) -> Result { + let Some(binary) = find_binary("hermes") else { + anyhow::bail!("hermes binary not found"); + }; + let output = Command::new(binary) + .args(args) + .output() + .context("run hermes")?; + Ok(ShellCapture { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code(), + }) +} + +fn infer_hermes_profile_from_home_env() -> Option { + std::env::var("HERMES_HOME").ok().and_then(|home| { + let path = PathBuf::from(home); + let profiles_root = home_join(".hermes/profiles"); + if path.starts_with(&profiles_root) { + path.file_name()?.to_str().map(str::to_string) + } else { + None + } + }) +} + +fn parse_hermes_profile_list(text: &str) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.contains('◆') || trimmed.starts_with('*') || trimmed.contains("(active)") { + let name = trimmed + .trim_start_matches('*') + .trim_start_matches('◆') + .split_whitespace() + .next()? + .trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_'); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + None +} + +pub fn workspace_paths_match(expected: &Path, actual: &Path) -> bool { + paths_equal(expected, actual) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hermes_profile_list_finds_active_marker() { + let text = " work\n◆ foo-app\n bar\n"; + assert_eq!(parse_hermes_profile_list(text).as_deref(), Some("foo-app")); + } +} diff --git a/crates/agent-doctor-core/src/workspace/backup.rs b/crates/agent-doctor-core/src/workspace/backup.rs new file mode 100644 index 0000000..02d8fab --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/backup.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; + +use crate::adapters::util::home_join; +use crate::repair::{backups_root, BackupSnapshot, SensitivityLevel, SnapshotFile}; + +use super::WorkspaceEntry; + +pub fn create_workspace_switch_backup( + name: &str, + entry: &WorkspaceEntry, +) -> Result { + let snapshot_id = format!("workspace-{name}-{}", unix_seconds()); + let snapshot_root = backups_root()?.join(&snapshot_id); + fs::create_dir_all(&snapshot_root)?; + + let mut paths = Vec::new(); + let hermes_config = home_join(".hermes/profiles") + .join(&entry.hermes_profile) + .join("config.yaml"); + if hermes_config.exists() { + paths.push(hermes_config); + } + let openclaw_config = home_join(".openclaw/openclaw.json"); + if openclaw_config.exists() { + paths.push(openclaw_config); + } + let claude_project_settings = entry.path.join(".claude/settings.json"); + if claude_project_settings.exists() { + paths.push(claude_project_settings); + } + + let mut files = snapshot_paths(&paths, &snapshot_root)?; + files.push(snapshot_metadata(name, entry, &snapshot_root)?); + + Ok(BackupSnapshot { + id: snapshot_id, + runtime_id: "workspace".to_string(), + root: snapshot_root.display().to_string(), + files, + }) +} + +fn snapshot_metadata( + name: &str, + entry: &WorkspaceEntry, + snapshot_root: &Path, +) -> Result { + let dest = snapshot_root.join("workspace-entry.yaml"); + let raw = format!( + "name: {name}\npath: {}\nhermes_profile: {}\ncodex_home: {}\nopenclaw_agent_id: {}\nopenclaw_workspace: {}\n", + entry.path.display(), + entry.hermes_profile, + entry.codex_home.display(), + entry.openclaw_agent_id, + entry.openclaw_workspace.display(), + ); + fs::write(&dest, raw).context("write workspace backup metadata")?; + Ok(SnapshotFile { + original_path: "workspace-entry".to_string(), + snapshot_path: dest.display().to_string(), + sensitivity: SensitivityLevel::ConfigShape, + }) +} + +fn snapshot_paths(paths: &[PathBuf], snapshot_root: &Path) -> Result> { + let mut files = Vec::new(); + for path in paths { + if !path.exists() { + continue; + } + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "config".to_string()); + let dest = snapshot_root.join(&file_name); + if dest.exists() { + let stem = path + .parent() + .and_then(|parent| parent.file_name()) + .map(|name| format!("{}-", name.to_string_lossy())) + .unwrap_or_default(); + let dest = snapshot_root.join(format!("{stem}{file_name}")); + fs::copy(path, &dest).with_context(|| { + format!("failed to copy {} to {}", path.display(), dest.display()) + })?; + files.push(SnapshotFile { + original_path: path.display().to_string(), + snapshot_path: dest.display().to_string(), + sensitivity: SensitivityLevel::LocalPath, + }); + continue; + } + fs::copy(path, &dest) + .with_context(|| format!("failed to copy {} to {}", path.display(), dest.display()))?; + files.push(SnapshotFile { + original_path: path.display().to_string(), + snapshot_path: dest.display().to_string(), + sensitivity: SensitivityLevel::LocalPath, + }); + } + Ok(files) +} + +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} diff --git a/crates/agent-doctor-core/src/workspace/fix.rs b/crates/agent-doctor-core/src/workspace/fix.rs new file mode 100644 index 0000000..ddd691f --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/fix.rs @@ -0,0 +1,193 @@ +use anyhow::{Context, Result}; + +use super::backends::{bind_claude_code, bind_codex, bind_hermes, bind_openclaw}; +use super::gateway::restart_workspace_gateways; +use super::snapshot::{apply_workspace_snapshot, save_workspace_snapshot}; +use super::{ + load_workspaces, save_workspaces, workspace_data_root, workspace_doctor, write_active_env, + WorkspaceCheckStatus, WorkspaceDoctorReport, WorkspaceEntry, +}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct WorkspaceFixAction { + pub id: String, + pub title: String, + pub applied: bool, + pub detail: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct WorkspaceFixReport { + pub active: Option, + pub actions: Vec, +} + +pub fn workspace_fix(dry_run: bool, restart_gateways: bool) -> Result { + let doc = load_workspaces()?; + let Some(active_name) = doc.active.clone() else { + return Ok(WorkspaceFixReport { + active: None, + actions: vec![WorkspaceFixAction { + id: "workspace.active.missing".into(), + title: "No active workspace".into(), + applied: false, + detail: "Run `agent-doctor workspace init` then `workspace use `.".into(), + }], + }); + }; + + let Some(entry) = doc.workspaces.get(&active_name).cloned() else { + return Ok(WorkspaceFixReport { + active: Some(active_name), + actions: vec![WorkspaceFixAction { + id: "workspace.active.invalid".into(), + title: "Active workspace entry missing".into(), + applied: false, + detail: "Re-register or pick a valid workspace with `workspace use`.".into(), + }], + }); + }; + + let doctor = workspace_doctor()?; + let mut actions = plan_fixes(&active_name, &entry, &doctor); + + if !dry_run { + for action in &mut actions { + if action.applied { + continue; + } + match action.id.as_str() { + "workspace.hermes.profile" => { + bind_hermes(&entry.hermes_profile, &entry.path)?; + action.applied = true; + action.detail = format!("Activated Hermes profile '{}'", entry.hermes_profile); + } + "workspace.openclaw.workspace" => { + bind_openclaw(&entry.openclaw_agent_id, &entry.openclaw_workspace)?; + action.applied = true; + action.detail = format!( + "Updated openclaw.json workspace for agent '{}'", + entry.openclaw_agent_id + ); + } + "workspace.codex.home" | "workspace.codex.global_memory" => { + write_active_env(&active_name, &entry)?; + bind_codex(&entry.codex_home)?; + action.applied = true; + action.detail = format!( + "Refreshed active-workspace.env (CODEX_HOME={})", + entry.codex_home.display() + ); + } + "workspace.claude.project_mcp" => { + let data_root = workspace_data_root(&active_name)?; + let report = apply_workspace_snapshot(&entry, &data_root)?; + if report.mcp_applied { + action.applied = true; + action.detail = "Restored project .mcp.json from workspace snapshot".into(); + } else { + bind_claude_code(&entry.path)?; + save_workspace_snapshot(&entry, &data_root)?; + action.applied = true; + action.detail = + "Ensured .claude/ scaffold and refreshed MCP snapshot template".into(); + } + } + "workspace.hermes.gateway_mismatch" if restart_gateways => { + let reports = restart_workspace_gateways(&entry); + action.applied = reports.iter().any(|report| report.success); + action.detail = reports + .into_iter() + .map(|report| format!("{}: {}", report.runtime_id, report.detail)) + .collect::>() + .join("; "); + } + _ => {} + } + } + } + + Ok(WorkspaceFixReport { + active: Some(active_name), + actions, + }) +} + +fn plan_fixes( + active_name: &str, + entry: &WorkspaceEntry, + doctor: &WorkspaceDoctorReport, +) -> Vec { + let mut actions = Vec::new(); + + for check in &doctor.checks { + if check.status == WorkspaceCheckStatus::Pass { + continue; + } + + let fixable = matches!( + check.id.as_str(), + "workspace.hermes.profile" + | "workspace.openclaw.workspace" + | "workspace.codex.home" + | "workspace.codex.global_memory" + | "workspace.claude.project_mcp" + | "workspace.hermes.gateway_mismatch" + ); + + if !fixable { + continue; + } + + actions.push(WorkspaceFixAction { + id: check.id.clone(), + title: check.title.clone(), + applied: false, + detail: if check.id == "workspace.claude.project_mcp" { + format!( + "Will restore .mcp.json from ~/.config/agent-doctor/workspaces/{active_name}/snapshots/" + ) + } else if check.id == "workspace.hermes.gateway_mismatch" { + "Will attempt Hermes/OpenClaw gateway restart (pass --restart-gateways)".into() + } else { + check.detail.clone() + }, + }); + } + + if actions.is_empty() { + actions.push(WorkspaceFixAction { + id: "workspace.fix.nothing".into(), + title: "No auto-fixable issues".into(), + applied: false, + detail: "Run `workspace doctor` for manual hints (cwd mismatch, gateway restart, global MCP)." + .into(), + }); + } + + let _ = entry; + actions +} + +pub fn remove_workspace(name: &str, purge_data: bool) -> Result<()> { + let mut doc = load_workspaces()?; + if !doc.workspaces.contains_key(name) { + anyhow::bail!("workspace '{name}' not found"); + } + + doc.workspaces.remove(name); + if doc.active.as_deref() == Some(name) { + doc.active = doc.workspaces.keys().next().cloned(); + } + save_workspaces(&doc)?; + + if purge_data { + let data_root = workspace_data_root(name)?; + if data_root.exists() { + std::fs::remove_dir_all(&data_root) + .with_context(|| format!("purge {}", data_root.display()))?; + } + } + + Ok(()) +} diff --git a/crates/agent-doctor-core/src/workspace/gateway.rs b/crates/agent-doctor-core/src/workspace/gateway.rs new file mode 100644 index 0000000..bb07a9a --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/gateway.rs @@ -0,0 +1,149 @@ +use super::backends::{hermes_gateway_profiles, run_hermes, run_openclaw}; +use super::WorkspaceEntry; +use crate::adapters::util::find_binary; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct GatewayRestartReport { + pub runtime_id: &'static str, + pub attempted: bool, + pub success: bool, + pub detail: String, +} + +pub fn restart_workspace_gateways(entry: &WorkspaceEntry) -> Vec { + vec![restart_hermes_gateway(entry), restart_openclaw_gateway()] +} + +fn restart_hermes_gateway(entry: &WorkspaceEntry) -> GatewayRestartReport { + if find_binary("hermes").is_none() { + return GatewayRestartReport { + runtime_id: "hermes", + attempted: false, + success: false, + detail: "hermes binary not found — restart gateway manually after switching profile" + .into(), + }; + } + + let running = hermes_gateway_profiles(); + if running.is_empty() { + return GatewayRestartReport { + runtime_id: "hermes", + attempted: false, + success: false, + detail: "no Hermes gateway lock detected — start gateway under this profile if needed" + .into(), + }; + } + + if running + .iter() + .all(|profile| profile == &entry.hermes_profile) + { + return GatewayRestartReport { + runtime_id: "hermes", + attempted: false, + success: true, + detail: format!( + "Hermes gateway already on profile '{}'", + entry.hermes_profile + ), + }; + } + + if let Ok(capture) = run_hermes(&["gateway", "restart"]) { + if capture.success { + let aligned = hermes_gateway_profiles() + .iter() + .all(|profile| profile == &entry.hermes_profile); + return GatewayRestartReport { + runtime_id: "hermes", + attempted: true, + success: aligned, + detail: if aligned { + format!( + "Hermes gateway restarted for profile '{}'", + entry.hermes_profile + ) + } else { + format!( + "`hermes gateway restart` ran but gateway still reports: {}", + running.join(", ") + ) + }, + }; + } + } + + let stop_ok = run_hermes(&["gateway", "stop"]) + .map(|capture| capture.success) + .unwrap_or(false); + let start_ok = run_hermes(&["gateway", "start"]) + .map(|capture| capture.success) + .unwrap_or(false); + let aligned = hermes_gateway_profiles() + .iter() + .all(|profile| profile == &entry.hermes_profile); + + GatewayRestartReport { + runtime_id: "hermes", + attempted: stop_ok || start_ok, + success: aligned, + detail: if aligned { + "Hermes gateway stop/start completed".into() + } else { + format!( + "could not align Hermes gateway to profile '{}' — try: hermes profile use {profile} && hermes gateway restart", + entry.hermes_profile, + profile = entry.hermes_profile + ) + }, + } +} + +fn restart_openclaw_gateway() -> GatewayRestartReport { + if find_binary("openclaw").is_none() { + return GatewayRestartReport { + runtime_id: "openclaw", + attempted: false, + success: false, + detail: "openclaw binary not found".into(), + }; + } + + for args in [["gateway", "restart"], ["gateway", "reload"]] { + if let Ok(capture) = run_openclaw(&args) { + if capture.success { + return GatewayRestartReport { + runtime_id: "openclaw", + attempted: true, + success: true, + detail: format!("ran `openclaw {}`", args.join(" ")), + }; + } + } + } + + GatewayRestartReport { + runtime_id: "openclaw", + attempted: true, + success: false, + detail: "OpenClaw gateway restart not confirmed — run `openclaw gateway restart` if routing is stale" + .into(), + } +} + +pub fn gateway_restart_hint(entry: &WorkspaceEntry) -> Option { + let running = hermes_gateway_profiles(); + if running + .iter() + .any(|profile| profile != &entry.hermes_profile) + { + Some(format!( + "Hermes gateway running under {} — rerun with --restart-gateways", + running.join(", ") + )) + } else { + None + } +} diff --git a/crates/agent-doctor-core/src/workspace/mod.rs b/crates/agent-doctor-core/src/workspace/mod.rs new file mode 100644 index 0000000..85e706d --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/mod.rs @@ -0,0 +1,690 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::adapters::util::home_join; + +use self::backends::{ + bind_claude_code, bind_codex, bind_hermes, bind_openclaw, claude_mcp_summary_for_project, + codex_home_from_env, hermes_active_profile, hermes_gateway_profiles, openclaw_agent_workspace, + workspace_paths_match, RuntimeBindReport, +}; +use self::path::{ + cwd, default_workspace_name, paths_equal, resolve_project_path, sanitize_workspace_name, +}; + +pub mod backends; +pub mod backup; +pub mod fix; +pub mod gateway; +pub mod path; +pub mod shell; +pub mod snapshot; + +const WORKSPACES_FILE: &str = "workspaces.yaml"; +const ACTIVE_ENV_FILE: &str = "active-workspace.env"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorkspacesDocument { + #[serde(default)] + pub active: Option, + #[serde(default)] + pub workspaces: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceEntry { + pub path: PathBuf, + #[serde(default)] + pub hermes_profile: String, + pub codex_home: PathBuf, + #[serde(default)] + pub openclaw_agent_id: String, + pub openclaw_workspace: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InitWorkspaceReport { + pub name: String, + pub path: PathBuf, + pub config_path: PathBuf, + pub bindings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UseWorkspaceReport { + pub name: String, + pub path: PathBuf, + pub env_file: PathBuf, + pub bindings: Vec, + pub backup_id: Option, + #[serde(default)] + pub gateway_restarts: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct UseWorkspaceOptions { + pub backup: bool, + pub restart_gateways: bool, +} + +pub use fix::{remove_workspace, workspace_fix, WorkspaceFixAction, WorkspaceFixReport}; +pub use gateway::{gateway_restart_hint, restart_workspace_gateways, GatewayRestartReport}; +pub use shell::{ + bash_hook_file_path, enter_workspace, fish_hook_file_path, hook_file_path, install_bash_hook, + install_fish_hook, install_zsh_hook, match_workspace_for_path, render_direnv_envrc, + render_shell_env, render_shell_env_for_name, write_direnv_envrc, EnterWorkspaceReport, +}; +pub use snapshot::{ + apply_workspace_snapshot, save_workspace_snapshot, snapshot_dir, SnapshotReport, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceStatusReport { + pub active: Option, + pub cwd: PathBuf, + pub matched_workspace: Option, + pub runtimes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeStatus { + pub runtime_id: &'static str, + pub isolation_tier: &'static str, + pub expected: String, + pub actual: String, + pub aligned: bool, + pub hint: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceDoctorReport { + pub active: Option, + pub checks: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceCheck { + pub id: String, + pub title: String, + pub status: WorkspaceCheckStatus, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceCheckStatus { + Pass, + Warn, + Fail, +} + +pub fn workspaces_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("agent-doctor").join(WORKSPACES_FILE)) + .context("could not resolve config directory") +} + +pub fn active_env_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("agent-doctor").join(ACTIVE_ENV_FILE)) + .context("could not resolve config directory") +} + +pub fn workspace_data_root(name: &str) -> Result { + dirs::config_dir() + .map(|dir| dir.join("agent-doctor").join("workspaces").join(name)) + .context("could not resolve config directory") +} + +pub fn load_workspaces() -> Result { + let path = workspaces_path()?; + if !path.exists() { + return Ok(WorkspacesDocument::default()); + } + let raw = fs::read_to_string(&path)?; + serde_yaml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display())) +} + +pub fn save_workspaces(doc: &WorkspacesDocument) -> Result { + let path = workspaces_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, serde_yaml::to_string(doc)?)?; + Ok(path) +} + +pub fn init_workspace( + path: Option, + name: Option, + prefer_git_root: bool, +) -> Result { + let project_path = resolve_project_path(path, prefer_git_root)?; + let mut doc = load_workspaces()?; + + if let Some((existing_name, _)) = doc + .workspaces + .iter() + .find(|(_, entry)| paths_equal(&entry.path, &project_path)) + { + bail!( + "project already registered as workspace '{existing_name}' ({})", + project_path.display() + ); + } + + let base_name = name + .as_deref() + .map(sanitize_workspace_name) + .unwrap_or_else(|| default_workspace_name(&project_path)); + let workspace_name = unique_workspace_name(&base_name, &doc.workspaces); + + let data_root = workspace_data_root(&workspace_name)?; + fs::create_dir_all(&data_root)?; + + let hermes_profile = workspace_name.clone(); + let codex_home = data_root.join("codex-home"); + let openclaw_agent_id = workspace_name.clone(); + let openclaw_workspace = data_root.join("openclaw-workspace"); + + let bindings = vec![ + bind_hermes(&hermes_profile, &project_path)?, + bind_claude_code(&project_path)?, + bind_codex(&codex_home)?, + bind_openclaw(&openclaw_agent_id, &openclaw_workspace)?, + ]; + + let snapshot = save_workspace_snapshot( + &WorkspaceEntry { + path: project_path.clone(), + hermes_profile: hermes_profile.clone(), + codex_home: codex_home.clone(), + openclaw_agent_id: openclaw_agent_id.clone(), + openclaw_workspace: openclaw_workspace.clone(), + }, + &data_root, + )?; + + doc.workspaces.insert( + workspace_name.clone(), + WorkspaceEntry { + path: project_path.clone(), + hermes_profile, + codex_home, + openclaw_agent_id, + openclaw_workspace, + }, + ); + if doc.active.is_none() { + doc.active = Some(workspace_name.clone()); + } + let config_path = save_workspaces(&doc)?; + + let mut init_bindings = bindings; + if snapshot.mcp_saved || snapshot.skills_saved { + init_bindings.push(RuntimeBindReport { + runtime_id: "snapshot", + action: "save MCP/skills snapshot".to_string(), + detail: format!( + "mcp={} skills={} dir={}", + snapshot.mcp_saved, + snapshot.skills_saved, + snapshot::snapshot_dir(&data_root).display() + ), + isolation_tier: "L3 (project-scoped MCP/skills)", + }); + } + + Ok(InitWorkspaceReport { + name: workspace_name, + path: project_path, + config_path, + bindings: init_bindings, + }) +} + +pub fn use_workspace(name: &str) -> Result { + use_workspace_with_options( + name, + &UseWorkspaceOptions { + backup: true, + restart_gateways: false, + }, + ) +} + +pub fn use_workspace_with_options( + name: &str, + options: &UseWorkspaceOptions, +) -> Result { + let mut doc = load_workspaces()?; + let entry = doc + .workspaces + .get(name) + .with_context(|| format!("workspace '{name}' not found"))? + .clone(); + + let backup_id = if options.backup { + Some( + backup::create_workspace_switch_backup(name, &entry)? + .id + .clone(), + ) + } else { + None + }; + + doc.active = Some(name.to_string()); + save_workspaces(&doc)?; + + let data_root = workspace_data_root(name)?; + let bindings = vec![ + bind_hermes(&entry.hermes_profile, &entry.path)?, + bind_claude_code(&entry.path)?, + bind_codex(&entry.codex_home)?, + bind_openclaw(&entry.openclaw_agent_id, &entry.openclaw_workspace)?, + ]; + + let snapshot = apply_workspace_snapshot(&entry, &data_root)?; + save_workspace_snapshot(&entry, &data_root)?; + + let env_file = write_active_env(name, &entry)?; + + let gateway_restarts = if options.restart_gateways { + restart_workspace_gateways(&entry) + } else { + Vec::new() + }; + + let mut use_bindings = bindings; + if snapshot.mcp_applied || snapshot.skills_applied { + use_bindings.push(RuntimeBindReport { + runtime_id: "snapshot", + action: "apply MCP/skills snapshot".to_string(), + detail: format!( + "mcp={} skills={}", + snapshot.mcp_applied, snapshot.skills_applied + ), + isolation_tier: "L3 (project-scoped MCP/skills)", + }); + } + + Ok(UseWorkspaceReport { + name: name.to_string(), + path: entry.path, + env_file, + bindings: use_bindings, + backup_id, + gateway_restarts, + }) +} + +pub fn workspace_status(at: Option) -> Result { + let doc = load_workspaces()?; + let current = at.map_or_else(cwd, |path| resolve_project_path(Some(path), false))?; + let matched = doc + .workspaces + .iter() + .find(|(_, entry)| paths_equal(&entry.path, ¤t)) + .map(|(name, _)| name.clone()); + + let active_name = doc.active.clone(); + let active_entry = active_name + .as_ref() + .and_then(|name| doc.workspaces.get(name)); + + let mut runtimes = Vec::new(); + if let Some(entry) = active_entry { + runtimes.push(hermes_runtime_status(entry)); + runtimes.push(claude_runtime_status(entry, ¤t)); + runtimes.push(codex_runtime_status(entry)); + runtimes.push(openclaw_runtime_status(entry)); + } + + Ok(WorkspaceStatusReport { + active: active_name, + cwd: current, + matched_workspace: matched, + runtimes, + }) +} + +pub fn workspace_doctor() -> Result { + let doc = load_workspaces()?; + let current = cwd()?; + let mut checks = Vec::new(); + + let Some(active_name) = doc.active.clone() else { + checks.push(WorkspaceCheck { + id: "workspace.active.missing".to_string(), + title: "No active workspace".to_string(), + status: WorkspaceCheckStatus::Warn, + detail: "Run `agent-doctor workspace init` then `workspace use `.".to_string(), + }); + return Ok(WorkspaceDoctorReport { + active: None, + checks, + }); + }; + + let Some(entry) = doc.workspaces.get(&active_name).cloned() else { + checks.push(WorkspaceCheck { + id: "workspace.active.invalid".to_string(), + title: "Active workspace entry missing".to_string(), + status: WorkspaceCheckStatus::Fail, + detail: format!("Active workspace '{active_name}' is not in workspaces.yaml"), + }); + return Ok(WorkspaceDoctorReport { + active: Some(active_name), + checks, + }); + }; + + if !paths_equal(¤t, &entry.path) && !current.starts_with(&entry.path) { + checks.push(WorkspaceCheck { + id: "workspace.cwd.mismatch".to_string(), + title: "Shell cwd differs from active workspace".to_string(), + status: WorkspaceCheckStatus::Warn, + detail: format!("cwd={} active={}", current.display(), entry.path.display()), + }); + } else { + checks.push(WorkspaceCheck { + id: "workspace.cwd.mismatch".to_string(), + title: "Shell cwd matches active workspace".to_string(), + status: WorkspaceCheckStatus::Pass, + detail: entry.path.display().to_string(), + }); + } + + let hermes_status = hermes_runtime_status(&entry); + checks.push(check_from_runtime( + "workspace.hermes.profile", + "Hermes profile alignment", + &hermes_status, + )); + + let codex_status = codex_runtime_status(&entry); + checks.push(check_from_runtime( + "workspace.codex.home", + "Codex CODEX_HOME alignment", + &codex_status, + )); + + let openclaw_status = openclaw_runtime_status(&entry); + checks.push(check_from_runtime( + "workspace.openclaw.workspace", + "OpenClaw agent workspace alignment", + &openclaw_status, + )); + + checks.extend(claude_doctor_checks(&entry)); + + let gateways = hermes_gateway_profiles(); + if !gateways.is_empty() { + if !gateways + .iter() + .any(|profile| profile == &entry.hermes_profile) + { + checks.push(WorkspaceCheck { + id: "workspace.hermes.gateway_mismatch".to_string(), + title: "Hermes gateway running under a different profile".to_string(), + status: WorkspaceCheckStatus::Warn, + detail: format!( + "expected profile '{}' but gateway lock found for: {}", + entry.hermes_profile, + gateways.join(", ") + ), + }); + } else if !gateways.is_empty() { + checks.push(WorkspaceCheck { + id: "workspace.hermes.gateway_mismatch".to_string(), + title: "Hermes gateway profile matches workspace".to_string(), + status: WorkspaceCheckStatus::Pass, + detail: entry.hermes_profile.clone(), + }); + } + } + + if !workspace_paths_match(&entry.codex_home, &codex_home_from_env()) { + checks.push(WorkspaceCheck { + id: "workspace.codex.global_memory".to_string(), + title: "Codex not using workspace CODEX_HOME".to_string(), + status: WorkspaceCheckStatus::Warn, + detail: "Source ~/.config/agent-doctor/active-workspace.env before launching Codex to isolate memories." + .to_string(), + }); + } + + Ok(WorkspaceDoctorReport { + active: Some(active_name), + checks, + }) +} + +fn claude_doctor_checks(entry: &WorkspaceEntry) -> Vec { + let summary = claude_mcp_summary_for_project(&entry.path); + let project_mcp = entry.path.join(".mcp.json").exists(); + let mut checks = Vec::new(); + + let global_mcp_total = summary.user_scope_servers + summary.claude_json_servers; + if global_mcp_total > 0 { + let mut sources = Vec::new(); + if summary.user_scope_servers > 0 { + sources.push(format!( + "{} in ~/.claude/settings.json", + summary.user_scope_servers + )); + } + if summary.claude_json_servers > 0 { + sources.push(format!( + "{} in ~/.claude.json (global)", + summary.claude_json_servers + )); + } + checks.push(WorkspaceCheck { + id: "workspace.claude.global_mcp".to_string(), + title: "Claude Code user-scoped MCP servers detected".to_string(), + status: WorkspaceCheckStatus::Warn, + detail: format!( + "{} user-scoped MCP server(s) ({}) may apply across projects — prefer project .mcp.json", + global_mcp_total, + sources.join(", ") + ), + }); + } else { + checks.push(WorkspaceCheck { + id: "workspace.claude.global_mcp".to_string(), + title: "No Claude Code user-scoped MCP bleed detected".to_string(), + status: WorkspaceCheckStatus::Pass, + detail: "No global mcpServers in ~/.claude/settings.json or ~/.claude.json".to_string(), + }); + } + + if summary.claude_json_project_servers > 0 { + checks.push(WorkspaceCheck { + id: "workspace.claude.project_json_mcp".to_string(), + title: "Claude Code project entry in ~/.claude.json".to_string(), + status: WorkspaceCheckStatus::Pass, + detail: format!( + "{} project-scoped MCP server(s) under ~/.claude.json projects[{}]", + summary.claude_json_project_servers, + entry.path.display() + ), + }); + } + + if summary.project_mcp_file_servers > 0 { + checks.push(WorkspaceCheck { + id: "workspace.claude.project_mcp".to_string(), + title: "Project .mcp.json configured".to_string(), + status: WorkspaceCheckStatus::Pass, + detail: format!( + "{} MCP server(s) in {}", + summary.project_mcp_file_servers, + entry.path.join(".mcp.json").display() + ), + }); + } else if !project_mcp { + checks.push(WorkspaceCheck { + id: "workspace.claude.project_mcp".to_string(), + title: "Project .mcp.json not present".to_string(), + status: WorkspaceCheckStatus::Warn, + detail: format!( + "Consider adding {} for project-scoped MCP", + entry.path.join(".mcp.json").display() + ), + }); + } + + checks +} + +fn check_from_runtime(id: &str, title: &str, status: &RuntimeStatus) -> WorkspaceCheck { + WorkspaceCheck { + id: id.to_string(), + title: title.to_string(), + status: if status.aligned { + WorkspaceCheckStatus::Pass + } else { + WorkspaceCheckStatus::Warn + }, + detail: if status.aligned { + status.expected.clone() + } else { + format!( + "expected={} actual={} — {}", + status.expected, status.actual, status.hint + ) + }, + } +} + +fn hermes_runtime_status(entry: &WorkspaceEntry) -> RuntimeStatus { + let expected = entry.hermes_profile.clone(); + let actual = hermes_active_profile().unwrap_or_else(|| "(unknown)".to_string()); + let aligned = actual == expected; + RuntimeStatus { + runtime_id: "hermes", + isolation_tier: "L3", + expected: expected.clone(), + actual, + aligned, + hint: "Run `agent-doctor workspace use ` or `hermes profile use `".into(), + } +} + +fn claude_runtime_status(entry: &WorkspaceEntry, current: &Path) -> RuntimeStatus { + let aligned = paths_equal(current, &entry.path) || current.starts_with(&entry.path); + RuntimeStatus { + runtime_id: "claude-code", + isolation_tier: "L3", + expected: entry.path.display().to_string(), + actual: current.display().to_string(), + aligned, + hint: "Start Claude Code from the workspace project directory".into(), + } +} + +fn codex_runtime_status(entry: &WorkspaceEntry) -> RuntimeStatus { + let expected = entry.codex_home.display().to_string(); + let actual = codex_home_from_env().display().to_string(); + let aligned = workspace_paths_match(&entry.codex_home, &codex_home_from_env()); + RuntimeStatus { + runtime_id: "codex", + isolation_tier: "L2", + expected, + actual, + aligned, + hint: "Source ~/.config/agent-doctor/active-workspace.env before running codex".into(), + } +} + +fn openclaw_runtime_status(entry: &WorkspaceEntry) -> RuntimeStatus { + let expected = entry.openclaw_workspace.display().to_string(); + let actual = openclaw_agent_workspace(&entry.openclaw_agent_id) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "(not configured)".to_string()); + let aligned = openclaw_agent_workspace(&entry.openclaw_agent_id) + .map(|path| workspace_paths_match(&entry.openclaw_workspace, &path)) + .unwrap_or(false); + RuntimeStatus { + runtime_id: "openclaw", + isolation_tier: "L2", + expected, + actual, + aligned, + hint: "Ensure openclaw.json agents.list workspace matches this workspace".into(), + } +} + +pub(crate) fn write_active_env(name: &str, entry: &WorkspaceEntry) -> Result { + let path = active_env_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let profile_home = home_join(".hermes/profiles").join(&entry.hermes_profile); + let mut file = fs::File::create(&path).context("create active-workspace.env")?; + writeln!(file, "# Agent Doctor active workspace: {name}")?; + writeln!( + file, + "# Usage: set -a && source \"{}\" && set +a", + path.display() + )?; + writeln!(file, "AGENT_DOCTOR_WORKSPACE={name}")?; + writeln!(file, "AGENT_DOCTOR_PROJECT_ROOT={}", entry.path.display())?; + writeln!(file, "HERMES_HOME={}", profile_home.display())?; + writeln!(file, "CODEX_HOME={}", entry.codex_home.display())?; + writeln!(file, "OPENCLAW_AGENT_ID={}", entry.openclaw_agent_id)?; + writeln!( + file, + "OPENCLAW_WORKSPACE={}", + entry.openclaw_workspace.display() + )?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } + + Ok(path) +} + +fn unique_workspace_name(base: &str, workspaces: &BTreeMap) -> String { + if !workspaces.contains_key(base) { + return base.to_string(); + } + for index in 2..1000 { + let candidate = format!("{base}-{index}"); + if !workspaces.contains_key(&candidate) { + return candidate; + } + } + format!("{base}-{}", std::process::id()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unique_workspace_name_appends_suffix() { + let mut workspaces = BTreeMap::new(); + workspaces.insert( + "foo".to_string(), + WorkspaceEntry { + path: PathBuf::from("/tmp/foo"), + hermes_profile: "foo".into(), + codex_home: PathBuf::from("/tmp/foo/codex"), + openclaw_agent_id: "foo".into(), + openclaw_workspace: PathBuf::from("/tmp/foo/openclaw"), + }, + ); + assert_eq!(unique_workspace_name("foo", &workspaces), "foo-2"); + } +} diff --git a/crates/agent-doctor-core/src/workspace/path.rs b/crates/agent-doctor-core/src/workspace/path.rs new file mode 100644 index 0000000..418ba0e --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/path.rs @@ -0,0 +1,116 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +pub fn resolve_project_path(path: Option, prefer_git_root: bool) -> Result { + let start = path.unwrap_or_else(|| std::env::current_dir().expect("current directory")); + let absolute = if start.is_absolute() { + start + } else { + std::env::current_dir() + .context("current directory")? + .join(start) + }; + let canonical = absolute + .canonicalize() + .with_context(|| format!("invalid project path: {}", absolute.display()))?; + + if prefer_git_root { + if let Some(root) = find_git_root(&canonical) { + return Ok(root); + } + } + Ok(canonical) +} + +pub fn find_git_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".git").exists() { + return Some(current.canonicalize().unwrap_or(current)); + } + if !current.pop() { + return None; + } + } +} + +pub fn sanitize_workspace_name(input: &str) -> String { + let mut out = String::new(); + let mut prev_hyphen = false; + for ch in input.chars() { + let lower = ch.to_ascii_lowercase(); + if lower.is_ascii_alphanumeric() { + out.push(lower); + prev_hyphen = false; + } else if !prev_hyphen && !out.is_empty() { + out.push('-'); + prev_hyphen = true; + } + } + while out.ends_with('-') { + out.pop(); + } + if out.is_empty() { + "project".to_string() + } else { + out + } +} + +pub fn default_workspace_name(project_path: &Path) -> String { + project_path + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_workspace_name) + .unwrap_or_else(|| "project".to_string()) +} + +pub fn paths_equal(a: &Path, b: &Path) -> bool { + a.canonicalize() + .ok() + .zip(b.canonicalize().ok()) + .map(|(left, right)| left == right) + .unwrap_or(false) +} + +pub fn cwd() -> Result { + std::env::current_dir().context("current working directory") +} + +pub fn ensure_unique_name( + name: &str, + entries: &[(String, PathBuf)], + path: &Path, +) -> Result { + let base = sanitize_workspace_name(name); + if entries.iter().any(|(existing_name, existing_path)| { + existing_name == &base && !paths_equal(existing_path, path) + }) { + bail!("workspace name '{base}' is already used by another project"); + } + Ok(base) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn sanitize_workspace_name_strips_invalid_chars() { + assert_eq!(sanitize_workspace_name("My App!"), "my-app"); + assert_eq!(sanitize_workspace_name("---foo---"), "foo"); + } + + #[test] + fn find_git_root_walks_upward() { + let temp = TempDir::new().expect("tempdir"); + let repo = temp.path().join("repo"); + let nested = repo.join("packages").join("api"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir_all(repo.join(".git")).unwrap(); + assert_eq!(find_git_root(&nested), Some(repo.canonicalize().unwrap())); + } +} diff --git a/crates/agent-doctor-core/src/workspace/shell.rs b/crates/agent-doctor-core/src/workspace/shell.rs new file mode 100644 index 0000000..e0617bb --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/shell.rs @@ -0,0 +1,299 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::path::{paths_equal, resolve_project_path}; +use super::{ + load_workspaces, use_workspace_with_options, UseWorkspaceOptions, UseWorkspaceReport, + WorkspaceEntry, +}; + +const HOOK_FILE: &str = "hooks/workspace.zsh"; +const BASH_HOOK_FILE: &str = "hooks/workspace.bash"; +const FISH_HOOK_FILE: &str = "hooks/workspace.fish"; + +pub fn match_workspace_for_path( + path: Option, + prefer_git_root: bool, +) -> Result> { + let project_path = resolve_project_path(path, prefer_git_root)?; + let doc = load_workspaces()?; + Ok(doc + .workspaces + .iter() + .find(|(_, entry)| { + paths_equal(&entry.path, &project_path) || project_path.starts_with(&entry.path) + }) + .map(|(name, _)| name.clone())) +} + +pub fn enter_workspace( + path: Option, + prefer_git_root: bool, +) -> Result { + let project_path = resolve_project_path(path.clone(), prefer_git_root)?; + let Some(name) = match_workspace_for_path(path, prefer_git_root)? else { + anyhow::bail!( + "no workspace registered for {} — run `agent-doctor workspace init` first", + project_path.display() + ); + }; + + let doc = load_workspaces()?; + let switched = doc.active.as_deref() != Some(name.as_str()); + let use_report = use_workspace_with_options( + &name, + &UseWorkspaceOptions { + backup: true, + restart_gateways: false, + }, + )?; + + let zsh_eval = render_shell_env("zsh", &use_report)?; + let bash_eval = render_shell_env("bash", &use_report)?; + + Ok(EnterWorkspaceReport { + name, + path: use_report.path.clone(), + switched, + use_report, + zsh_eval, + bash_eval, + }) +} + +pub fn render_shell_env_for_name(name: &str, shell: &str) -> Result { + let doc = load_workspaces()?; + let entry = doc + .workspaces + .get(name) + .with_context(|| format!("workspace '{name}' not found"))?; + let report = UseWorkspaceReport { + name: name.to_string(), + path: entry.path.clone(), + env_file: super::active_env_path()?, + bindings: Vec::new(), + backup_id: None, + gateway_restarts: Vec::new(), + }; + render_shell_env(shell, &report) +} + +pub fn render_shell_env(shell: &str, report: &UseWorkspaceReport) -> Result { + let entry = load_workspaces()? + .workspaces + .get(&report.name) + .cloned() + .with_context(|| format!("workspace '{}' missing", report.name))?; + + let mut lines = Vec::new(); + match shell { + "zsh" | "bash" => { + lines.push(format!("export AGENT_DOCTOR_WORKSPACE='{}'", report.name)); + lines.push(format!( + "export AGENT_DOCTOR_PROJECT_ROOT='{}'", + entry.path.display() + )); + lines.push(format!( + "export HERMES_HOME='{}'", + entry.hermes_profile_home().display() + )); + lines.push(format!( + "export CODEX_HOME='{}'", + entry.codex_home.display() + )); + lines.push(format!( + "export OPENCLAW_AGENT_ID='{}'", + entry.openclaw_agent_id + )); + lines.push(format!( + "export OPENCLAW_WORKSPACE='{}'", + entry.openclaw_workspace.display() + )); + } + _ => { + lines.push(format!( + "set -a && source \"{}\" && set +a", + report.env_file.display() + )); + } + } + Ok(lines.join("\n")) +} + +pub fn install_zsh_hook() -> Result { + write_zsh_hook(&hook_file_path()?) +} + +pub fn install_bash_hook() -> Result { + write_bash_hook(&bash_hook_file_path()?) +} + +pub fn install_fish_hook() -> Result { + write_fish_hook(&fish_hook_file_path()?) +} + +fn write_zsh_hook(hook_path: &Path) -> Result { + if let Some(parent) = hook_path.parent() { + fs::create_dir_all(parent)?; + } + + let binary = agent_doctor_binary(); + + let contents = format!( + r#"# Agent Doctor workspace auto-align (zsh) +# Add to ~/.zshrc: source "{hook}" + +agent_doctor_workspace_chpwd() {{ + local ws + ws=$({binary} workspace match 2>/dev/null) || return 0 + [[ -z "$ws" ]] && return 0 + if [[ "${{AGENT_DOCTOR_WORKSPACE:-}}" != "$ws" ]]; then + eval "$({binary} workspace env --shell zsh --name "$ws" 2>/dev/null)" || return 0 + fi +}} + +if [[ -n "${{chpwd_functions[(r)agent_doctor_workspace_chpwd]:-}}" ]]; then + : +else + chpwd_functions+=(agent_doctor_workspace_chpwd) +fi +"#, + hook = hook_path.display(), + binary = binary, + ); + + fs::write(hook_path, contents).with_context(|| format!("write {}", hook_path.display()))?; + Ok(hook_path.to_path_buf()) +} + +fn write_bash_hook(hook_path: &Path) -> Result { + if let Some(parent) = hook_path.parent() { + fs::create_dir_all(parent)?; + } + + let binary = agent_doctor_binary(); + + let contents = format!( + r#"# Agent Doctor workspace auto-align (bash) +# Add to ~/.bashrc: source "{hook}" + +agent_doctor_workspace_prompt() {{ + local ws + ws=$({binary} workspace match 2>/dev/null) || return 0 + [[ -z "$ws" ]] && return 0 + if [[ "${{AGENT_DOCTOR_WORKSPACE:-}}" != "$ws" ]]; then + eval "$({binary} workspace env --shell bash --name "$ws" 2>/dev/null)" || return 0 + fi +}} + +if [[ ":${{PROMPT_COMMAND:-}}:" != *":agent_doctor_workspace_prompt:"* ]]; then + PROMPT_COMMAND="agent_doctor_workspace_prompt${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}" +fi +"#, + hook = hook_path.display(), + binary = binary, + ); + + fs::write(hook_path, contents).with_context(|| format!("write {}", hook_path.display()))?; + Ok(hook_path.to_path_buf()) +} + +fn agent_doctor_binary() -> String { + std::env::current_exe() + .ok() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "agent-doctor".to_string()) +} + +pub fn hook_file_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("agent-doctor").join(HOOK_FILE)) + .context("could not resolve config directory") +} + +pub fn bash_hook_file_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("agent-doctor").join(BASH_HOOK_FILE)) + .context("could not resolve config directory") +} + +pub fn fish_hook_file_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("agent-doctor").join(FISH_HOOK_FILE)) + .context("could not resolve config directory") +} + +pub fn render_direnv_envrc(name: &str) -> Result { + Ok(format!( + r#"# Agent Doctor workspace — allow with: direnv allow +if command -v agent-doctor >/dev/null 2>&1; then + eval "$(agent-doctor workspace env --shell bash --name {name})" +elif [ -f "$HOME/.config/agent-doctor/active-workspace.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$HOME/.config/agent-doctor/active-workspace.env" + set +a +fi +"#, + name = name, + )) +} + +pub fn write_direnv_envrc(name: &str) -> Result { + let doc = load_workspaces()?; + let entry = doc + .workspaces + .get(name) + .with_context(|| format!("workspace '{name}' not found"))?; + let envrc = entry.path.join(".envrc"); + let contents = render_direnv_envrc(name)?; + fs::write(&envrc, contents).with_context(|| format!("write {}", envrc.display()))?; + Ok(envrc) +} + +fn write_fish_hook(hook_path: &Path) -> Result { + if let Some(parent) = hook_path.parent() { + fs::create_dir_all(parent)?; + } + + let binary = agent_doctor_binary(); + + let contents = format!( + r#"# Agent Doctor workspace auto-align (fish) +# Add to ~/.config/fish/config.fish: source "{hook}" + +function __agent_doctor_workspace_align --on-variable PWD + set -l ws ({binary} workspace match 2>/dev/null) + if test -z "$ws" + return + end + if test "$AGENT_DOCTOR_WORKSPACE" != "$ws" + eval ({binary} workspace env --shell bash --name $ws 2>/dev/null) + end +end +"#, + hook = hook_path.display(), + binary = binary, + ); + + fs::write(hook_path, contents).with_context(|| format!("write {}", hook_path.display()))?; + Ok(hook_path.to_path_buf()) +} + +impl WorkspaceEntry { + pub(crate) fn hermes_profile_home(&self) -> PathBuf { + crate::adapters::util::home_join(".hermes/profiles").join(&self.hermes_profile) + } +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct EnterWorkspaceReport { + pub name: String, + pub path: PathBuf, + pub switched: bool, + pub use_report: UseWorkspaceReport, + pub zsh_eval: String, + pub bash_eval: String, +} diff --git a/crates/agent-doctor-core/src/workspace/snapshot.rs b/crates/agent-doctor-core/src/workspace/snapshot.rs new file mode 100644 index 0000000..ce90e95 --- /dev/null +++ b/crates/agent-doctor-core/src/workspace/snapshot.rs @@ -0,0 +1,165 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::WorkspaceEntry; + +const SKILLS_SNAPSHOT: &str = "snapshots/skills"; + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct SnapshotReport { + pub mcp_saved: bool, + pub mcp_applied: bool, + pub skills_saved: bool, + pub skills_applied: bool, +} + +pub fn snapshot_dir(data_root: &Path) -> PathBuf { + data_root.join("snapshots") +} + +pub fn save_workspace_snapshot(entry: &WorkspaceEntry, data_root: &Path) -> Result { + let mut report = SnapshotReport::default(); + let dir = snapshot_dir(data_root); + fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + + report.mcp_saved = save_mcp_snapshot(&entry.path, &dir.join("mcp.json"))?; + report.skills_saved = save_skills_snapshot(&entry.path, &dir.join(SKILLS_SNAPSHOT))?; + + Ok(report) +} + +pub fn apply_workspace_snapshot( + entry: &WorkspaceEntry, + data_root: &Path, +) -> Result { + let mut report = SnapshotReport::default(); + let dir = snapshot_dir(data_root); + + let mcp_snapshot = dir.join("mcp.json"); + if mcp_snapshot.exists() { + report.mcp_applied = apply_mcp_snapshot(&entry.path, &mcp_snapshot)?; + } + + let skills_snapshot = dir.join(SKILLS_SNAPSHOT); + if skills_snapshot.is_dir() { + report.skills_applied = apply_skills_snapshot(&entry.path, &skills_snapshot)?; + } + + Ok(report) +} + +fn save_mcp_snapshot(project_path: &Path, target: &Path) -> Result { + let project_mcp = project_path.join(".mcp.json"); + if project_mcp.exists() { + fs::copy(&project_mcp, target) + .with_context(|| format!("copy {} to {}", project_mcp.display(), target.display()))?; + return Ok(true); + } + + if !target.exists() { + fs::write(target, "{\n \"mcpServers\": {}\n}\n") + .with_context(|| format!("write {}", target.display()))?; + } + Ok(false) +} + +fn apply_mcp_snapshot(project_path: &Path, snapshot: &Path) -> Result { + let project_mcp = project_path.join(".mcp.json"); + if project_mcp.exists() { + return Ok(false); + } + fs::copy(snapshot, &project_mcp).with_context(|| { + format!( + "restore {} from {}", + project_mcp.display(), + snapshot.display() + ) + })?; + Ok(true) +} + +fn save_skills_snapshot(project_path: &Path, target_dir: &Path) -> Result { + let project_skills = project_path.join(".claude/skills"); + if !project_skills.is_dir() { + return Ok(false); + } + + if target_dir.exists() { + fs::remove_dir_all(target_dir).ok(); + } + copy_dir_recursive(&project_skills, target_dir)?; + Ok(true) +} + +fn apply_skills_snapshot(project_path: &Path, snapshot_dir: &Path) -> Result { + let project_skills = project_path.join(".claude/skills"); + if project_skills.exists() { + return Ok(false); + } + fs::create_dir_all(project_skills.parent().unwrap())?; + copy_dir_recursive(snapshot_dir, &project_skills)?; + Ok(true) +} + +fn copy_dir_recursive(from: &Path, to: &Path) -> Result<()> { + fs::create_dir_all(to).with_context(|| format!("create {}", to.display()))?; + for entry in fs::read_dir(from).with_context(|| format!("read {}", from.display()))? { + let entry = entry?; + let file_type = entry.file_type()?; + let dest = to.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&entry.path(), &dest)?; + } else { + fs::copy(entry.path(), &dest).with_context(|| { + format!("copy {} to {}", entry.path().display(), dest.display()) + })?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn mcp_snapshot_roundtrip() { + let temp = env::temp_dir().join(format!("ad-snapshot-{}", std::process::id())); + let _ = fs::remove_dir_all(&temp); + fs::create_dir_all(&temp).unwrap(); + + let project = temp.join("project"); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join(".mcp.json"), + r#"{"mcpServers":{"demo":{"command":"echo"}}}"#, + ) + .unwrap(); + + let data_root = temp.join("data"); + let entry = WorkspaceEntry { + path: project.clone(), + hermes_profile: "demo".into(), + codex_home: data_root.join("codex"), + openclaw_agent_id: "demo".into(), + openclaw_workspace: data_root.join("openclaw"), + }; + + let save = save_workspace_snapshot(&entry, &data_root).unwrap(); + assert!(save.mcp_saved); + + let other = temp.join("other-project"); + fs::create_dir_all(&other).unwrap(); + let mut entry2 = entry.clone(); + entry2.path = other.clone(); + + let apply = apply_workspace_snapshot(&entry2, &data_root).unwrap(); + assert!(apply.mcp_applied); + assert!(other.join(".mcp.json").exists()); + + let _ = fs::remove_dir_all(&temp); + } +} diff --git a/desktop/README.md b/desktop/README.md index f6bbd52..bc6d2c0 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -4,7 +4,8 @@ ## Features (MVP) -- System tray with **Show**, **Run doctor**, **Quit** +- System tray with **Show**, **Run doctor**, **Quit** (tooltip shows active workspace) +- Workspace picker: list and switch registered project workspaces - Small window listing discovered runtimes and company profile status - Hermes model preset switching and API key status - Foundation for future repair reports and guided fixes diff --git a/desktop/index.html b/desktop/index.html index 381c3d0..cf3fbac 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -90,6 +90,37 @@

配置预设

+
+ +

Loading workspaces…

+
+
+ + +
+ +
+

+
+