mod edit;
mod paths;
#[cfg(test)]
mod tests;
pub use edit::{Action, Change};
pub use paths::Env;
use cyberbrain_core::{Result, Slash};
use edit::edit;
use serde::Serialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
pub const MANAGED_BY: &str = "cyberbrain";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Client {
ClaudeCode,
ClaudeDesktop,
Codex,
}
impl Client {
pub const ALL: [Client; 3] = [Client::ClaudeCode, Client::ClaudeDesktop, Client::Codex];
pub fn name(self) -> &'static str {
match self {
Client::ClaudeCode => "claude-code",
Client::ClaudeDesktop => "claude-desktop",
Client::Codex => "codex",
}
}
}
pub struct Options {
pub clients: Vec<Client>,
pub project: PathBuf,
pub store: PathBuf,
pub name: String,
pub undo: bool,
pub dry_run: bool,
pub env: Env,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
#[serde(serialize_with = "cyberbrain_core::path_serde::slash")]
pub binary: PathBuf,
#[serde(serialize_with = "cyberbrain_core::path_serde::slash")]
pub store: PathBuf,
pub name: String,
pub undo: bool,
pub dry_run: bool,
pub clients: Vec<ClientReport>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ClientReport {
pub client: &'static str,
pub found: bool,
pub changes: Vec<Change>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
}
impl ClientReport {
fn absent(client: Client, note: impl Into<String>) -> ClientReport {
ClientReport {
client: client.name(),
found: false,
changes: Vec::new(),
note: Some(note.into()),
snippet: None,
}
}
}
const EVENTS: [(&str, &str, &str); 6] = [
("SessionStart", "session-start", ""),
("UserPromptSubmit", "user-prompt-submit", ""),
("PreToolUse", "pre-tool-use", PRE_TOOL_MATCHER),
("PostToolUse", "post-tool-use", TOOL_MATCHER),
("Stop", "stop", ""),
("PreCompact", "pre-compact", ""),
];
const TOOL_MATCHER: &str = "Edit|Write|MultiEdit|NotebookEdit";
const PRE_TOOL_MATCHER: &str = "Edit|Write|MultiEdit|NotebookEdit|Bash|WebFetch";
pub fn run(opts: &Options) -> Result<Report> {
let binary = current_binary();
let store = absolute(&opts.store);
let wanted = if opts.clients.is_empty() {
Client::ALL.to_vec()
} else {
opts.clients.clone()
};
let mut clients = Vec::new();
for client in wanted {
let report = match client {
Client::ClaudeCode => claude_code(opts, &binary)?,
Client::ClaudeDesktop => claude_desktop(opts, &binary, &store)?,
Client::Codex => codex(opts, &binary, &store),
};
clients.push(report);
}
Ok(Report {
binary,
store,
name: opts.name.clone(),
undo: opts.undo,
dry_run: opts.dry_run,
clients,
})
}
fn claude_code(opts: &Options, binary: &Path) -> Result<ClientReport> {
let path = paths::claude_code(&opts.project);
if opts.undo && !path.exists() {
return Ok(ClientReport::absent(
Client::ClaudeCode,
format!("{} does not exist; nothing to undo", Slash(&path)),
));
}
let whose = "settings.json";
let change = edit(
&path,
"the hooks are per project, so this is the project you named",
opts.dry_run,
|root| {
let mut touched = false;
let mut changed = false;
{
let hooks = edit::object(root, "hooks", whose)?;
for (event, arg, matcher) in EVENTS {
let list = edit::array(hooks, event, whose)?;
let ours =
|v: &Value| v.get("_managedBy").and_then(Value::as_str) == Some(MANAGED_BY);
let before = list.iter().find(|v| ours(v)).cloned();
let had = before.is_some();
list.retain(|v| !ours(v));
if opts.undo {
touched |= had;
continue;
}
let entry = hook_entry(binary, matcher, arg);
changed |= before.as_ref() != Some(&entry);
touched = true;
list.push(entry);
}
hooks.retain(|_, v| !v.as_array().is_some_and(|a| a.is_empty()));
}
if root
.get("hooks")
.and_then(Value::as_object)
.is_some_and(|h| h.is_empty())
{
root.remove("hooks");
}
Ok(match (opts.undo, touched, changed) {
(true, true, _) => Action::Removed,
(true, false, _) => Action::NothingToUndo,
(false, _, true) => Action::Added,
(false, _, false) => Action::Unchanged,
})
},
)?;
Ok(ClientReport {
client: Client::ClaudeCode.name(),
found: true,
changes: vec![change],
note: None,
snippet: None,
})
}
fn hook_entry(binary: &Path, matcher: &str, arg: &str) -> Value {
json!({
"matcher": matcher,
"hooks": [{
"type": "command",
"command": binary.display().to_string(),
"args": ["hook", arg],
}],
"_managedBy": MANAGED_BY,
})
}
fn claude_desktop(opts: &Options, binary: &Path, store: &Path) -> Result<ClientReport> {
let places = paths::claude_desktop(&opts.env);
let candidates = paths::resolve(&places);
if candidates.is_empty() {
return Ok(ClientReport::absent(
Client::ClaudeDesktop,
"Claude Desktop is not installed for this user; nothing was written. Install it, \
then run this again."
.to_string(),
));
}
let entry = mcp_entry(binary, store);
let mut changes = Vec::new();
for candidate in &candidates {
let change = edit(&candidate.path, candidate.why, opts.dry_run, |root| {
let servers = edit::object(root, "mcpServers", "claude_desktop_config.json")?;
let before = servers.get(&opts.name).cloned();
if opts.undo {
servers.remove(&opts.name);
let empty = servers.is_empty();
if empty {
root.remove("mcpServers");
}
return Ok(if before.is_some() {
Action::Removed
} else {
Action::NothingToUndo
});
}
servers.insert(opts.name.clone(), entry.clone());
Ok(match before {
Some(b) if b == entry => Action::Unchanged,
Some(_) => Action::Updated,
None => Action::Added,
})
})?;
changes.push(change);
}
let note = (changes.len() > 1).then(|| {
"This machine has more than one Claude Desktop installation, and only one of them \
reads the file its own Edit Config button opens. Both were written, so it works \
either way."
.to_string()
});
Ok(ClientReport {
client: Client::ClaudeDesktop.name(),
found: true,
changes,
note,
snippet: None,
})
}
fn mcp_entry(binary: &Path, store: &Path) -> Value {
json!({
"command": binary.display().to_string(),
"args": ["mcp", "--store", store.display().to_string()],
})
}
fn codex(opts: &Options, binary: &Path, store: &Path) -> ClientReport {
let Some(config) = paths::codex_config(&opts.env) else {
return ClientReport::absent(
Client::Codex,
"Codex CLI has not been run on this machine (no ~/.codex); nothing to set up."
.to_string(),
);
};
let b = binary.display();
let s = store.display();
let snippet = format!(
"codex mcp add {name} -- {b} mcp --store {s}\n\n\
or, in {config}:\n\n\
[mcp_servers.{name}]\n\
command = \"{b}\"\n\
args = [\"mcp\", \"--store\", \"{s}\"]\n",
name = opts.name,
config = Slash(&config),
);
ClientReport {
client: Client::Codex.name(),
found: true,
changes: Vec::new(),
note: Some(format!(
"{} is yours, with your comments in it, and this command does not rewrite it. \
Paste one of these instead.",
Slash(&config)
)),
snippet: Some(snippet),
}
}
fn current_binary() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok().or(Some(p)))
.map(plain)
.unwrap_or_else(|| PathBuf::from("cyberbrain"))
}
fn absolute(p: &Path) -> PathBuf {
plain(p.canonicalize().unwrap_or_else(|_| p.to_path_buf()))
}
#[cfg(windows)]
fn plain(p: PathBuf) -> PathBuf {
match p.to_str().and_then(without_verbatim_prefix) {
Some(s) => PathBuf::from(s),
None => p,
}
}
#[cfg(not(windows))]
fn plain(p: PathBuf) -> PathBuf {
p
}
#[cfg(any(windows, test))]
fn without_verbatim_prefix(path: &str) -> Option<String> {
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
return Some(format!(r"\\{rest}"));
}
let rest = path.strip_prefix(r"\\?\")?;
let b = rest.as_bytes();
match (b.first(), b.get(1), b.get(2)) {
(Some(d), Some(b':'), Some(b'\\')) if d.is_ascii_alphabetic() => Some(rest.to_string()),
_ => None,
}
}