use anyhow::{Context, Result};
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use super::StatusCheck;
use crate::agent_setup::hooks;
const PLUGIN_JSON: &str = include_str!("../../.claude-plugin/plugin.json");
fn claude_dir_from_config(home: PathBuf, config_dir: Option<std::ffi::OsString>) -> PathBuf {
config_dir
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".claude"))
}
fn claude_dir() -> Option<PathBuf> {
home::home_dir().map(|home| claude_dir_from_config(home, std::env::var_os("CLAUDE_CONFIG_DIR")))
}
fn settings_path() -> Option<PathBuf> {
claude_dir().map(|d| d.join("settings.json"))
}
pub fn detect() -> Option<&'static str> {
if claude_dir().is_some_and(|d| d.is_dir()) {
return Some("found Claude config directory");
}
None
}
pub fn check() -> Result<StatusCheck> {
let Some(path) = settings_path() else {
return Ok(StatusCheck::NotInstalled);
};
if !path.exists() {
return Ok(StatusCheck::NotInstalled);
}
let content = fs::read_to_string(&path).context("Failed to read ~/.claude/settings.json")?;
let settings: Value =
serde_json::from_str(&content).context("~/.claude/settings.json is not valid JSON")?;
Ok(check_settings(&settings))
}
fn check_settings(settings: &Value) -> StatusCheck {
if let Some(plugins) = settings.get("enabledPlugins").and_then(|v| v.as_object())
&& plugins.keys().any(|k| k.starts_with("workmux-status@"))
{
return StatusCheck::Installed;
}
if hooks::has_workmux_hooks(settings) {
return StatusCheck::Installed;
}
StatusCheck::NotInstalled
}
pub fn uninstall() -> Result<String> {
let Some(path) = settings_path() else {
return Ok("Claude Code config dir not found, nothing to uninstall".to_string());
};
uninstall_at(path)
}
fn uninstall_at(path: PathBuf) -> Result<String> {
if !path.exists() {
return Ok("No Claude Code settings.json found".to_string());
}
let content = fs::read_to_string(&path)?;
let mut settings: Value = serde_json::from_str(&content)?;
let removed = hooks::remove_workmux_hooks(&mut settings);
let plugins_removed = hooks::remove_workmux_plugins(&mut settings);
hooks::remove_empty_hooks_wrapper(&mut settings);
if removed || plugins_removed {
fs::write(&path, serde_json::to_string_pretty(&settings)? + "\n")?;
Ok(format!("Removed workmux hooks from {}", path.display()))
} else {
Ok("No workmux hooks found in Claude Code settings".to_string())
}
}
fn load_hooks_from_plugin() -> Result<Value> {
let plugin: Value =
serde_json::from_str(PLUGIN_JSON).expect("embedded plugin.json is valid JSON");
plugin
.get("hooks")
.cloned()
.ok_or_else(|| anyhow::anyhow!("plugin.json missing hooks key"))
}
pub fn install() -> Result<String> {
let path =
settings_path().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
let mut settings: Value = if path.exists() {
let content =
fs::read_to_string(&path).context("Failed to read ~/.claude/settings.json")?;
serde_json::from_str(&content).context("~/.claude/settings.json is not valid JSON")?
} else {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).context("Failed to create ~/.claude/ directory")?;
}
Value::Object(serde_json::Map::new())
};
let hooks_to_add = load_hooks_from_plugin()?;
let settings_obj = settings
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json root is not an object"))?;
if !settings_obj.contains_key("hooks") {
settings_obj.insert("hooks".to_string(), Value::Object(serde_json::Map::new()));
}
let existing_hooks = settings_obj
.get_mut("hooks")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| anyhow::anyhow!("settings.json hooks is not an object"))?;
let hooks_map = hooks_to_add.as_object().expect("plugin hooks is an object");
for (event, hook_groups) in hooks_map {
let Some(new_groups) = hook_groups.as_array() else {
continue;
};
if let Some(existing_groups) = existing_hooks.get_mut(event) {
let arr = existing_groups
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("hooks.{event} is not an array"))?;
for group in new_groups {
if !arr.contains(group) {
arr.push(group.clone());
}
}
} else {
existing_hooks.insert(event.clone(), hook_groups.clone());
}
}
let output = serde_json::to_string_pretty(&settings)?;
fs::write(&path, output + "\n").context("Failed to write ~/.claude/settings.json")?;
Ok(format!("Installed hooks to {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_load_hooks_from_plugin() {
let hooks = load_hooks_from_plugin().unwrap();
let obj = hooks.as_object().unwrap();
assert!(obj.contains_key("UserPromptSubmit"));
assert!(obj.contains_key("Notification"));
assert!(obj.contains_key("PostToolUse"));
assert!(obj.contains_key("Stop"));
}
#[test]
fn test_claude_dir_respects_env() {
let path = claude_dir_from_config(
PathBuf::from("/home/test"),
Some(std::ffi::OsString::from("/tmp/workmux-test-claude-cfg")),
);
assert_eq!(path, PathBuf::from("/tmp/workmux-test-claude-cfg"));
}
#[test]
fn test_claude_dir_defaults_to_home() {
let path = claude_dir_from_config(PathBuf::from("/home/test"), None);
assert_eq!(path, PathBuf::from("/home/test/.claude"));
}
#[test]
fn test_merge_into_empty_settings() {
let mut settings = json!({});
let hooks_to_add = load_hooks_from_plugin().unwrap();
let hooks_map = hooks_to_add.as_object().unwrap();
let settings_obj = settings.as_object_mut().unwrap();
settings_obj.insert("hooks".to_string(), Value::Object(serde_json::Map::new()));
let existing_hooks = settings_obj
.get_mut("hooks")
.unwrap()
.as_object_mut()
.unwrap();
for (event, hook_groups) in hooks_map {
existing_hooks.insert(event.clone(), hook_groups.clone());
}
let hooks = settings.get("hooks").unwrap().as_object().unwrap();
assert_eq!(hooks.len(), 4);
}
#[test]
fn test_merge_deduplicates() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "workmux set-window-status done"
}]
}]
}
});
let hooks_to_add = load_hooks_from_plugin().unwrap();
let hooks_map = hooks_to_add.as_object().unwrap();
let existing_hooks = settings.get_mut("hooks").unwrap().as_object_mut().unwrap();
for (event, hook_groups) in hooks_map {
let new_groups = hook_groups.as_array().unwrap();
if let Some(existing_groups) = existing_hooks.get_mut(event) {
let arr = existing_groups.as_array_mut().unwrap();
for group in new_groups {
if !arr.contains(group) {
arr.push(group.clone());
}
}
} else {
existing_hooks.insert(event.clone(), hook_groups.clone());
}
}
let stop = settings
.get("hooks")
.unwrap()
.get("Stop")
.unwrap()
.as_array()
.unwrap();
assert_eq!(stop.len(), 1);
}
#[test]
fn test_check_settings_empty() {
let settings = json!({});
assert!(matches!(
check_settings(&settings),
StatusCheck::NotInstalled
));
}
#[test]
fn test_check_settings_plugin_enabled() {
let settings = json!({
"enabledPlugins": {
"workmux-status@workmux": true
}
});
assert!(matches!(check_settings(&settings), StatusCheck::Installed));
}
#[test]
fn test_check_settings_plugin_disabled() {
let settings = json!({
"enabledPlugins": {
"workmux-status@workmux": false
}
});
assert!(matches!(check_settings(&settings), StatusCheck::Installed));
}
#[test]
fn test_check_settings_plugin_different_version() {
let settings = json!({
"enabledPlugins": {
"workmux-status@1.2.3": true
}
});
assert!(matches!(check_settings(&settings), StatusCheck::Installed));
}
#[test]
fn test_check_settings_other_plugins_only() {
let settings = json!({
"enabledPlugins": {
"some-other-plugin@1.0": true
}
});
assert!(matches!(
check_settings(&settings),
StatusCheck::NotInstalled
));
}
#[test]
fn test_check_settings_hooks_installed() {
let settings = json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "workmux set-window-status done"
}]
}]
}
});
assert!(matches!(check_settings(&settings), StatusCheck::Installed));
}
#[test]
fn test_check_settings_both_plugin_and_hooks() {
let settings = json!({
"enabledPlugins": {
"workmux-status@workmux": true
},
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "workmux set-window-status done"
}]
}]
}
});
assert!(matches!(check_settings(&settings), StatusCheck::Installed));
}
#[test]
fn test_merge_preserves_existing_hooks() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff"
}]
}]
}
});
let hooks_to_add = load_hooks_from_plugin().unwrap();
let hooks_map = hooks_to_add.as_object().unwrap();
let existing_hooks = settings.get_mut("hooks").unwrap().as_object_mut().unwrap();
for (event, hook_groups) in hooks_map {
let new_groups = hook_groups.as_array().unwrap();
if let Some(existing_groups) = existing_hooks.get_mut(event) {
let arr = existing_groups.as_array_mut().unwrap();
for group in new_groups {
if !arr.contains(group) {
arr.push(group.clone());
}
}
} else {
existing_hooks.insert(event.clone(), hook_groups.clone());
}
}
let stop = settings
.get("hooks")
.unwrap()
.get("Stop")
.unwrap()
.as_array()
.unwrap();
assert_eq!(stop.len(), 2);
let hooks = settings.get("hooks").unwrap().as_object().unwrap();
assert_eq!(hooks.len(), 4);
}
#[test]
fn test_uninstall_no_settings_file() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.json");
let result = uninstall_at(settings_path).unwrap();
assert!(result.contains("No Claude Code settings.json"));
}
#[test]
fn test_uninstall_no_hooks_present() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.json");
std::fs::write(&settings_path, r#"{"someSetting": true}"#).unwrap();
let result = uninstall_at(settings_path).unwrap();
assert!(result.contains("No workmux hooks found"));
}
#[test]
fn test_uninstall_removes_hooks_only() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.json");
std::fs::write(
&settings_path,
r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"workmux set-window-status done"}]},{"hooks":[{"type":"command","command":"afplay /System/Library/Sounds/Glass.aiff"}]}]}}"#,
)
.unwrap();
let result = uninstall_at(settings_path.clone()).unwrap();
assert!(result.contains("Removed workmux hooks"), "result: {result}");
let content = std::fs::read_to_string(&settings_path).unwrap();
let settings: Value = serde_json::from_str(&content).unwrap();
let stop = settings["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 1);
assert!(
stop[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("Glass")
);
}
#[test]
fn test_uninstall_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.json");
std::fs::write(
&settings_path,
r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"workmux set-window-status done"}]}]}}"#,
)
.unwrap();
let result1 = uninstall_at(settings_path.clone()).unwrap();
assert!(result1.contains("Removed workmux hooks"));
let result2 = uninstall_at(settings_path).unwrap();
assert!(result2.contains("No workmux hooks found"));
}
}