Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 4 additions & 18 deletions crates/konnect/src/install.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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)
}
Expand All @@ -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!("===========================================");
Expand Down Expand Up @@ -391,10 +381,6 @@ fn install_marker(client: InstallClient, paths: &InstallPaths) -> Option<PathBuf
None
}

fn has_install_marker(client: InstallClient, paths: &InstallPaths) -> bool {
install_marker(client, paths).is_some()
}

fn remove_if_present(path: &Path) -> Result<()> {
if path.exists() {
fs::remove_file(path)?;
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 3 additions & 6 deletions crates/konnect/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>: load config from specified file
let config_path = args
Expand Down
132 changes: 132 additions & 0 deletions crates/konnect/tests/startup_cli.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>>)> {
fn visit(root: &Path, dir: &Path, entries: &mut Vec<(PathBuf, Option<Vec<u8>>)>) {
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}");
}
}
Loading