use crate::skill;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
User,
Project(PathBuf),
}
#[derive(Debug)]
enum Step {
ClaudeCodeMcp { scope: &'static str },
DesktopMcp { path: PathBuf },
Skill { path: PathBuf },
AlreadyDone { what: String },
}
const DESKTOP_SKILL_NOTE: &str = "\nThe Claude app keeps skills in its own store rather than on disk, so \
this one is\nadded once by hand: Settings → Skills → Add → Upload a skill, and choose";
impl Step {
fn describe(&self) -> String {
match self {
Step::ClaudeCodeMcp { scope } => {
format!("Claude Code register MCP server (--scope {scope})")
}
Step::DesktopMcp { path } => {
format!("Claude app add mcpServers.lucida to {}", tilde(path))
}
Step::Skill { path } => format!("skill write {}", tilde(path)),
Step::AlreadyDone { what } => format!("already done {what}"),
}
}
fn is_work(&self) -> bool {
!matches!(self, Step::AlreadyDone { .. })
}
}
pub fn run(scope: Scope, dry_run: bool, assume_yes: bool) -> Result<()> {
let exe = std::env::current_exe().context("finding this binary")?;
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
let clients = Clients::detect(&scope);
let steps = plan(&scope, &exe, &clients)?;
println!("Lucida {}", exe.display());
println!(
"Scope {}\n",
match &scope {
Scope::User => "user — every project on this machine".to_string(),
Scope::Project(dir) => format!("project — {}", dir.display()),
}
);
for step in &steps {
println!(" {}", step.describe());
}
if !steps.iter().any(Step::is_work) {
println!("\nNothing to do.");
return Ok(());
}
if dry_run {
println!("\n--dry-run, so nothing was changed.");
return Ok(());
}
if !assume_yes && !confirm()? {
println!("Nothing was changed.");
return Ok(());
}
println!();
for step in &steps {
apply(step, &exe, &scope)?;
}
println!(
"\nRestart Claude Code and the Claude app to pick this up — both read \
their server lists at startup."
);
if clients.desktop.is_some() {
println!("{DESKTOP_SKILL_NOTE}\n {}", tilde(&skill_path(&scope)));
}
Ok(())
}
pub struct Clients {
pub claude_code: bool,
pub desktop: Option<PathBuf>,
}
impl Clients {
fn detect(scope: &Scope) -> Self {
Self {
claude_code: which("claude").is_some(),
desktop: match scope {
Scope::User => desktop_config(),
Scope::Project(_) => None,
},
}
}
}
fn plan(scope: &Scope, exe: &Path, clients: &Clients) -> Result<Vec<Step>> {
let mut steps = Vec::new();
let has_claude_code = clients.claude_code;
if has_claude_code {
let scope_flag = match scope {
Scope::User => "user",
Scope::Project(_) => "project",
};
if claude_code_has_lucida(exe) {
steps.push(Step::AlreadyDone {
what: "Claude Code already registers this binary".into(),
});
} else {
steps.push(Step::ClaudeCodeMcp { scope: scope_flag });
}
}
let desktop = clients.desktop.clone();
if let Some(path) = &desktop {
if desktop_has_lucida(path, exe)? {
steps.push(Step::AlreadyDone {
what: "the Claude app already registers this binary".into(),
});
} else {
steps.push(Step::DesktopMcp { path: path.clone() });
}
}
if has_claude_code || desktop.is_some() {
steps.push(Step::Skill {
path: skill_path(scope),
});
}
if steps.is_empty() {
bail!(
"found neither Claude Code nor the Claude app on this machine.\n\n\
Any MCP client can run Lucida as a stdio server — see the README \
for the shape, and `lucida skill` prints the skill."
);
}
Ok(steps)
}
fn apply(step: &Step, exe: &Path, scope: &Scope) -> Result<()> {
match step {
Step::AlreadyDone { .. } => Ok(()),
Step::ClaudeCodeMcp { scope: flag } => {
let mut cmd = std::process::Command::new("claude");
cmd.args(["mcp", "add", "--scope", flag, "lucida", "--"]);
cmd.arg(exe).arg("mcp");
if let Scope::Project(dir) = scope {
cmd.current_dir(dir);
}
let status = cmd.status().context("running `claude mcp add`")?;
if !status.success() {
bail!("`claude mcp add` exited with {status}");
}
println!(" registered with Claude Code");
Ok(())
}
Step::DesktopMcp { path } => {
merge_desktop_config(path, exe)?;
println!(" added to {}", tilde(path));
Ok(())
}
Step::Skill { path } => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, skill::SKILL)
.with_context(|| format!("writing {}", path.display()))?;
println!(" wrote {}", tilde(path));
Ok(())
}
}
}
fn merge_desktop_config(path: &Path, exe: &Path) -> Result<()> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut config: Value = if text.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&text)
.with_context(|| format!("{} is not valid JSON", path.display()))?
};
if !config.is_object() {
bail!("{} does not contain a JSON object", path.display());
}
match config.get("mcpServers") {
None | Some(Value::Null) => {}
Some(value) if value.is_object() => {}
Some(other) => bail!(
"{}'s `mcpServers` is {} rather than an object, so Lucida cannot be \
added to it without discarding what is there.\n\n\
Correct or remove that key and run `lucida setup` again.",
path.display(),
json_kind(other)
),
}
config["mcpServers"]["lucida"] = json!({
"command": exe.to_string_lossy(),
"args": ["mcp"],
});
let mut body = serde_json::to_string_pretty(&config)?;
body.push('\n');
crate::config::write_replacing(path, &body, false)
}
fn json_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
fn claude_code_has_lucida(exe: &Path) -> bool {
std::process::Command::new("claude")
.args(["mcp", "get", "lucida"])
.output()
.ok()
.filter(|out| out.status.success())
.is_some_and(|out| String::from_utf8_lossy(&out.stdout).contains(&*exe.to_string_lossy()))
}
fn desktop_has_lucida(path: &Path, exe: &Path) -> Result<bool> {
let Ok(text) = std::fs::read_to_string(path) else {
return Ok(false);
};
let Ok(config) = serde_json::from_str::<Value>(&text) else {
return Ok(false);
};
Ok(config["mcpServers"]["lucida"]["command"].as_str() == Some(&exe.to_string_lossy()))
}
fn desktop_config() -> Option<PathBuf> {
let path = if cfg!(target_os = "macos") {
home()?.join("Library/Application Support/Claude/claude_desktop_config.json")
} else if cfg!(target_os = "windows") {
PathBuf::from(std::env::var_os("APPDATA")?).join("Claude/claude_desktop_config.json")
} else {
home()?.join(".config/Claude/claude_desktop_config.json")
};
path.is_file().then_some(path)
}
fn skill_path(scope: &Scope) -> PathBuf {
match scope {
Scope::User => home()
.unwrap_or_default()
.join(".claude/skills/lucida/SKILL.md"),
Scope::Project(dir) => dir.join(".claude/skills/lucida/SKILL.md"),
}
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn which(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
let pathext = cfg!(target_os = "windows").then(|| std::env::var("PATHEXT").unwrap_or_default());
let names = candidate_names(program, pathext.as_deref());
std::env::split_paths(&path).find_map(|dir| {
names
.iter()
.map(|name| dir.join(name))
.find(|candidate| candidate.is_file())
})
}
fn candidate_names(program: &str, pathext: Option<&str>) -> Vec<String> {
let Some(pathext) = pathext else {
return vec![program.to_string()];
};
let mut extensions: Vec<String> = pathext
.split(';')
.map(str::trim)
.filter(|ext| !ext.is_empty())
.map(str::to_ascii_lowercase)
.collect();
if extensions.is_empty() {
extensions = [".com", ".exe", ".bat", ".cmd"]
.iter()
.map(|ext| (*ext).to_string())
.collect();
}
let mut names = vec![program.to_string()];
names.extend(extensions.into_iter().map(|ext| match ext.strip_prefix('.') {
Some(bare) => format!("{program}.{bare}"),
None => format!("{program}.{ext}"),
}));
names
}
fn tilde(path: &Path) -> String {
match home() {
Some(home) => match path.strip_prefix(&home) {
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => path.display().to_string(),
},
None => path.display().to_string(),
}
}
fn confirm() -> Result<bool> {
use std::io::{BufRead, Write};
if !std::io::stdin().is_terminal() {
bail!(
"there is no terminal to confirm at.\n\n\
Run `lucida setup --yes` to proceed without asking, or \
`lucida setup --dry-run` to see the plan only."
);
}
eprint!("\nApply this? [y/N] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin().lock().read_line(&mut answer)?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_skill_is_planned_whenever_either_client_is_present() {
let exe = Path::new("/opt/lucida");
let config = PathBuf::from("/nonexistent/claude_desktop_config.json");
let cases = [
(true, true, true, "both clients"),
(true, false, true, "Claude Code only"),
(false, true, true, "the Claude app only"),
(false, false, false, "neither"),
];
for (claude_code, desktop, expect_skill, what) in cases {
let clients = Clients {
claude_code,
desktop: desktop.then(|| config.clone()),
};
let planned = plan(&Scope::User, exe, &clients);
if !claude_code && !desktop {
assert!(planned.is_err(), "{what}: must refuse, not plan nothing");
continue;
}
let steps = planned.unwrap_or_else(|e| panic!("{what}: {e}"));
let has_skill = steps.iter().any(|s| matches!(s, Step::Skill { .. }));
assert_eq!(has_skill, expect_skill, "{what}: skill step wrong");
}
}
#[test]
fn a_project_scope_run_does_not_reach_for_the_desktop_app() {
let clients = Clients::detect(&Scope::Project(PathBuf::from(".")));
assert!(clients.desktop.is_none());
}
#[test]
fn the_skill_lands_where_each_scope_expects_it() {
let user = skill_path(&Scope::User);
assert!(user.ends_with(".claude/skills/lucida/SKILL.md"), "{user:?}");
let project = skill_path(&Scope::Project(PathBuf::from("/tmp/proj")));
assert_eq!(
project,
PathBuf::from("/tmp/proj/.claude/skills/lucida/SKILL.md")
);
}
#[test]
fn merging_preserves_every_other_setting() {
let dir = std::env::temp_dir().join(format!("lucida-setup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("claude_desktop_config.json");
std::fs::write(
&path,
r#"{"coworkUserFilesPath":"/somewhere","preferences":{"theme":"dark"},
"mcpServers":{"other":{"command":"/bin/other","args":["mcp"]}}}"#,
)
.unwrap();
merge_desktop_config(&path, Path::new("/usr/local/bin/lucida")).unwrap();
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(after["coworkUserFilesPath"], "/somewhere");
assert_eq!(after["preferences"]["theme"], "dark");
assert_eq!(after["mcpServers"]["other"]["command"], "/bin/other");
assert_eq!(
after["mcpServers"]["lucida"]["command"],
"/usr/local/bin/lucida"
);
assert_eq!(after["mcpServers"]["lucida"]["args"][0], "mcp");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_config_with_no_servers_gains_the_key() {
let dir = std::env::temp_dir().join(format!("lucida-setup-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("claude_desktop_config.json");
std::fs::write(&path, r#"{"preferences":{}}"#).unwrap();
merge_desktop_config(&path, Path::new("/opt/lucida")).unwrap();
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(after["mcpServers"]["lucida"]["command"], "/opt/lucida");
assert!(after["preferences"].is_object());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_malformed_server_list_is_refused_rather_than_panicking() {
let dir = std::env::temp_dir().join(format!("lucida-setup-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (label, body) in [
("an array", r#"{"mcpServers":[]}"#),
("a string", r#"{"mcpServers":"none"}"#),
("a number", r#"{"mcpServers":3}"#),
] {
let path = dir.join("claude_desktop_config.json");
std::fs::write(&path, body).unwrap();
let error = merge_desktop_config(&path, Path::new("/opt/lucida"))
.expect_err(&format!("{label} was accepted"))
.to_string();
assert!(error.contains(label), "{error}");
assert!(error.contains("lucida setup"), "must say what to do: {error}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
}
let path = dir.join("claude_desktop_config.json");
std::fs::write(&path, r#"{"mcpServers":null}"#).unwrap();
merge_desktop_config(&path, Path::new("/opt/lucida")).unwrap();
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(after["mcpServers"]["lucida"]["command"], "/opt/lucida");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn merging_leaves_no_staging_file_behind() {
let dir = std::env::temp_dir().join(format!("lucida-setup-atomic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("claude_desktop_config.json");
std::fs::write(&path, r#"{"preferences":{"theme":"dark"}}"#).unwrap();
merge_desktop_config(&path, Path::new("/opt/lucida")).unwrap();
let left: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
assert_eq!(left, vec!["claude_desktop_config.json"], "{left:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_program_is_looked_for_under_every_executable_extension() {
assert_eq!(candidate_names("claude", None), vec!["claude"]);
let names = candidate_names("claude", Some(".COM;.EXE;.BAT;.CMD;.VBS"));
assert_eq!(names.first().unwrap(), "claude", "the bare name comes first");
for expected in ["claude.exe", "claude.cmd", "claude.bat", "claude.vbs"] {
assert!(names.contains(&expected.to_string()), "{names:?}");
}
for empty in ["", " ", ";;"] {
let names = candidate_names("claude", Some(empty));
assert!(names.contains(&"claude.exe".to_string()), "{names:?}");
assert!(names.contains(&"claude.cmd".to_string()), "{names:?}");
}
let names = candidate_names("claude", Some("EXE;CMD"));
assert!(names.contains(&"claude.exe".to_string()), "{names:?}");
assert!(!names.iter().any(|n| n.contains("..")), "{names:?}");
}
#[test]
fn detection_reports_absence_rather_than_inventing_a_path() {
assert!(which("a-program-that-is-not-installed-anywhere").is_none());
}
}