- inject_project_mcp 末尾新增 try_register_globally 调用,通过
find_claude_bin 自动定位 claude CLI 并执行 claude mcp add -s user,
让新接入项目的产品组 MCP 立即对 Claude Code 可用
- write_liangjing_context 增加 group_id 参数,子项目读取后可直接
构造 lian-jing-{group_id} ToolSearch 查询,无需额外推断
- try_register_globally 为 best-effort:CLI 未找到或调用失败时
仅写日志,不影响 settings.json 注入结果
275 lines
9.6 KiB
Rust
275 lines
9.6 KiB
Rust
use crate::{db, db::log_event};
|
||
use serde_json::{json, Value};
|
||
use std::path::PathBuf;
|
||
|
||
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
|
||
|
||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||
|
||
/// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径)
|
||
fn project_root(project_id: &str) -> Result<PathBuf, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
|
||
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
|
||
[project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
);
|
||
|
||
let (win_path, wsl_path, platform) =
|
||
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||
|
||
if platform.as_deref() == Some("wsl") {
|
||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||
let distro: String = conn
|
||
.query_row(
|
||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||
let inner = wsl.trim_start_matches('/').replace('/', "\\");
|
||
Ok(PathBuf::from(format!(
|
||
"\\\\wsl.localhost\\{}\\{}",
|
||
distro, inner
|
||
)))
|
||
} else {
|
||
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
|
||
Ok(PathBuf::from(win))
|
||
}
|
||
}
|
||
|
||
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
|
||
Ok(project_root(project_id)?.join(".claude").join("settings.json"))
|
||
}
|
||
|
||
fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
|
||
Ok(project_root(project_id)?.join(".claude").join("liangjing.json"))
|
||
}
|
||
|
||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id
|
||
fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> {
|
||
let path = liangjing_path(project_id)?;
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent)
|
||
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
||
}
|
||
let ctx = json!({
|
||
"project_id": project_id,
|
||
"group_id": group_id,
|
||
"mcp_port": port
|
||
});
|
||
let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?;
|
||
std::fs::write(&path, content)
|
||
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
|
||
}
|
||
|
||
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
|
||
fn find_claude_bin() -> Option<String> {
|
||
if let Ok(path) = std::env::var("CLAUDE_BIN") {
|
||
if std::path::Path::new(&path).exists() {
|
||
return Some(path);
|
||
}
|
||
}
|
||
|
||
let home = std::env::var("USERPROFILE")
|
||
.or_else(|_| std::env::var("HOME"))
|
||
.unwrap_or_default();
|
||
|
||
let candidates = [
|
||
format!(r"{}\.local\bin\claude.exe", home),
|
||
format!(r"{}\AppData\Local\AnthropicClaude\claude.exe", home),
|
||
r"C:\Program Files\AnthropicClaude\claude.exe".to_string(),
|
||
];
|
||
|
||
for c in &candidates {
|
||
if std::path::Path::new(c).exists() {
|
||
return Some(c.clone());
|
||
}
|
||
}
|
||
|
||
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
|
||
if let Ok(out) = std::process::Command::new("where").arg("claude.exe").output() {
|
||
if out.status.success() {
|
||
if let Ok(stdout) = String::from_utf8(out.stdout) {
|
||
if let Some(line) = stdout.lines().next() {
|
||
return Some(line.trim().to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志
|
||
fn try_register_globally(group_id: &str, port: u16) {
|
||
let server_name = format!("lian-jing-{}", group_id);
|
||
let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id);
|
||
|
||
let Some(claude_bin) = find_claude_bin() else {
|
||
db::log_event(
|
||
"mcp_inject",
|
||
"warn",
|
||
"claude CLI 未找到,跳过全局 Mcp 注册",
|
||
None,
|
||
);
|
||
return;
|
||
};
|
||
|
||
let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| {
|
||
r"C:\Program Files\Git\bin\bash.exe".to_string()
|
||
});
|
||
|
||
match std::process::Command::new(&claude_bin)
|
||
.args([
|
||
"mcp",
|
||
"add",
|
||
"--transport",
|
||
"sse",
|
||
"-s",
|
||
"user",
|
||
&server_name,
|
||
&server_url,
|
||
])
|
||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||
.output()
|
||
{
|
||
Ok(out) if out.status.success() => {
|
||
db::log_event(
|
||
"mcp_inject",
|
||
"info",
|
||
"全局 MCP 注册成功",
|
||
Some(&server_name),
|
||
);
|
||
}
|
||
Ok(out) => {
|
||
let msg = format!(
|
||
"claude mcp add 非零退出: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
db::log_event("mcp_inject", "warn", &msg, Some(&server_name));
|
||
}
|
||
Err(e) => {
|
||
db::log_event(
|
||
"mcp_inject",
|
||
"warn",
|
||
&format!("执行 claude mcp add 失败: {}", e),
|
||
Some(&server_name),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
|
||
|
||
fn read_json(path: &PathBuf) -> Result<Value, String> {
|
||
if !path.exists() {
|
||
return Ok(json!({}));
|
||
}
|
||
let content = std::fs::read_to_string(path)
|
||
.map_err(|e| format!("读取 settings.json 失败: {}", e))?;
|
||
if content.trim().is_empty() {
|
||
return Ok(json!({}));
|
||
}
|
||
serde_json::from_str(&content).map_err(|e| {
|
||
format!(
|
||
"settings.json 格式错误,请手动修复后重试({}): {}",
|
||
path.display(),
|
||
e
|
||
)
|
||
})
|
||
}
|
||
|
||
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent)
|
||
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
||
}
|
||
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
|
||
std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
|
||
}
|
||
|
||
// ── 公开接口 ──────────────────────────────────────────────────────────────────
|
||
|
||
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
|
||
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||
let port = crate::mcp_server::read_port();
|
||
let path = settings_path(project_id)?;
|
||
let mut settings = read_json(&path)?;
|
||
|
||
let obj = settings
|
||
.as_object_mut()
|
||
.ok_or("settings.json 顶层必须是 JSON 对象")?;
|
||
|
||
// 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断)
|
||
obj.entry("permissions")
|
||
.or_insert(json!({"defaultMode": "bypassPermissions"}));
|
||
|
||
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
||
|
||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||
servers[&key] = json!({
|
||
"type": "sse",
|
||
"url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
|
||
});
|
||
|
||
write_json(&path, &settings).inspect(|_| {
|
||
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
|
||
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
|
||
})?;
|
||
|
||
// 写炼境上下文(含 group_id),让子项目 Claude 会话无需遍历即可知道 project_id + group_id
|
||
let _ = write_liangjing_context(project_id, group_id, port);
|
||
|
||
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
||
try_register_globally(group_id, port);
|
||
Ok(())
|
||
}
|
||
|
||
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
|
||
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||
let path = settings_path(project_id)?;
|
||
if !path.exists() {
|
||
return Ok(());
|
||
}
|
||
let mut settings = read_json(&path)?;
|
||
|
||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||
if let Some(servers) = settings
|
||
.get_mut("mcpServers")
|
||
.and_then(|v| v.as_object_mut())
|
||
{
|
||
servers.remove(&key);
|
||
}
|
||
|
||
write_json(&path, &settings)
|
||
}
|
||
|
||
/// 端口变更后重新注入所有产品组成员的 MCP 配置
|
||
/// 返回注入失败的错误列表(部分失败不影响其他项目)
|
||
pub fn reinject_all_mcp() -> Vec<String> {
|
||
let conn = match db::pool().get() {
|
||
Ok(c) => c,
|
||
Err(e) => return vec![e.to_string()],
|
||
};
|
||
|
||
let mappings: Vec<(String, String)> = {
|
||
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
|
||
Ok(s) => s,
|
||
Err(e) => return vec![e.to_string()],
|
||
};
|
||
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||
.unwrap_or_default()
|
||
};
|
||
|
||
let mut errors = Vec::new();
|
||
for (project_id, group_id) in mappings {
|
||
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
|
||
errors.push(format!("项目 {}: {}", project_id, e));
|
||
}
|
||
}
|
||
errors
|
||
}
|