Compare commits

..

No commits in common. "e1e7ffb3b4310e58d05e0fbeaf1e04ab4347e6c4" and "383b5b35d74a6148c2ac09a4e9b741cec5df772c" have entirely different histories.

7 changed files with 10 additions and 162 deletions

View File

@ -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 配置注入(写入被管理项目的 .codex/config.toml 与 Claude 兼容配置)
src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .claude/settings.json)
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
docs/ai-context/ AI 上下文文档(速查卡、契约映射)

View File

@ -1,16 +0,0 @@
---
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 的本地工作;不得伪造诊断结果。

View File

@ -78,7 +78,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
## PR 创建(炼境托管项目)
本项目由炼境管理;Codex 使用项目级 `.codex/config.toml` 发现 MCP(项目须受信任),`.claude/liangjing.json` 含 `project_id`。
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**

View File

@ -18,7 +18,6 @@ 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)]
@ -182,7 +181,6 @@ 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),
]
}
@ -331,27 +329,6 @@ 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) {
@ -454,11 +431,6 @@ 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()),
@ -715,13 +687,11 @@ mod tests {
// 空目录:七项全不通过(真实反映"什么都没接")
let checks = verify_onboarding(&conn, "p1", &base);
assert_eq!(checks.len(), 9, "检查链应为九项");
assert_eq!(checks.len(), 7, "检查链应为七项");
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(

View File

@ -258,9 +258,10 @@ fn scan_dir_files(dir: &Path) -> DetectedMap {
insert(&mut map, 2, "Docker", Some("direct"), None, None);
}
// 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/")); }
// Claude CLI
if dir.join(".claude").exists() {
insert(&mut map, 2, "Claude CLI", Some("direct"), None, Some(".claude/"));
}
map
}

View File

@ -6,8 +6,6 @@ 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";
// ── 路径解析 ──────────────────────────────────────────────────────────────────
@ -77,9 +75,6 @@ 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(单一服务器寻址后已无用,留着会过期误导)
@ -326,86 +321,12 @@ 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!(
"{}{}{}",
&current[..start],
block,
&current[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!(
"{}{}",
&current[..start],
&current[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)?;
@ -424,7 +345,7 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
}
servers[MCP_KEY] = json!({
"type": "sse",
"url": server_url
"url": format!("http://127.0.0.1:{}/mcp", port)
});
write_json(&path, &settings).inspect(|_| {
@ -443,7 +364,6 @@ 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(());
@ -552,33 +472,6 @@ 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());

View File

@ -164,7 +164,7 @@ pub fn tools_list_result() -> Value {
},
{
"name": "inject_mcp",
"description": "向项目的 .codex/config.toml 与 .claude/settings.json 注入炼境 MCP 配置。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。",
"description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。",
"inputSchema": {
"type": "object",
"properties": {
@ -437,7 +437,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={}(已配置 Codex 与 Claude;单一 lian-jing 服务器,无需 group_id)", pid))
.map(|_| format!("MCP 注入成功:project_id={}(单一 lian-jing 服务器,无需 group_id)", pid))
}
"get_ci_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");