diff --git a/README.md b/README.md index ea9c9ef2..ecf54e0c 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,10 @@ konnect status --client codex konnect uninstall --client codex ``` -Keep `--client codex` in the MCP server command so first-launch setup also stays -inside Codex's directories. For example, register a standalone binary with the +MCP server startup never installs or restores guidance. Run `konnect init` +explicitly when you want those files installed; after `konnect uninstall`, +starting the server leaves them removed. `--client` remains accepted in server +commands for compatibility. For example, register a standalone binary with the Codex CLI using: ```bash diff --git a/crates/konnect/src/install.rs b/crates/konnect/src/install.rs index 290ba336..a71de7c9 100644 --- a/crates/konnect/src/install.rs +++ b/crates/konnect/src/install.rs @@ -1,7 +1,7 @@ //! Client-aware installer for Konnect's bundled guidance. //! -//! Handles client-scoped install, uninstall, status, first-launch setup, and -//! Claude hook integration without writing into another client's directories. +//! Handles explicit client-scoped install, uninstall, status, and Claude hook +//! integration without writing into another client's directories. use crate::manifest::{AGENTS, HOOK_SKILLS, SKILLS}; use anyhow::{bail, Context, Result}; @@ -95,10 +95,6 @@ pub fn run_install(client: InstallClient) -> Result<()> { run_install_at(client, &InstallPaths::for_current_user()?, true) } -pub fn run_install_silent(client: InstallClient) -> Result<()> { - run_install_at(client, &InstallPaths::for_current_user()?, false) -} - pub fn run_uninstall(client: InstallClient) -> Result<()> { run_uninstall_at(client, &InstallPaths::for_current_user()?, true) } @@ -125,12 +121,6 @@ pub fn print_skill_content(name: &str) -> Result<()> { std::process::exit(1); } -pub fn needs_install(client: InstallClient) -> bool { - InstallPaths::for_current_user() - .map(|paths| !has_install_marker(client, &paths)) - .unwrap_or(false) -} - /// Double-click behavior remains Claude-focused for backward compatibility. pub fn run_double_click_install() -> Result<()> { println!("==========================================="); @@ -391,10 +381,6 @@ fn install_marker(client: InstallClient, paths: &InstallPaths) -> Option bool { - install_marker(client, paths).is_some() -} - fn remove_if_present(path: &Path) -> Result<()> { if path.exists() { fs::remove_file(path)?; @@ -713,8 +699,8 @@ mod tests { let paths = test_paths(&temp); fs::create_dir_all(paths.data_dir()).unwrap(); fs::write(paths.legacy_marker(), "0.4.0").unwrap(); - assert!(has_install_marker(InstallClient::Claude, &paths)); - assert!(!has_install_marker(InstallClient::Codex, &paths)); + assert!(install_marker(InstallClient::Claude, &paths).is_some()); + assert!(install_marker(InstallClient::Codex, &paths).is_none()); } #[test] diff --git a/crates/konnect/src/main.rs b/crates/konnect/src/main.rs index 3819502c..0464fd44 100644 --- a/crates/konnect/src/main.rs +++ b/crates/konnect/src/main.rs @@ -98,12 +98,9 @@ async fn main() -> Result<()> { return install::run_double_click_install(); } - let client = install::client_from_server_args(&args[1..])?; - - // ─── Auto-install on first MCP launch (safety net) ────────────── - if install::needs_install(client) { - let _ = install::run_install_silent(client); - } + // Keep accepting the public `--client` option, but MCP startup is + // deliberately non-mutating: guidance installation requires `konnect init`. + let _client = install::client_from_server_args(&args[1..])?; // --config : load config from specified file let config_path = args diff --git a/crates/konnect/tests/startup_cli.rs b/crates/konnect/tests/startup_cli.rs new file mode 100644 index 00000000..a90cc08d --- /dev/null +++ b/crates/konnect/tests/startup_cli.rs @@ -0,0 +1,132 @@ +//! Guidance-install invariants for real CLI process startup. +//! +//! Windows is intentionally excluded: `dirs::home_dir()` uses +//! `SHGetKnownFolderPath`, so overriding `HOME`/`USERPROFILE` would still risk +//! modifying the test runner's real client configuration. + +#![cfg(not(target_os = "windows"))] + +use serde_json::json; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn konnect(home: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_konnect")); + command.env("HOME", home); + command.env("XDG_CONFIG_HOME", home.join(".config")); + command +} + +fn guidance_snapshot(home: &Path) -> Vec<(PathBuf, Option>)> { + fn visit(root: &Path, dir: &Path, entries: &mut Vec<(PathBuf, Option>)>) { + let mut children: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap()) + .collect(); + children.sort_by_key(|entry| entry.file_name()); + for child in children { + let path = child.path(); + let relative = path.strip_prefix(root).unwrap().to_path_buf(); + if path.is_dir() { + entries.push((relative, None)); + visit(root, &path, entries); + } else { + entries.push((relative, Some(fs::read(path).unwrap()))); + } + } + } + + let mut entries = Vec::new(); + for name in [".claude", ".agents"] { + let path = home.join(name); + if path.exists() { + entries.push((PathBuf::from(name), None)); + visit(home, &path, &mut entries); + } + } + for name in [ + ".konnect/.installed", + ".konnect/.installed-claude", + ".konnect/.installed-codex", + ] { + let path = home.join(name); + if path.exists() { + entries.push((PathBuf::from(name), Some(fs::read(path).unwrap()))); + } + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + entries +} + +fn start_and_initialize_server(home: &Path, client: &str) { + let mut child = konnect(home) + .args(["--client", client]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "startup-test", "version": "0"} + } + }) + ) + .unwrap(); + stdin.flush().unwrap(); + + let mut response = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut response) + .unwrap(); + assert!(response.contains("\"serverInfo\""), "{response}"); + + child.kill().unwrap(); + child.wait().unwrap(); +} + +#[test] +fn mcp_start_does_not_install_into_a_clean_home() { + for client in ["claude", "codex"] { + let temp = tempfile::tempdir().unwrap(); + let before = guidance_snapshot(temp.path()); + + start_and_initialize_server(temp.path(), client); + + assert_eq!(guidance_snapshot(temp.path()), before, "client: {client}"); + } +} + +#[test] +fn mcp_start_does_not_reverse_an_explicit_uninstall() { + for client in ["claude", "codex"] { + let temp = tempfile::tempdir().unwrap(); + + assert!(konnect(temp.path()) + .args(["init", "--client", client]) + .status() + .unwrap() + .success()); + assert!(konnect(temp.path()) + .args(["uninstall", "--client", client]) + .status() + .unwrap() + .success()); + + let before = guidance_snapshot(temp.path()); + start_and_initialize_server(temp.path(), client); + assert_eq!(guidance_snapshot(temp.path()), before, "client: {client}"); + } +}