Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ cargo run -p agent-doctor -- doctor --json
| `install <runtime>` | 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 <runtime>` | 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 |
Expand Down
1 change: 1 addition & 0 deletions cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
298 changes: 298 additions & 0 deletions cli/src/commands/workspace.rs
Original file line number Diff line number Diff line change
@@ -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<std::path::PathBuf>, name: Option<String>, 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<std::path::PathBuf>, 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<std::path::PathBuf>, 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<std::path::PathBuf>, 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()
);
}
}
Loading
Loading