Compare commits
3 Commits
fdae4a8034
...
e794e7e914
| Author | SHA1 | Date | |
|---|---|---|---|
| e794e7e914 | |||
| b8f12425ab | |||
|
|
ed83f2874a |
@ -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 上下文文档(速查卡、契约映射)
|
||||
|
||||
@ -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 的本地工作;不得伪造诊断结果。
|
||||
@ -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:**
|
||||
|
||||
@ -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<TemplateResult> {
|
||||
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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<PathBuf, String> {
|
||||
fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_root(project_id)?.join(".claude").join("liangjing.json"))
|
||||
}
|
||||
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
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());
|
||||
|
||||
@ -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("");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user