feat(agent-context): add canonical project context
Feature-Confirmed: true
This commit is contained in:
parent
2294dbf5b5
commit
167fcf9880
@ -459,6 +459,14 @@ modules:
|
|||||||
progress: 14
|
progress: 14
|
||||||
note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。"
|
note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。"
|
||||||
position: [2000, 800]
|
position: [2000, 800]
|
||||||
|
|
||||||
|
- id: agent-client-capability-packs
|
||||||
|
name: Agent 客户端能力包
|
||||||
|
area: infra
|
||||||
|
status: in_progress
|
||||||
|
progress: 0
|
||||||
|
note: "P2:以工具无关项目上下文为锚点,收敛 Codex / Claude 客户端差异;不复制 MCP、凭证或部署能力。"
|
||||||
|
position: [2250, 800]
|
||||||
edges:
|
edges:
|
||||||
# 前端依赖
|
# 前端依赖
|
||||||
- from: workspace-mgmt
|
- from: workspace-mgmt
|
||||||
@ -750,3 +758,9 @@ edges:
|
|||||||
- from: project-capability-control-plane
|
- from: project-capability-control-plane
|
||||||
to: runtime-diagnostics
|
to: runtime-diagnostics
|
||||||
type: dependency
|
type: dependency
|
||||||
|
- from: onboarding-pack
|
||||||
|
to: agent-client-capability-packs
|
||||||
|
type: dependency
|
||||||
|
- from: project-capability-control-plane
|
||||||
|
to: agent-client-capability-packs
|
||||||
|
type: related
|
||||||
|
|||||||
55
.blueprint/modules/agent-client-capability-packs.md
Normal file
55
.blueprint/modules/agent-client-capability-packs.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: agent-client-capability-packs
|
||||||
|
name: Agent 客户端能力包
|
||||||
|
status: planned
|
||||||
|
progress: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
炼境为项目提供稳定的本地控制面与信号;客户端能力包只负责将同一项目规则、MCP 与工作流投影给 Codex、Claude 等 Agent。项目根目录与 canonical context 是锚点,不能把任一客户端的项目 UI、插件安装状态或全局配置当作项目身份。
|
||||||
|
|
||||||
|
## 已确认边界
|
||||||
|
|
||||||
|
```
|
||||||
|
项目根目录
|
||||||
|
├── .lian-jing/context.json ← canonical project_id 与兼容版本
|
||||||
|
├── .codex/config.toml ← 项目级 Codex MCP 投影(P0 已有)
|
||||||
|
├── .claude/liangjing.json ← 历史兼容投影
|
||||||
|
└── .agents/skills/lian-jing/ ← 项目内工作流说明
|
||||||
|
|
||||||
|
Codex 用户级插件
|
||||||
|
└── Skill-only:指导读取项目状态并调用已存在的 MCP;不存 project_id、MCP URL、凭证或部署命令
|
||||||
|
```
|
||||||
|
|
||||||
|
不在本模块实现 IDE UI、客户端项目同步、staging、云部署或生产发布。手工定义的同名 MCP server 必须继续拒绝覆盖。
|
||||||
|
|
||||||
|
## 任务卡
|
||||||
|
|
||||||
|
### ✅ P2-A. 工具无关的 canonical 项目上下文与兼容迁移
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, docs/ai-context/project-context.yaml
|
||||||
|
- acceptance: "[cargo test] 对仅含 `.claude/liangjing.json` 的临时项目执行接入/验证 → 写入 `.lian-jing/context.json` 且 project_id 一致;已有 context 的未知字段保留;手工 `[mcp_servers.lian-jing]` 配置仍拒绝覆盖。"
|
||||||
|
- done_note: "2026-09-21:inject_mcp 先写 `.lian-jing/context.json`(schema_version=1、project_id),再写 Codex/Claude 投影;canonical 文件保留未知字段,旧 Claude context 继续保留运行时端口。"
|
||||||
|
|
||||||
|
### 📋 P2-B. 能力包描述符与 onboarding 自检报告
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: P2-A. 工具无关的 canonical 项目上下文与兼容迁移
|
||||||
|
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/, docs/ai-context/project-context.yaml
|
||||||
|
- acceptance: "[cargo test] verify_onboarding(project_id) → 对 canonical 缺失、legacy-only、pack 版本不符、Codex MCP 不可用分别返回可区分的 ready/missing/legacy/version_mismatch/unavailable 信号,且不将任一状态伪报为 ready。"
|
||||||
|
|
||||||
|
### 📋 P2-C. Codex Skill-only 可移植能力包
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: P2-B. 能力包描述符与 onboarding 自检报告
|
||||||
|
- files: plugins/lian-jing-codex/plugin.json, plugins/lian-jing-codex/.codex-plugin/plugin.json, plugins/lian-jing-codex/skills/lian-jing/SKILL.md, plugins/README.md
|
||||||
|
- acceptance: "[插件结构检查] manifest 与 skill 路径均在插件根目录内,skill 不含 project_id、127.0.0.1 MCP URL、凭证或部署命令;[新 Codex 会话] 已接入项目可按 skill 调用既有 MCP,未接入项目输出接入诊断而非虚构状态。"
|
||||||
|
|
||||||
|
### 📋 P2-D. 接入包版本、升级说明与架构防腐同步
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: P2-B. 能力包描述符与 onboarding 自检报告, P2-C. Codex Skill-only 可移植能力包
|
||||||
|
- files: AGENTS.md, docs/ai-context/project-brief.md, docs/ai-context/project-context.yaml, docs/templates/agents-meta-rules.md, resources/onboarding/
|
||||||
|
- acceptance: "[cargo test] 接入包升级检查 → 旧 context/旧能力包给出明确 upgrade_hint 且不自动覆盖用户配置;[rg] 常驻架构文档将炼境描述为项目控制面、客户端描述为 adapter,且不把客户端插件表述为凭证或部署所有者。"
|
||||||
@ -9,7 +9,7 @@
|
|||||||
| 前端 | React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow |
|
| 前端 | React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow |
|
||||||
| 后端 | Rust + Tauri v2 |
|
| 后端 | Rust + Tauri v2 |
|
||||||
| 数据 | SQLite(rusqlite + r2d2) |
|
| 数据 | SQLite(rusqlite + r2d2) |
|
||||||
| MCP | 内嵌 HTTP Server(供 Claude Code 调用) |
|
| MCP | 内嵌 HTTP Server(供 Claude Code、Codex 等客户端调用) |
|
||||||
| 包管理 | pnpm |
|
| 包管理 | pnpm |
|
||||||
|
|
||||||
## 关键约定(改代码前必看)
|
## 关键约定(改代码前必看)
|
||||||
@ -18,6 +18,7 @@
|
|||||||
2. **DB 变更必须有 migration**:在 `src-tauri/src/db.rs` 的 `migrate()` 里补语句,否则旧版本升级报错
|
2. **DB 变更必须有 migration**:在 `src-tauri/src/db.rs` 的 `migrate()` 里补语句,否则旧版本升级报错
|
||||||
3. **Rust 业务函数依赖注入**:禁止内部 `pool().get()`,连接由调用方传入
|
3. **Rust 业务函数依赖注入**:禁止内部 `pool().get()`,连接由调用方传入
|
||||||
4. **接入包模板 = 分发给被管理项目的规则**:改 `resources/onboarding/` 务必同步 `docs/templates/`
|
4. **接入包模板 = 分发给被管理项目的规则**:改 `resources/onboarding/` 务必同步 `docs/templates/`
|
||||||
|
5. **项目身份上下文**:`.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;`.claude/liangjing.json` 仅保留为 Claude 兼容投影
|
||||||
|
|
||||||
## 目录速查
|
## 目录速查
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,13 @@ contracts:
|
|||||||
- "src-tauri/src/commands/gitea.rs"
|
- "src-tauri/src/commands/gitea.rs"
|
||||||
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"
|
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"
|
||||||
|
|
||||||
|
- label: 被管理项目身份上下文
|
||||||
|
paths:
|
||||||
|
- "src-tauri/src/mcp_inject.rs"
|
||||||
|
- ".lian-jing/context.json"
|
||||||
|
- ".claude/liangjing.json"
|
||||||
|
doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影。迁移必须保留未知字段,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。"
|
||||||
|
|
||||||
- label: Tauri command 接口(前后端契约)
|
- label: Tauri command 接口(前后端契约)
|
||||||
paths:
|
paths:
|
||||||
- "src-tauri/src/commands/**/*.rs"
|
- "src-tauri/src/commands/**/*.rs"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const MCP_KEY: &str = "lian-jing";
|
|||||||
const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
|
const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
|
||||||
const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START";
|
const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START";
|
||||||
const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END";
|
const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END";
|
||||||
|
const CONTEXT_SCHEMA_VERSION: u8 = 1;
|
||||||
|
|
||||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -44,8 +45,7 @@ pub(crate) fn project_root_with_conn(
|
|||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let (win_path, wsl_path, platform) =
|
let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||||||
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
|
||||||
|
|
||||||
if platform.as_deref() == Some("wsl") {
|
if platform.as_deref() == Some("wsl") {
|
||||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||||||
@ -71,38 +71,75 @@ pub(crate) fn project_root_with_conn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
|
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
|
||||||
Ok(project_root(project_id)?.join(".claude").join("settings.json"))
|
Ok(project_root(project_id)?
|
||||||
|
.join(".claude")
|
||||||
|
.join("settings.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn liangjing_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"))
|
Ok(project_root(project_id)?
|
||||||
|
.join(".claude")
|
||||||
|
.join("liangjing.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
|
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
|
||||||
Ok(project_root(project_id)?.join(".codex").join("config.toml"))
|
Ok(project_root(project_id)?.join(".codex").join("config.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。
|
fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
|
||||||
/// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导)
|
match std::fs::read_to_string(path) {
|
||||||
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
|
Ok(content) if content.trim().is_empty() => Ok(json!({})),
|
||||||
let path = liangjing_path(project_id)?;
|
Ok(content) => serde_json::from_str::<Value>(&content)
|
||||||
|
.map_err(|error| format!("读取 {label} 失败({}): {error}", path.display()))
|
||||||
|
.and_then(|value| {
|
||||||
|
if value.is_object() {
|
||||||
|
Ok(value)
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"{label} 顶层必须是 JSON 对象({})",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
|
||||||
|
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
|
||||||
}
|
}
|
||||||
let mut ctx = match std::fs::read_to_string(&path) {
|
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
|
||||||
Ok(s) => serde_json::from_str::<Value>(&s)
|
std::fs::write(path, content).map_err(|error| format!("写入 {label} 失败: {error}"))
|
||||||
.ok()
|
}
|
||||||
.filter(|v| v.is_object())
|
|
||||||
.unwrap_or_else(|| json!({})),
|
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
|
||||||
Err(_) => json!({}),
|
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
|
||||||
};
|
fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> {
|
||||||
|
let path = root.join(".lian-jing").join("context.json");
|
||||||
|
let mut ctx = read_context_object(&path, "canonical 项目上下文")?;
|
||||||
|
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
||||||
|
obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION));
|
||||||
|
obj.insert("project_id".into(), json!(project_id));
|
||||||
|
write_context_object(&path, &ctx, "canonical 项目上下文")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_canonical_context(project_id: &str) -> Result<(), String> {
|
||||||
|
let root = project_root(project_id)?;
|
||||||
|
write_canonical_context_at(&root, project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。
|
||||||
|
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
|
||||||
|
let path = liangjing_path(project_id)?;
|
||||||
|
let mut ctx = read_context_object(&path, "Claude 兼容上下文")?;
|
||||||
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
||||||
obj.insert("project_id".into(), json!(project_id));
|
obj.insert("project_id".into(), json!(project_id));
|
||||||
obj.insert("mcp_port".into(), json!(port));
|
obj.insert("mcp_port".into(), json!(port));
|
||||||
obj.remove("group_id");
|
obj.remove("group_id");
|
||||||
let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?;
|
write_context_object(&path, &ctx, "Claude 兼容上下文")
|
||||||
std::fs::write(&path, content)
|
|
||||||
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
|
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
|
||||||
@ -130,7 +167,10 @@ fn find_claude_bin() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
|
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
|
||||||
if let Ok(out) = std::process::Command::new("where").arg("claude.exe").output() {
|
if let Ok(out) = std::process::Command::new("where")
|
||||||
|
.arg("claude.exe")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
if let Ok(stdout) = String::from_utf8(out.stdout) {
|
if let Ok(stdout) = String::from_utf8(out.stdout) {
|
||||||
if let Some(line) = stdout.lines().next() {
|
if let Some(line) = stdout.lines().next() {
|
||||||
@ -257,11 +297,21 @@ fn try_register_globally(port: u16) -> bool {
|
|||||||
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
|
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
|
||||||
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
||||||
let Some(claude_bin) = find_claude_bin() else {
|
let Some(claude_bin) = find_claude_bin() else {
|
||||||
db::log_event("mcp_inject", "warn", "claude CLI 未找到,跳过旧注册清理", None);
|
db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
"claude CLI 未找到,跳过旧注册清理",
|
||||||
|
None,
|
||||||
|
);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let Some(git_bash) = find_git_bash() else {
|
let Some(git_bash) = find_git_bash() else {
|
||||||
db::log_event("mcp_inject", "warn", "git-bash 未找到,跳过旧注册清理", None);
|
db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
"git-bash 未找到,跳过旧注册清理",
|
||||||
|
None,
|
||||||
|
);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let Ok(conn) = db::pool().get() else {
|
let Ok(conn) = db::pool().get() else {
|
||||||
@ -303,8 +353,8 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
|
|||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(json!({}));
|
return Ok(json!({}));
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(path)
|
let content =
|
||||||
.map_err(|e| format!("读取 settings.json 失败: {}", e))?;
|
std::fs::read_to_string(path).map_err(|e| format!("读取 settings.json 失败: {}", e))?;
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
return Ok(json!({}));
|
return Ok(json!({}));
|
||||||
}
|
}
|
||||||
@ -319,8 +369,7 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
|
|||||||
|
|
||||||
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
||||||
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
|
||||||
}
|
}
|
||||||
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
|
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))
|
std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
|
||||||
@ -346,10 +395,12 @@ fn upsert_codex_mcp_config(path: &PathBuf, server_url: &str) -> Result<(), Strin
|
|||||||
block,
|
block,
|
||||||
¤t[end + CODEX_MCP_MANAGED_END.len()..]
|
¤t[end + CODEX_MCP_MANAGED_END.len()..]
|
||||||
),
|
),
|
||||||
(Some(_), _) | (_, Some(_)) => return Err(format!(
|
(Some(_), _) | (_, Some(_)) => {
|
||||||
|
return Err(format!(
|
||||||
"{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试",
|
"{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试",
|
||||||
path.display()
|
path.display()
|
||||||
)),
|
))
|
||||||
|
}
|
||||||
(None, None)
|
(None, None)
|
||||||
if current
|
if current
|
||||||
.lines()
|
.lines()
|
||||||
@ -394,7 +445,11 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
.trim_matches(['\r', '\n'])
|
.trim_matches(['\r', '\n'])
|
||||||
.to_string();
|
.to_string();
|
||||||
let output = if next.is_empty() { String::new() } else { format!("{next}\n") };
|
let output = if next.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{next}\n")
|
||||||
|
};
|
||||||
std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}"))
|
std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,6 +460,7 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
|
|||||||
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||||
let port = crate::mcp_server::read_port();
|
let port = crate::mcp_server::read_port();
|
||||||
let server_url = format!("http://127.0.0.1:{}/mcp", port);
|
let server_url = format!("http://127.0.0.1:{}/mcp", port);
|
||||||
|
write_canonical_context(project_id)?;
|
||||||
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?;
|
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?;
|
||||||
let path = settings_path(project_id)?;
|
let path = settings_path(project_id)?;
|
||||||
let mut settings = read_json(&path)?;
|
let mut settings = read_json(&path)?;
|
||||||
@ -432,8 +488,8 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
|||||||
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
|
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 写炼境上下文,让子项目 Claude 会话无需遍历即可知道 project_id
|
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
||||||
let _ = write_liangjing_context(project_id, port);
|
write_liangjing_context(project_id, port)?;
|
||||||
|
|
||||||
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
||||||
try_register_globally(port);
|
try_register_globally(port);
|
||||||
@ -492,7 +548,11 @@ pub fn migrate_to_single_server() {
|
|||||||
format!(";失败详情: {}", errors.join("; "))
|
format!(";失败详情: {}", errors.join("; "))
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let level = if errors.is_empty() && register_ok { "info" } else { "warn" };
|
let level = if errors.is_empty() && register_ok {
|
||||||
|
"info"
|
||||||
|
} else {
|
||||||
|
"warn"
|
||||||
|
};
|
||||||
db::log_event("mcp_inject", level, &summary, None);
|
db::log_event("mcp_inject", level, &summary, None);
|
||||||
|
|
||||||
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
|
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
|
||||||
@ -544,8 +604,14 @@ mod tests {
|
|||||||
/// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。
|
/// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_path_rejects_empty_and_relative() {
|
fn registered_path_rejects_empty_and_relative() {
|
||||||
assert!(validate_registered_path("", "win_path").is_err(), "空串应拒绝");
|
assert!(
|
||||||
assert!(validate_registered_path(" ", "win_path").is_err(), "空白应拒绝");
|
validate_registered_path("", "win_path").is_err(),
|
||||||
|
"空串应拒绝"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_registered_path(" ", "win_path").is_err(),
|
||||||
|
"空白应拒绝"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
validate_registered_path("src-tauri", "win_path").is_err(),
|
validate_registered_path("src-tauri", "win_path").is_err(),
|
||||||
"相对路径应拒绝(相对炼境 cwd 解析)"
|
"相对路径应拒绝(相对炼境 cwd 解析)"
|
||||||
@ -554,10 +620,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() {
|
fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() {
|
||||||
let base = std::env::temp_dir().join(format!(
|
let base =
|
||||||
"lianjing_codex_mcp_test_{}",
|
std::env::temp_dir().join(format!("lianjing_codex_mcp_test_{}", std::process::id()));
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
std::fs::create_dir_all(base.join(".codex")).unwrap();
|
std::fs::create_dir_all(base.join(".codex")).unwrap();
|
||||||
let path = base.join(".codex/config.toml");
|
let path = base.join(".codex/config.toml");
|
||||||
@ -570,7 +634,10 @@ mod tests {
|
|||||||
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
|
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
|
||||||
|
|
||||||
remove_codex_mcp_config(&path).unwrap();
|
remove_codex_mcp_config(&path).unwrap();
|
||||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "model = \"gpt-6\"\n");
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&path).unwrap(),
|
||||||
|
"model = \"gpt-6\"\n"
|
||||||
|
);
|
||||||
|
|
||||||
let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n";
|
let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n";
|
||||||
std::fs::write(&path, manual).unwrap();
|
std::fs::write(&path, manual).unwrap();
|
||||||
@ -579,6 +646,42 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() {
|
||||||
|
let base = std::env::temp_dir().join(format!(
|
||||||
|
"lianjing_canonical_context_test_{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(base.join(".claude")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
base.join(".claude/liangjing.json"),
|
||||||
|
r#"{"project_id":"legacy-project","mcp_port":27190}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
base.join(".lian-jing/context.json"),
|
||||||
|
r#"{"project_setting":"preserve-me"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
write_canonical_context_at(&base, "legacy-project").unwrap();
|
||||||
|
let context: Value = serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(base.join(".lian-jing/context.json")).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(context["schema_version"], CONTEXT_SCHEMA_VERSION);
|
||||||
|
assert_eq!(context["project_id"], "legacy-project");
|
||||||
|
assert_eq!(context["project_setting"], "preserve-me");
|
||||||
|
assert!(
|
||||||
|
!context.as_object().unwrap().contains_key("mcp_port"),
|
||||||
|
"运行时端口不应进入工具无关的 canonical context"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_path_accepts_absolute_and_unc() {
|
fn registered_path_accepts_absolute_and_unc() {
|
||||||
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());
|
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user