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
268 changes: 142 additions & 126 deletions Cargo.lock

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ members = [
]

[workspace.package]
version = "4.1.1"
version = "4.2.0"
authors = ["JJ Adonis"]
edition = "2024"
rust-version = "1.94.1"
Expand All @@ -24,15 +24,15 @@ homepage = "https://github.com/thedoublejay/gather-step"
description = "High-performance multi-repo codebase intelligence engine"

[workspace.dependencies]
gather-step = { path = "crates/gather-step-cli", version = "4.1.1" }
gather-step-analysis = { path = "crates/gather-step-analysis", version = "4.1.1" }
gather-step-core = { path = "crates/gather-step-core", version = "4.1.1" }
gather-step-deploy = { path = "crates/gather-step-deploy", version = "4.1.1" }
gather-step-git = { path = "crates/gather-step-git", version = "4.1.1" }
gather-step-mcp = { path = "crates/gather-step-mcp", version = "4.1.1" }
gather-step-output = { path = "crates/gather-step-output", version = "4.1.1" }
gather-step-parser = { path = "crates/gather-step-parser", version = "4.1.1" }
gather-step-storage = { path = "crates/gather-step-storage", version = "4.1.1" }
gather-step = { path = "crates/gather-step-cli", version = "4.2.0" }
gather-step-analysis = { path = "crates/gather-step-analysis", version = "4.2.0" }
gather-step-core = { path = "crates/gather-step-core", version = "4.2.0" }
gather-step-deploy = { path = "crates/gather-step-deploy", version = "4.2.0" }
gather-step-git = { path = "crates/gather-step-git", version = "4.2.0" }
gather-step-mcp = { path = "crates/gather-step-mcp", version = "4.2.0" }
gather-step-output = { path = "crates/gather-step-output", version = "4.2.0" }
gather-step-parser = { path = "crates/gather-step-parser", version = "4.2.0" }
gather-step-storage = { path = "crates/gather-step-storage", version = "4.2.0" }

