diff --git a/.changepacks/changepack_log_rust_only_install.json b/.changepacks/changepack_log_rust_only_install.json new file mode 100644 index 00000000..00d7079c --- /dev/null +++ b/.changepacks/changepack_log_rust_only_install.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "Add `devup-mcp --install`: copy the binary to a fixed per-user path and register it in opencode, Claude Code and Codex without a launcher script. Default write root is now the enclosing git checkout instead of the start directory.", + "date": "2026-10-20T00:00:00+09:00" +} diff --git a/Cargo.lock b/Cargo.lock index 0ddde4cc..fb1e58a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -764,6 +764,7 @@ dependencies = [ "syn 2.0.119", "tokio", "tokio-tungstenite", + "toml_edit", "tracing-subscriber", "url", ] @@ -3677,6 +3678,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -3689,6 +3691,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index b3736053..6cc720e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ sha2 = "0.11" subtle = "2" syn = { version = "2.0.119", features = ["full"] } tokio = { version = "1", features = ["full"] } +toml_edit = "0.25" # The version axum's `ws` feature already builds, so the bridge relay's client # and the tests' stand-in plugin add no second WebSocket implementation. tokio-tungstenite = "0.29" diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index d498ce32..d7624b41 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -35,6 +35,7 @@ serde_json.workspace = true sha2.workspace = true syn.workspace = true tokio.workspace = true +toml_edit.workspace = true tracing-subscriber.workspace = true url.workspace = true diff --git a/crates/devup-mcp/src/install.rs b/crates/devup-mcp/src/install.rs new file mode 100644 index 00000000..a8b8f1c8 --- /dev/null +++ b/crates/devup-mcp/src/install.rs @@ -0,0 +1,317 @@ +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use serde::Serialize; +use serde_json::{Map, Value, json}; + +const SERVER_NAME: &str = "devup-mcp"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallReport { + pub binary: BinaryReport, + pub clients: Vec, + pub next: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryReport { + pub path: PathBuf, + pub action: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientReport { + pub client: &'static str, + pub config: PathBuf, + pub action: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +pub fn home_dir() -> anyhow::Result { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .context("Could not determine the home directory to install into.") +} + +/// Outside every package manager directory, so the release check keeps this +/// copy current (`self_update` treats it as `owned`). +pub fn default_binary_path(home: &Path) -> PathBuf { + if cfg!(windows) { + home.join("AppData") + .join("Local") + .join("devup-mcp") + .join("bin") + .join("devup-mcp.exe") + } else { + home.join(".local").join("bin").join("devup-mcp") + } +} + +pub fn install() -> anyhow::Result { + let home = home_dir()?; + let source = + std::env::current_exe().context("Could not locate the running devup-mcp binary.")?; + install_at(&home, &source, &default_binary_path(&home)) +} + +pub fn install_at(home: &Path, source: &Path, target: &Path) -> anyhow::Result { + let binary = BinaryReport { + path: target.to_path_buf(), + action: copy_binary(source, target)?, + }; + let command = target.to_string_lossy().into_owned(); + Ok(InstallReport { + binary, + clients: vec![ + register_opencode(home, &command), + register_claude_code(home, &command), + register_codex(home, &command), + ], + next: "Restart each client, or reconnect its MCP servers, so it starts the installed binary.", + }) +} + +fn copy_binary(source: &Path, target: &Path) -> anyhow::Result<&'static str> { + if let (Ok(a), Ok(b)) = (dunce::canonicalize(source), dunce::canonicalize(target)) + && a == b + { + return Ok("already-installed"); + } + let bytes = + fs::read(source).with_context(|| format!("Could not read {}.", source.display()))?; + if fs::read(target).is_ok_and(|existing| existing == bytes) { + return Ok("unchanged"); + } + let parent = target + .parent() + .context("The install path has no parent directory.")?; + fs::create_dir_all(parent) + .with_context(|| format!("Could not create {}.", parent.display()))?; + let staged = sibling(target, ".install"); + fs::write(&staged, &bytes).with_context(|| format!("Could not write {}.", staged.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?; + } + if target.exists() { + // A running image cannot be overwritten on Windows but can be renamed, + // so a client still holding the old binary does not block the install. + let previous = sibling(target, ".previous"); + match fs::remove_file(&previous) { + Err(error) if error.kind() != io::ErrorKind::NotFound => { + return Err(error) + .with_context(|| format!("Could not remove {}.", previous.display())); + } + _ => {} + } + fs::rename(target, &previous) + .with_context(|| format!("Could not move aside {}.", target.display()))?; + } + fs::rename(&staged, target) + .with_context(|| format!("Could not place {}.", target.display()))?; + Ok("copied") +} + +fn register_opencode(home: &Path, command: &str) -> ClientReport { + let dir = home.join(".config").join("opencode"); + let config = ["opencode.jsonc", "opencode.json"] + .iter() + .map(|name| dir.join(name)) + .find(|path| path.is_file()) + .unwrap_or_else(|| dir.join("opencode.json")); + if !dir.is_dir() { + return skipped("opencode", config, "opencode is not set up for this user."); + } + let result = update_json(&config, |root| { + let entry = object_at(object_at(root, "mcp")?, SERVER_NAME)?; + entry.insert("type".into(), json!("local")); + entry.insert("command".into(), json!([command])); + entry.insert("enabled".into(), json!(true)); + Ok(()) + }); + report("opencode", config, result) +} + +fn register_claude_code(home: &Path, command: &str) -> ClientReport { + let config = home.join(".claude.json"); + if !config.is_file() && !home.join(".claude").is_dir() { + return skipped( + "claude-code", + config, + "Claude Code is not set up for this user.", + ); + } + let result = update_json(&config, |root| { + let entry = object_at(object_at(root, "mcpServers")?, SERVER_NAME)?; + entry.insert("type".into(), json!("stdio")); + entry.insert("command".into(), json!(command)); + entry.insert("args".into(), json!([])); + Ok(()) + }); + report("claude-code", config, result) +} + +fn register_codex(home: &Path, command: &str) -> ClientReport { + let dir = home.join(".codex"); + let config = dir.join("config.toml"); + if !dir.is_dir() { + return skipped("codex", config, "Codex is not set up for this user."); + } + let result = update_codex(&config, command); + report("codex", config, result) +} + +fn update_codex(path: &Path, command: &str) -> anyhow::Result<&'static str> { + let original = read_optional(path)?; + let mut document: toml_edit::DocumentMut = original + .as_deref() + .unwrap_or("") + .parse() + .with_context(|| format!("{} is not valid TOML.", path.display()))?; + if !document.contains_key("mcp_servers") { + let mut servers = toml_edit::Table::new(); + servers.set_implicit(true); + document.insert("mcp_servers", toml_edit::Item::Table(servers)); + } + let servers = document["mcp_servers"] + .as_table_mut() + .context("`mcp_servers` in the Codex config is not a table.")?; + let entry = servers + .entry(SERVER_NAME) + .or_insert(toml_edit::table()) + .as_table_mut() + .context("`mcp_servers.devup-mcp` in the Codex config is not a table.")?; + let mut changed = false; + if entry.get("command").and_then(toml_edit::Item::as_str) != Some(command) { + entry.insert("command", toml_edit::value(command)); + changed = true; + } + let args_empty = entry + .get("args") + .and_then(toml_edit::Item::as_array) + .is_some_and(toml_edit::Array::is_empty); + if !args_empty { + entry.insert("args", toml_edit::value(toml_edit::Array::new())); + changed = true; + } + if !changed { + return Ok("unchanged"); + } + write_config(path, original.as_deref(), &document.to_string()) +} + +fn update_json( + path: &Path, + edit: impl FnOnce(&mut Map) -> anyhow::Result<()>, +) -> anyhow::Result<&'static str> { + let original = read_optional(path)?; + let mut root: Value = match original.as_deref() { + Some(text) if !text.trim().is_empty() => serde_json::from_str(text).with_context(|| { + format!( + "{} is not plain JSON (comments or trailing commas?). Add the devup-mcp entry by hand.", + path.display() + ) + })?, + _ => json!({}), + }; + let before = root.clone(); + edit( + root.as_object_mut() + .context("The config's top level is not a JSON object.")?, + )?; + if root == before { + return Ok("unchanged"); + } + let contents = format!("{}\n", serde_json::to_string_pretty(&root)?); + write_config(path, original.as_deref(), &contents) +} + +fn object_at<'a>( + map: &'a mut Map, + key: &str, +) -> anyhow::Result<&'a mut Map> { + map.entry(key) + .or_insert_with(|| json!({})) + .as_object_mut() + .with_context(|| format!("`{key}` in the config is not an object.")) +} + +fn read_optional(path: &Path) -> anyhow::Result> { + match fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("Could not read {}.", path.display())), + } +} + +/// The first edit keeps the untouched original beside the config, so the +/// pre-devup-mcp state is always one rename away. +fn write_config( + path: &Path, + original: Option<&str>, + contents: &str, +) -> anyhow::Result<&'static str> { + if let Some(original) = original { + let backup = sibling(path, ".devup-mcp.bak"); + if !backup.exists() { + fs::write(&backup, original) + .with_context(|| format!("Could not write {}.", backup.display()))?; + } + } else if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let staged = sibling(path, ".devup-mcp.tmp"); + fs::write(&staged, contents) + .with_context(|| format!("Could not write {}.", staged.display()))?; + fs::rename(&staged, path).with_context(|| format!("Could not replace {}.", path.display()))?; + Ok(if original.is_some() { + "updated" + } else { + "created" + }) +} + +fn sibling(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(suffix); + path.with_file_name(name) +} + +fn skipped(client: &'static str, config: PathBuf, reason: &str) -> ClientReport { + ClientReport { + client, + config, + action: "skipped", + reason: Some(reason.to_owned()), + } +} + +fn report( + client: &'static str, + config: PathBuf, + result: anyhow::Result<&'static str>, +) -> ClientReport { + match result { + Ok(action) => ClientReport { + client, + config, + action, + reason: None, + }, + Err(error) => ClientReport { + client, + config, + action: "failed", + reason: Some(format!("{error:#}")), + }, + } +} diff --git a/crates/devup-mcp/src/lib.rs b/crates/devup-mcp/src/lib.rs index f6ca1b15..7a855c04 100644 --- a/crates/devup-mcp/src/lib.rs +++ b/crates/devup-mcp/src/lib.rs @@ -1,4 +1,5 @@ pub mod asset_batches; +pub mod install; pub mod server; pub mod skills; @@ -115,6 +116,7 @@ pub enum CliAction { Version, SelfCheck, MergeAssetBatches(Vec), + Install, InstallSkills(SkillCommandConfig), CheckSkills(SkillCommandConfig), Serve(ServerConfig), @@ -163,6 +165,9 @@ where Some("--self-check") if no_other_options_yet && arguments.peek().is_none() => { return Ok(CliAction::SelfCheck); } + Some("--install") if no_other_options_yet && arguments.peek().is_none() => { + return Ok(CliAction::Install); + } Some("--merge-asset-batches") if no_other_options_yet => { let paths: Vec<_> = arguments.map(PathBuf::from).collect(); anyhow::ensure!( @@ -314,7 +319,7 @@ where "--skill-dir requires --install-skills or --check-skills." ); if roots.is_empty() { - roots.push(std::env::current_dir()?); + roots.push(default_write_root(&std::env::current_dir()?)); } Ok(CliAction::Serve(ServerConfig { allowed_write_roots: roots, @@ -325,6 +330,18 @@ where })) } +/// Default write root without `--allow-write-root`: the enclosing git checkout +/// (nearest ancestor with `.git`, directory or worktree file), so a host started +/// in `apps/front` can still write the repository's `.devup/`. Falls back to +/// `start` outside a checkout. +pub fn default_write_root(start: &std::path::Path) -> PathBuf { + start + .ancestors() + .find(|directory| directory.join(".git").exists()) + .unwrap_or(start) + .to_path_buf() +} + pub fn self_check() -> SelfCheckReport { let credential_ok = devup_mcp_figma::KeyringCredentialStore::probe().is_ok(); let (env_client_id, env_client_secret, env_client_name) = env_figma_client_credentials(); diff --git a/crates/devup-mcp/src/main.rs b/crates/devup-mcp/src/main.rs index 1aea5fc8..7ae5f891 100644 --- a/crates/devup-mcp/src/main.rs +++ b/crates/devup-mcp/src/main.rs @@ -9,6 +9,13 @@ async fn main() -> anyhow::Result<()> { ); return Ok(()); } + if action == devup_mcp::CliAction::Install { + println!( + "{}", + serde_json::to_string_pretty(&devup_mcp::install::install()?)? + ); + return Ok(()); + } if action == devup_mcp::CliAction::SelfCheck { println!("{}", serde_json::to_string(&devup_mcp::self_check())?); return Ok(()); diff --git a/crates/devup-mcp/tests/cli.rs b/crates/devup-mcp/tests/cli.rs index fefa5c7d..26accea4 100644 --- a/crates/devup-mcp/tests/cli.rs +++ b/crates/devup-mcp/tests/cli.rs @@ -1,6 +1,9 @@ use std::{ffi::OsString, fs, process::Command}; -use devup_mcp::{CliAction, ClientCredentialSource, parse_cli_args, resolve_figma_direct_config}; +use devup_mcp::{ + CliAction, ClientCredentialSource, default_write_root, parse_cli_args, + resolve_figma_direct_config, +}; #[path = "../build_identity.rs"] mod build_identity; @@ -234,13 +237,42 @@ fn skill_commands_accept_repeated_explicit_dirs_and_reject_mixed_modes() -> anyh Ok(()) } +#[test] +fn default_write_root_is_the_enclosing_checkout() -> anyhow::Result<()> { + let base = std::env::temp_dir().join(format!("devup-mcp-cli-checkout-{}", std::process::id())); + let repo = base.join("repo"); + let nested = repo.join("apps").join("front"); + let worktree = base.join("worktree"); + let worktree_nested = worktree.join("src"); + let loose = base.join("loose").join("deeper"); + fs::create_dir_all(repo.join(".git"))?; + fs::create_dir_all(&nested)?; + fs::create_dir_all(&worktree_nested)?; + fs::write(worktree.join(".git"), b"gitdir: elsewhere")?; + fs::create_dir_all(&loose)?; + + assert_eq!(default_write_root(&nested), repo); + assert_eq!(default_write_root(&repo), repo); + assert_eq!(default_write_root(&worktree_nested), worktree); + let temp_is_outside_any_checkout = !base.ancestors().any(|dir| dir.join(".git").exists()); + if temp_is_outside_any_checkout { + assert_eq!(default_write_root(&loose), loose); + } + + fs::remove_dir_all(base)?; + Ok(()) +} + #[test] fn no_arguments_use_the_startup_current_directory() -> anyhow::Result<()> { let action = parse_cli_args(std::iter::empty::())?; let CliAction::Serve(config) = action else { panic!("no arguments must start the server") }; - assert_eq!(config.allowed_write_roots, vec![std::env::current_dir()?]); + assert_eq!( + config.allowed_write_roots, + vec![default_write_root(&std::env::current_dir()?)] + ); assert_eq!(config.figma_client_id, None); assert_eq!(config.figma_client_secret, None); assert_eq!(config.figma_callback_port, None); diff --git a/crates/devup-mcp/tests/install.rs b/crates/devup-mcp/tests/install.rs new file mode 100644 index 00000000..b0dda801 --- /dev/null +++ b/crates/devup-mcp/tests/install.rs @@ -0,0 +1,108 @@ +use std::fs; + +use devup_mcp::install::install_at; + +#[test] +fn install_copies_the_binary_and_registers_every_set_up_client() -> anyhow::Result<()> { + let base = std::env::temp_dir().join(format!("devup-mcp-install-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let home = base.join("home"); + fs::create_dir_all(home.join(".config").join("opencode"))?; + fs::create_dir_all(home.join(".codex"))?; + fs::write( + home.join(".config/opencode/opencode.json"), + r#"{"mcp":{"devup-mcp":{"type":"local","command":["python","launcher.py"],"timeout":30000},"other":{"type":"remote"}}}"#, + )?; + fs::write( + home.join(".codex/config.toml"), + "model = \"x\"\n\n[mcp_servers.devup-mcp]\ncommand = 'python.exe'\nargs = ['launcher.py']\nstartup_timeout_sec = 30.0\n", + )?; + let source = base.join("source-bin"); + fs::write(&source, b"binary-v1")?; + let target = base.join("bin").join("devup-mcp"); + + let report = install_at(&home, &source, &target)?; + assert_eq!(report.binary.action, "copied"); + assert_eq!(fs::read(&target)?, b"binary-v1"); + let command = target.to_string_lossy().into_owned(); + + let opencode: serde_json::Value = serde_json::from_str(&fs::read_to_string( + home.join(".config/opencode/opencode.json"), + )?)?; + assert_eq!( + opencode["mcp"]["devup-mcp"]["command"], + serde_json::json!([command]) + ); + assert_eq!(opencode["mcp"]["devup-mcp"]["timeout"], 30000); + assert_eq!(opencode["mcp"]["other"]["type"], "remote"); + assert!( + home.join(".config/opencode/opencode.json.devup-mcp.bak") + .is_file() + ); + + let codex: toml_edit::DocumentMut = + fs::read_to_string(home.join(".codex/config.toml"))?.parse()?; + assert_eq!(codex["model"].as_str(), Some("x")); + assert_eq!( + codex["mcp_servers"]["devup-mcp"]["command"].as_str(), + Some(command.as_str()) + ); + assert!( + codex["mcp_servers"]["devup-mcp"]["args"] + .as_array() + .is_some_and(|a| a.is_empty()) + ); + assert_eq!( + codex["mcp_servers"]["devup-mcp"]["startup_timeout_sec"].as_float(), + Some(30.0) + ); + + let claude = report + .clients + .iter() + .find(|c| c.client == "claude-code") + .unwrap(); + assert_eq!(claude.action, "skipped"); + + let again = install_at(&home, &source, &target)?; + assert_eq!(again.binary.action, "unchanged"); + assert!( + again + .clients + .iter() + .filter(|c| c.client != "claude-code") + .all(|c| c.action == "unchanged") + ); + + fs::write(&source, b"binary-v2")?; + assert_eq!(install_at(&home, &source, &target)?.binary.action, "copied"); + assert_eq!(fs::read(&target)?, b"binary-v2"); + + fs::remove_dir_all(base)?; + Ok(()) +} + +#[test] +fn jsonc_config_is_reported_not_rewritten() -> anyhow::Result<()> { + let base = std::env::temp_dir().join(format!("devup-mcp-install-jsonc-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let home = base.join("home"); + let dir = home.join(".config").join("opencode"); + fs::create_dir_all(&dir)?; + let original = "{\n // keep me\n \"mcp\": {}\n}\n"; + fs::write(dir.join("opencode.jsonc"), original)?; + let source = base.join("source-bin"); + fs::write(&source, b"bin")?; + + let report = install_at(&home, &source, &base.join("bin").join("devup-mcp"))?; + let opencode = report + .clients + .iter() + .find(|c| c.client == "opencode") + .unwrap(); + assert_eq!(opencode.action, "failed"); + assert_eq!(fs::read_to_string(dir.join("opencode.jsonc"))?, original); + + fs::remove_dir_all(base)?; + Ok(()) +}