pub mod claude;
pub mod codex;
pub mod copilot;
pub mod opencode;
pub mod pi;
use anyhow::{Context, Result};
use console::style;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Agent {
Claude,
Codex,
Copilot,
OpenCode,
Pi,
}
impl Agent {
pub fn name(&self) -> &'static str {
match self {
Agent::Claude => "Claude Code",
Agent::Codex => "Codex",
Agent::Copilot => "Copilot CLI",
Agent::OpenCode => "OpenCode",
Agent::Pi => "pi",
}
}
}
#[derive(Debug)]
pub enum StatusCheck {
Installed,
NotInstalled,
Error(String),
}
#[derive(Debug)]
pub struct AgentCheck {
pub agent: Agent,
pub reason: &'static str,
pub status: StatusCheck,
}
pub fn check_all() -> Vec<AgentCheck> {
let mut results = Vec::new();
if let Some(reason) = claude::detect() {
let status = match claude::check() {
Ok(s) => s,
Err(e) => StatusCheck::Error(e.to_string()),
};
results.push(AgentCheck {
agent: Agent::Claude,
reason,
status,
});
}
if let Some(reason) = codex::detect() {
let status = match codex::check() {
Ok(s) => s,
Err(e) => StatusCheck::Error(e.to_string()),
};
results.push(AgentCheck {
agent: Agent::Codex,
reason,
status,
});
}
if let Some(reason) = copilot::detect() {
let status = match copilot::check() {
Ok(s) => s,
Err(e) => StatusCheck::Error(e.to_string()),
};
results.push(AgentCheck {
agent: Agent::Copilot,
reason,
status,
});
}
if let Some(reason) = pi::detect() {
let status = match pi::check() {
Ok(s) => s,
Err(e) => StatusCheck::Error(e.to_string()),
};
results.push(AgentCheck {
agent: Agent::Pi,
reason,
status,
});
}
if let Some(reason) = opencode::detect() {
let status = match opencode::check() {
Ok(s) => s,
Err(e) => StatusCheck::Error(e.to_string()),
};
results.push(AgentCheck {
agent: Agent::OpenCode,
reason,
status,
});
}
results
}
pub fn install(agent: Agent) -> Result<String> {
match agent {
Agent::Claude => claude::install(),
Agent::Codex => codex::install(),
Agent::Copilot => copilot::install(),
Agent::OpenCode => opencode::install(),
Agent::Pi => pi::install(),
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct SetupState {
#[serde(default)]
declined: BTreeSet<Agent>,
#[serde(default)]
declined_skills: BTreeSet<Agent>,
}
fn setup_state_path() -> Result<PathBuf> {
Ok(crate::state::store::get_state_dir()?.join("setup.json"))
}
fn load_setup_state() -> SetupState {
let Ok(path) = setup_state_path() else {
return SetupState::default();
};
if !path.exists() {
return SetupState::default();
}
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default()
}
fn save_setup_state(state: &SetupState) -> Result<()> {
let path = setup_state_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).context("Failed to create state directory")?;
}
let content = serde_json::to_string_pretty(state)?;
fs::write(&path, content + "\n")?;
Ok(())
}
pub fn is_declined(agent: Agent) -> bool {
load_setup_state().declined.contains(&agent)
}
fn mark_declined(agents: &[Agent]) -> Result<()> {
let mut state = load_setup_state();
for agent in agents {
state.declined.insert(*agent);
}
save_setup_state(&state)
}
fn mark_skills_declined(agents: &[Agent]) -> Result<()> {
let mut state = load_setup_state();
for agent in agents {
state.declined_skills.insert(*agent);
}
save_setup_state(&state)
}
pub(crate) fn print_description(prefix: &str) {
println!("{prefix} Status tracking shows agent activity in your tmux window list:");
println!("{prefix}");
println!(
"{prefix} {} 2:user-auth 🤖 3:refactor 💬 {}",
style("1:main*").reverse(),
style("4:dark-mode ✅").dim(),
);
println!("{prefix}");
println!("{prefix} 🤖 = working 💬 = waiting for input ✅ = done");
println!(
"{prefix} {}",
style("https://workmux.raine.dev/guide/status-tracking").dim()
);
}
fn confirm_install() -> Result<bool> {
let prompt = format!(
" Install status tracking hooks? {}{}{} ",
style("[").bold().cyan(),
style("Y/n").bold(),
style("]").bold().cyan(),
);
loop {
print!("{}", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let answer = input.trim().to_lowercase();
match answer.as_str() {
"" | "y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => println!(" {}", style("Please enter y or n").dim()),
}
}
}
fn print_install_result(agent: Agent, result: &Result<String>) {
match result {
Ok(msg) => println!(" {} {}", style("✔").green(), msg),
Err(e) => println!(" {} {}: {}", style("✗").red(), agent.name(), e),
}
}
fn install_agents(agents: &[&AgentCheck]) {
for check in agents {
let result = install(check.agent);
print_install_result(check.agent, &result);
}
}
pub fn prompt_wizard() -> Result<()> {
if !io::stdin().is_terminal() {
return Ok(());
}
if std::env::var("CI").is_ok() || std::env::var("WORKMUX_TEST").is_ok() {
return Ok(());
}
let checks = check_all();
let needs_hooks: Vec<_> = checks
.iter()
.filter(|c| matches!(c.status, StatusCheck::NotInstalled))
.filter(|c| !is_declined(c.agent))
.collect();
if needs_hooks.is_empty() {
return Ok(());
}
let dim = style("│").dim();
let corner_top = style("┌").dim();
if !needs_hooks.is_empty() {
println!();
println!("{} {}", corner_top, style("Status Tracking").bold().cyan());
println!("{}", dim);
for check in &needs_hooks {
println!(
"{} Detected {} ({})",
dim,
style(check.agent.name()).bold(),
check.reason
);
}
println!("{}", dim);
let dim_str = format!("{}", dim);
print_description(&dim_str);
println!("{}", dim);
if confirm_install()? {
install_agents(&needs_hooks);
} else {
let agents: Vec<_> = needs_hooks.iter().map(|c| c.agent).collect();
if let Err(e) = mark_declined(&agents) {
tracing::debug!(?e, "failed to save declined state");
}
}
}
{
let skill_agents: Vec<Agent> = checks
.iter()
.map(|c| c.agent)
.filter(|a| crate::skills::needs_install(*a))
.collect();
if !skill_agents.is_empty() {
println!("{}", dim);
println!("{} {}", dim, style("Skills").bold().cyan());
println!("{}", dim);
let skill_names: Vec<_> = crate::skills::BUNDLED_SKILLS
.iter()
.map(|s| s.name)
.collect();
println!(
"{} workmux includes skills: {}",
dim,
skill_names.join(", ")
);
println!(
"{} Learn more: {}",
dim,
style("https://workmux.raine.dev/guide/skills").dim()
);
println!("{}", dim);
if confirm_install_skills()? {
for agent in &skill_agents {
match crate::skills::install_skills(*agent) {
Ok(msg) => println!(" {}", msg),
Err(e) => println!(" {} {}: {}", style("✗").red(), agent.name(), e),
}
}
} else if let Err(e) = mark_skills_declined(&skill_agents) {
tracing::debug!(?e, "failed to save declined skills state");
}
}
}
println!();
Ok(())
}
fn confirm_install_skills() -> Result<bool> {
let prompt = format!(
" Install skills? {}{}{} ",
style("[").bold().cyan(),
style("Y/n").bold(),
style("]").bold().cyan(),
);
loop {
print!("{}", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let answer = input.trim().to_lowercase();
match answer.as_str() {
"" | "y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => println!(" {}", style("Please enter y or n").dim()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_name() {
assert_eq!(Agent::Claude.name(), "Claude Code");
assert_eq!(Agent::Codex.name(), "Codex");
assert_eq!(Agent::Copilot.name(), "Copilot CLI");
assert_eq!(Agent::OpenCode.name(), "OpenCode");
assert_eq!(Agent::Pi.name(), "pi");
}
#[test]
fn test_agent_serialization() {
assert_eq!(serde_json::to_string(&Agent::Claude).unwrap(), "\"claude\"");
assert_eq!(serde_json::to_string(&Agent::Codex).unwrap(), "\"codex\"");
assert_eq!(
serde_json::to_string(&Agent::Copilot).unwrap(),
"\"copilot\""
);
assert_eq!(
serde_json::to_string(&Agent::OpenCode).unwrap(),
"\"opencode\""
);
assert_eq!(serde_json::to_string(&Agent::Pi).unwrap(), "\"pi\"");
}
#[test]
fn test_agent_deserialization() {
let agent: Agent = serde_json::from_str("\"claude\"").unwrap();
assert_eq!(agent, Agent::Claude);
let agent: Agent = serde_json::from_str("\"codex\"").unwrap();
assert_eq!(agent, Agent::Codex);
let agent: Agent = serde_json::from_str("\"copilot\"").unwrap();
assert_eq!(agent, Agent::Copilot);
let agent: Agent = serde_json::from_str("\"opencode\"").unwrap();
assert_eq!(agent, Agent::OpenCode);
let agent: Agent = serde_json::from_str("\"pi\"").unwrap();
assert_eq!(agent, Agent::Pi);
}
#[test]
fn test_setup_state_default_is_empty() {
let state = SetupState::default();
assert!(state.declined.is_empty());
}
#[test]
fn test_setup_state_serialization_round_trip() {
let mut state = SetupState::default();
state.declined.insert(Agent::Claude);
let json = serde_json::to_string(&state).unwrap();
let deserialized: SetupState = serde_json::from_str(&json).unwrap();
assert!(deserialized.declined.contains(&Agent::Claude));
assert!(!deserialized.declined.contains(&Agent::OpenCode));
}
#[test]
fn test_setup_state_round_trip_multiple_agents() {
let mut state = SetupState::default();
state.declined.insert(Agent::Claude);
state.declined.insert(Agent::Codex);
state.declined.insert(Agent::OpenCode);
state.declined.insert(Agent::Pi);
let json = serde_json::to_string_pretty(&state).unwrap();
let deserialized: SetupState = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.declined.len(), 4);
assert!(deserialized.declined.contains(&Agent::Claude));
assert!(deserialized.declined.contains(&Agent::Codex));
assert!(deserialized.declined.contains(&Agent::OpenCode));
}
#[test]
fn test_setup_state_deserialize_empty_json() {
let deserialized: SetupState = serde_json::from_str("{}").unwrap();
assert!(deserialized.declined.is_empty());
}
}