From ed83f2874a499df2c397dfe009cb77a8033af061 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 12:35:10 +0900 Subject: [PATCH] feat(onboarding): add Codex MCP project integration Feature-Confirmed: true --- AGENTS.md | 2 +- .../.agents/skills/lian-jing/SKILL.md | 16 +++ .../resources/onboarding/git-workflow.md.tmpl | 2 +- src-tauri/src/commands/onboarding.rs | 32 ++++- src-tauri/src/commands/project_scan.rs | 7 +- src-tauri/src/mcp_inject.rs | 109 +++++++++++++++++- src-tauri/src/mcp_tools.rs | 4 +- 7 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 6e155a7..9b9b171 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ src-tauri/src/db.rs SQLite 建表 + migration 逻辑 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .claude/settings.json) +src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .codex/config.toml 与 Claude 兼容配置) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md new file mode 100644 index 0000000..87655d7 --- /dev/null +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -0,0 +1,16 @@ +--- +name: lian-jing +description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管理、Gitea 与交付能力。 +--- + +# 炼境项目技能 + +开始项目工作时,先读取根 `AGENTS.md` 与 `.claude/liangjing.json` 取得 `project_id`。 + +如果当前客户端已发现 `lian-jing` MCP: + +1. 所有 MCP 调用显式传入 `project_id`。 +2. 优先调用 `get_diagnostics`,将其作为当前项目健康状态的事实来源。 +3. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 + +如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index d50c169..4bdf858 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -78,7 +78,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 ## PR 创建(炼境托管项目) -本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。 +本项目由炼境管理;Codex 使用项目级 `.codex/config.toml` 发现 MCP(项目须受信任),`.claude/liangjing.json` 含 `project_id`。 炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。 **推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 828cd27..ed034c9 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -18,6 +18,7 @@ const TMPL_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.ym const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.tmpl"); const TMPL_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md"); const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs"); +const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -181,6 +182,7 @@ pub fn dispatch_templates(project_path: &str) -> Vec { dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY), dispatch_fully_managed(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE), + dispatch_fully_managed(root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL), ] } @@ -329,6 +331,27 @@ fn url_host(url: &str) -> Option { } } +fn check_codex_mcp(content: Option<&str>) -> (bool, String) { + match content { + Some(content) + if content.contains("[mcp_servers.lian-jing]") + && content.contains("url = \"http://127.0.0.1:") + && content.contains("/mcp\"") => + { + (true, "lian-jing 指向本地 MCP 服务".into()) + } + Some(_) => (false, "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into()), + None => (false, ".codex/config.toml 缺失——调用 inject_mcp 后生成".into()), + } +} + +fn check_skill(content: Option<&str>) -> (bool, String) { + match content { + Some(content) if content.contains("name: lian-jing") => (true, "炼境项目技能已分发".into()), + _ => (false, "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into()), + } +} + /// 纯函数:管理块版本是否与最新模板一致。 fn check_managed_version(content: Option<&str>, expected: &str) -> (bool, String) { match content.and_then(block_version) { @@ -427,6 +450,11 @@ pub fn verify_onboarding( checks.push(VerifyCheck { name: label.into(), pass, detail }); } + let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); + checks.push(VerifyCheck { name: "Codex 炼境技能已分发".into(), pass, detail }); + let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref()); + checks.push(VerifyCheck { name: "Codex MCP 已配置".into(), pass, detail }); + // 3 lefthook hook 真挂在 .git/hooks let (pass, detail) = match read(".git/hooks/pre-commit") { Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()), @@ -683,11 +711,13 @@ mod tests { // 空目录:七项全不通过(真实反映"什么都没接") let checks = verify_onboarding(&conn, "p1", &base); - assert_eq!(checks.len(), 7, "检查链应为七项"); + assert_eq!(checks.len(), 9, "检查链应为九项"); assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过"); // 铺齐全部生效条件后:七项全通过 dispatch_templates(base.to_str().unwrap()); + std::fs::create_dir_all(base.join(".codex")).unwrap(); + std::fs::write(base.join(".codex/config.toml"), "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n").unwrap(); std::fs::create_dir_all(base.join(".git/hooks")).unwrap(); std::fs::write(base.join(".git/hooks/pre-commit"), "call lefthook run pre-commit").unwrap(); std::fs::write( diff --git a/src-tauri/src/commands/project_scan.rs b/src-tauri/src/commands/project_scan.rs index 447c621..42dea3d 100644 --- a/src-tauri/src/commands/project_scan.rs +++ b/src-tauri/src/commands/project_scan.rs @@ -258,10 +258,9 @@ fn scan_dir_files(dir: &Path) -> DetectedMap { insert(&mut map, 2, "Docker", Some("direct"), None, None); } - // Claude CLI - if dir.join(".claude").exists() { - insert(&mut map, 2, "Claude CLI", Some("direct"), None, Some(".claude/")); - } + // AI coding clients + if dir.join(".claude").exists() { insert(&mut map, 2, "Claude CLI", Some("direct"), None, Some(".claude/")); } + if dir.join(".codex").exists() || dir.join(".agents/skills").exists() { insert(&mut map, 2, "Codex", Some("direct"), None, Some(".codex/ 或 .agents/skills/")); } map } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 790d7ce..1f87d5d 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; const MCP_KEY: &str = "lian-jing"; /// 旧组级 key 前缀——仅用于注入时清理存量配置,T6 收尾后可删 const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; +const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; +const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -75,6 +77,9 @@ fn settings_path(project_id: &str) -> Result { fn liangjing_path(project_id: &str) -> Result { Ok(project_root(project_id)?.join(".claude").join("liangjing.json")) } +fn codex_config_path(project_id: &str) -> Result { + Ok(project_root(project_id)?.join(".codex").join("config.toml")) +} /// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。 /// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导) @@ -321,12 +326,86 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e)) } +fn upsert_codex_mcp_config(path: &PathBuf, server_url: &str) -> Result<(), String> { + let block = format!( + "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\n{CODEX_MCP_MANAGED_END}\n" + ); + let current = match std::fs::read_to_string(path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")), + }; + let (start, end) = ( + current.find(CODEX_MCP_MANAGED_START), + current.find(CODEX_MCP_MANAGED_END), + ); + let next = match (start, end) { + (Some(start), Some(end)) if end >= start => format!( + "{}{}{}", + ¤t[..start], + block, + ¤t[end + CODEX_MCP_MANAGED_END.len()..] + ), + (Some(_), _) | (_, Some(_)) => return Err(format!( + "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", + path.display() + )), + (None, None) + if current + .lines() + .any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) => + { + return Err(format!( + "{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖", + path.display() + )); + } + (None, None) if current.trim().is_empty() => block, + (None, None) => format!("{}\n{}", current.trim_end(), block), + }; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建 .codex 目录失败: {error}"))?; + } + std::fs::write(path, next).map_err(|error| format!("写入 Codex config.toml 失败: {error}")) +} + +fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { + let current = match std::fs::read_to_string(path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")), + }; + let (Some(start), Some(end)) = ( + current.find(CODEX_MCP_MANAGED_START), + current.find(CODEX_MCP_MANAGED_END), + ) else { + return Ok(()); + }; + if end < start { + return Err(format!("{} 的炼境 Codex 管理块不完整", path.display())); + } + + let next = format!( + "{}{}", + ¤t[..start], + ¤t[end + CODEX_MCP_MANAGED_END.len()..] + ) + .trim_matches(['\r', '\n']) + .to_string(); + let output = if next.is_empty() { String::new() } else { format!("{next}\n") }; + std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}")) +} + // ── 公开接口 ────────────────────────────────────────────────────────────────── /// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置, /// 并清理旧组级 key(迁移内建于重注入) pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { let port = crate::mcp_server::read_port(); + let server_url = format!("http://127.0.0.1:{}/mcp", port); + upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?; let path = settings_path(project_id)?; let mut settings = read_json(&path)?; @@ -345,7 +424,7 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { } servers[MCP_KEY] = json!({ "type": "sse", - "url": format!("http://127.0.0.1:{}/mcp", port) + "url": server_url }); write_json(&path, &settings).inspect(|_| { @@ -364,6 +443,7 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { + remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; if !path.exists() { return Ok(()); @@ -472,6 +552,33 @@ mod tests { ); } + #[test] + fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() { + let base = std::env::temp_dir().join(format!( + "lianjing_codex_mcp_test_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".codex")).unwrap(); + let path = base.join(".codex/config.toml"); + + std::fs::write(&path, "model = \"gpt-6\"\n").unwrap(); + upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").unwrap(); + let configured = std::fs::read_to_string(&path).unwrap(); + assert!(configured.contains("model = \"gpt-6\"")); + assert!(configured.contains("[mcp_servers.lian-jing]")); + assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\"")); + + remove_codex_mcp_config(&path).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "model = \"gpt-6\"\n"); + + let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n"; + std::fs::write(&path, manual).unwrap(); + assert!(upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), manual); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn registered_path_accepts_absolute_and_unc() { assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok()); diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index bb48a05..5c45183 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -164,7 +164,7 @@ pub fn tools_list_result() -> Value { }, { "name": "inject_mcp", - "description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。", + "description": "向项目的 .codex/config.toml 与 .claude/settings.json 注入炼境 MCP 配置。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。", "inputSchema": { "type": "object", "properties": { @@ -426,7 +426,7 @@ pub async fn tools_call(params: Option<&Value>) -> Value { "inject_mcp" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); crate::mcp_inject::inject_project_mcp(pid) - .map(|_| format!("MCP 注入成功:project_id={}(单一 lian-jing 服务器,无需 group_id)", pid)) + .map(|_| format!("MCP 注入成功:project_id={}(已配置 Codex 与 Claude;单一 lian-jing 服务器,无需 group_id)", pid)) } "get_ci_status" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); -- 2.45.2