tree-sitter = "=0.26.8"
tree-sitter-typescript = "0.23.2"
Expand All @@ -56,9 +56,10 @@ console = "0.16.3"
comfy-table = "7.2.2"
chrono = { version = "0.4.44", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_json = "1.0.150"
serde_norway = "0.9.42"
toml = "1.1.2"
toml_edit = "0.25.12"
bitcode = "0.6.9"
blake3 = "1.8.5"
dirs = "6"
Expand All @@ -69,19 +70,19 @@ globset = "0.4.18"
rustc-hash = "2.1.2"
hashbrown = "=0.17.0"
parking_lot = "=0.12.5"
tokio = { version = "1.52.2", features = ["macros", "rt-multi-thread", "sync", "time", "signal", "net", "io-util"] }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "sync", "time", "signal", "net", "io-util"] }
tokio-util = "0.7.18"
notify = "=9.0.0-rc.4"
gix = { version = "0.83", default-features = true }
regex = "1.12.3"
simdutf8 = "=0.1.5"
similar = "3.1.0"
similar = "3.1.1"
lru = "=0.18.0"
quick_cache = "0.6.21"
quick_cache = "0.6.22"
smallvec = { version = "=1.15.1", features = ["serde"] }
camino = "1.2.2"
aho-corasick = "1.1.4"
memchr = "2.8.0"
memchr = "2.8.1"
libc = "0.2"
dhat = "=0.3.3"
regex-automata = { version = "0.4.14", default-features = false, features = ["std", "syntax", "meta", "nfa", "dfa", "hybrid", "perf", "unicode"] }
Expand Down
1 change: 1 addition & 0 deletions crates/gather-step-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ serde.workspace = true
thiserror.workspace = true
serde_json.workspace = true
serde_norway.workspace = true
toml_edit.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
Expand Down
16 changes: 14 additions & 2 deletions crates/gather-step-cli/src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,13 @@ async fn run_non_interactive(app: &AppContext, args: InitArgs) -> Result<()> {
generate::run_summary_pair(app)?;
}
if let Some(scope) = args.setup_mcp {
setup_mcp::run(app, setup_mcp::SetupMcpArgs { scope })?;
setup_mcp::run(
app,
setup_mcp::SetupMcpArgs {
client: setup_mcp::McpClient::Claude,
scope,
},
)?;
}
if args.watch && !args.no_watch {
emit_setup_complete(&output);
Expand Down Expand Up @@ -193,7 +199,13 @@ async fn run_wizard(app: &AppContext, args: InitArgs) -> Result<()> {
generate::run_summary_pair(app)?;
}
if let Some(scope) = scope {
setup_mcp::run(app, setup_mcp::SetupMcpArgs { scope })?;
setup_mcp::run(
app,
setup_mcp::SetupMcpArgs {
client: setup_mcp::McpClient::Claude,
scope,
},
)?;
}
emit_setup_complete(&output);
if do_watch {
Expand Down
94 changes: 87 additions & 7 deletions crates/gather-step-cli/src/commands/setup_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use serde::Serialize;
use serde_json::{Map, Value, json};
use toml_edit::{Array, DocumentMut, Item, Table, value};

use crate::app::AppContext;

Expand All @@ -17,15 +18,27 @@ pub enum McpScope {
Local,
}

#[derive(Debug, Clone, Copy, ValueEnum, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum McpClient {
Claude,
Codex,
}

#[derive(Debug, Clone, Copy, Args)]
pub struct SetupMcpArgs {
/// MCP client to configure.
#[arg(long, value_enum, default_value = "claude")]
pub client: McpClient,
/// Configuration scope. Ignored for Codex, whose config is always global.
#[arg(long, value_enum, default_value = "local")]
pub scope: McpScope,
}

#[derive(Debug, Serialize)]
struct SetupMcpOutput {
event: &'static str,
client: McpClient,
scope: McpScope,
settings_path: String,
path_resolution: PathResolution,
Expand All @@ -41,22 +54,22 @@ enum PathResolution {
}

pub fn run(app: &AppContext, args: SetupMcpArgs) -> Result<()> {
let settings_path = match args.scope {
McpScope::Local => app.workspace_path.join(".claude/settings.json"),
McpScope::Global => home_dir()
.context("cannot resolve HOME")?
.join(".claude/settings.json"),
};
let settings_path = resolve_settings_path(args.client, args.scope, &app.workspace_path)?;
let command_path = find_command_on_path("gather-step");
let path_resolution = if command_path.is_some() {
PathResolution::Ok
} else {
PathResolution::NotFound
};
write_settings(&settings_path, &app.workspace_path)?;

match args.client {
McpClient::Claude => write_settings(&settings_path, &app.workspace_path)?,
McpClient::Codex => write_codex_config(&settings_path, &app.workspace_path)?,
}

let payload = SetupMcpOutput {
event: "setup_mcp_completed",
client: args.client,
scope: args.scope,
settings_path: settings_path.display().to_string(),
path_resolution,
Expand All @@ -73,6 +86,28 @@ pub fn run(app: &AppContext, args: SetupMcpArgs) -> Result<()> {
Ok(())
}

/// Resolve the config file the chosen client actually reads MCP server
/// definitions from.
///
/// Claude Code does not read `mcpServers` out of `settings.json`: project scope
/// lives in `.mcp.json` at the workspace root and user scope in `~/.claude.json`.
/// Codex reads a single global `~/.codex/config.toml`, so scope does not apply.
fn resolve_settings_path(client: McpClient, scope: McpScope, workspace: &Path) -> Result<PathBuf> {
match client {
McpClient::Claude => match scope {
McpScope::Local => Ok(workspace.join(".mcp.json")),
McpScope::Global => Ok(home_dir()
.context("cannot resolve HOME")?
.join(".claude.json")),
},
McpClient::Codex => Ok(home_dir()
.context("cannot resolve HOME")?
.join(".codex/config.toml")),
}
}

/// Merge a workspace-pinned `gather-step` entry into a JSON `mcpServers` map,
/// preserving every other key. Used for Claude's `.mcp.json` and `~/.claude.json`.
pub fn write_settings(path: &Path, workspace: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
Expand Down Expand Up @@ -112,6 +147,51 @@ pub fn write_settings(path: &Path, workspace: &Path) -> Result<()> {
Ok(())
}

/// Merge a workspace-pinned `gather-step` entry into a Codex `config.toml`,
/// preserving existing servers, other tables, comments, and formatting.
pub fn write_codex_config(path: &Path, workspace: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}

let mut doc = if path.exists() {
let body =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
body.parse::<DocumentMut>()
.with_context(|| format!("parsing {}", path.display()))?
} else {
DocumentMut::new()
};

let workspace_str = workspace
.to_str()
.context("workspace path is not valid UTF-8")?;
let mut args = Array::new();
args.push("--workspace");
args.push(workspace_str);
args.push("serve");

let mut server = Table::new();
server.insert("command", value("gather-step"));
server.insert("args", value(args));

// Keep `mcp_servers` an implicit table so the entry renders as the
// idiomatic `[mcp_servers.gather-step]` section rather than an inline table.
if doc.get("mcp_servers").is_none() {
let mut servers = Table::new();
servers.set_implicit(true);
doc.insert("mcp_servers", Item::Table(servers));
}
let servers = doc["mcp_servers"]
.as_table_mut()
.context("mcp_servers is not a table")?;
servers.insert("gather-step", Item::Table(server));

std::fs::write(path, doc.to_string()).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}

fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from)
}
Expand Down
8 changes: 4 additions & 4 deletions crates/gather-step-cli/src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ fn mcp_state(app: &AppContext) -> &'static str {
}

fn mcp_state_with_home(app: &AppContext, home: Option<&std::ffi::OsStr>) -> &'static str {
let local = app.workspace_path.join(".claude/settings.json");
let local = app.workspace_path.join(".mcp.json");
if json_has_gather_step(&local) {
return "configured: local";
}

if let Some(home) = home {
let global = std::path::PathBuf::from(home).join(".claude/settings.json");
let global = std::path::PathBuf::from(home).join(".claude.json");
if json_has_gather_step(&global) {
return "configured: global";
}
Expand Down Expand Up @@ -418,7 +418,7 @@ mod tests {
#[test]
fn mcp_state_reports_local_configuration_first() {
let temp = tempfile::tempdir().expect("temp dir");
write_settings(&temp.path().join(".claude/settings.json"));
write_settings(&temp.path().join(".mcp.json"));

assert_eq!(
mcp_state_with_home(&app_for(temp.path()), None),
Expand All @@ -430,7 +430,7 @@ mod tests {
fn mcp_state_reports_global_configuration() {
let workspace = tempfile::tempdir().expect("workspace");
let home = tempfile::tempdir().expect("home");
write_settings(&home.path().join(".claude/settings.json"));
write_settings(&home.path().join(".claude.json"));

assert_eq!(
mcp_state_with_home(&app_for(workspace.path()), Some(home.path().as_os_str())),
Expand Down
6 changes: 3 additions & 3 deletions crates/gather-step-cli/tests/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ fn setup_mcp_local_writes_workspace_settings() {

let output = run_ok(temp.path(), &["setup-mcp", "--scope", "local"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let settings_path = temp.path().join(".claude/settings.json");
let settings_path = temp.path().join(".mcp.json");
let settings = fs::read_to_string(&settings_path).expect("settings file should be written");
let value: Value = serde_json::from_str(&settings).expect("settings json");

Expand Down Expand Up @@ -174,7 +174,7 @@ fn setup_mcp_json_reports_missing_path_resolution() {
}

#[test]
fn setup_mcp_global_writes_home_claude_settings() {
fn setup_mcp_global_writes_home_claude_json() {
let workspace = TempDir::new("setup-mcp-global-workspace");
let home = TempDir::new("setup-mcp-global-home");

Expand All @@ -194,7 +194,7 @@ fn setup_mcp_global_writes_home_claude_settings() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let settings_path = home.path().join(".claude/settings.json");
let settings_path = home.path().join(".claude.json");
let settings = fs::read_to_string(&settings_path).expect("settings file should be written");
let value: Value = serde_json::from_str(&settings).expect("settings json");
assert_eq!(value["mcpServers"]["gather-step"]["command"], "gather-step");
Expand Down
Loading