diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs index 22dd2b22f..974357894 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -884,12 +884,26 @@ fn copy_skill_runtime_tree( Ok(()) } -pub(crate) fn prepare_enabled_client_skill_root( +fn prepare_client_skill_runtime_root( + root: &Path, + index: &ClientExtensionIndex, isolated_os_home: &Path, ) -> Result, String> { - let root = extensions_root()?; - let index = read_index(&root)?; let client_skill_root = isolated_os_home.join(".agents").join("client-skills"); + + // This directory is a complete projection of the currently enabled + // client Skills. Remove the previous projection before copying so a + // disabled, deleted, renamed, or otherwise changed Skill cannot remain + // discoverable through the extra root. + if client_skill_root.exists() { + fs::remove_dir_all(&client_skill_root).map_err(|error| { + format!( + "清理客户端 Skill 运行时目录失败:{}: {error}", + client_skill_root.display() + ) + })?; + } + let mut copied_any = false; for item in index @@ -927,6 +941,14 @@ pub(crate) fn prepare_enabled_client_skill_root( Ok(copied_any.then_some(client_skill_root)) } +pub(crate) fn prepare_enabled_client_skill_root( + isolated_os_home: &Path, +) -> Result, String> { + let root = extensions_root()?; + let index = read_index(&root)?; + prepare_client_skill_runtime_root(&root, &index, isolated_os_home) +} + pub(crate) fn prepare_enabled_client_mcp_servers() -> Result, String> { let root = extensions_root()?; let mut index = read_index(&root)?; @@ -1385,6 +1407,83 @@ mod tests { ); } + #[test] + fn client_skill_runtime_root_removes_stale_disabled_and_renamed_items() { + let directory = tempfile::tempdir().expect("temp directory"); + let extension_root = directory.path().join("extensions"); + let source_root = extension_root.join("sources/source-1"); + let skill_a = source_root.join("skill-a/SKILL.md"); + let skill_b = source_root.join("skill-b/SKILL.md"); + fs::create_dir_all(skill_a.parent().expect("skill-a parent")).expect("create skill-a"); + fs::create_dir_all(skill_b.parent().expect("skill-b parent")).expect("create skill-b"); + fs::write(&skill_a, "---\nname: skill-a\n---\n").expect("write skill-a"); + fs::write(&skill_b, "---\nname: skill-b\n---\n").expect("write skill-b"); + + let source = ImportedExtensionSource { + id: "source-1".to_string(), + original_name: "fixture".to_string(), + storage_path: "sources/source-1".to_string(), + fingerprint: "source-fingerprint".to_string(), + }; + let mut index = ClientExtensionIndex { + schema_version: CLIENT_EXTENSIONS_SCHEMA_VERSION.to_string(), + sources: vec![source], + items: vec![ + StoredExtensionItem { + id: "item-a".to_string(), + source_id: "source-1".to_string(), + extension_type: "skill".to_string(), + name: "skill-a".to_string(), + original_name: "skill-a".to_string(), + source_relative_path: "skill-a/SKILL.md".to_string(), + enabled: true, + fingerprint: "skill-a-fingerprint".to_string(), + last_error: None, + mcp_config: None, + }, + StoredExtensionItem { + id: "item-b".to_string(), + source_id: "source-1".to_string(), + extension_type: "skill".to_string(), + name: "skill-b".to_string(), + original_name: "skill-b".to_string(), + source_relative_path: "skill-b/SKILL.md".to_string(), + enabled: true, + fingerprint: "skill-b-fingerprint".to_string(), + last_error: None, + mcp_config: None, + }, + ], + }; + let isolated_home = directory.path().join("home"); + let runtime_root = isolated_home.join(".agents").join("client-skills"); + + prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home) + .expect("prepare initial Skill runtime root"); + assert!(runtime_root.join("skill-a/SKILL.md").is_file()); + assert!(runtime_root.join("skill-b/SKILL.md").is_file()); + + index.items[0].enabled = false; + prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home) + .expect("remove disabled Skill from runtime root"); + assert!(!runtime_root.join("skill-a").exists()); + assert!(runtime_root.join("skill-b/SKILL.md").is_file()); + + index.items[1].name = "renamed-skill".to_string(); + prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home) + .expect("refresh renamed Skill runtime root"); + assert!(!runtime_root.join("skill-b").exists()); + assert!(runtime_root.join("renamed-skill/SKILL.md").is_file()); + + index.items[1].enabled = false; + assert_eq!( + prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home) + .expect("clear empty Skill runtime root"), + None + ); + assert!(!runtime_root.exists()); + } + #[test] fn client_skill_fingerprint_tracks_enabled_names_and_content() { let mut index = ClientExtensionIndex::default(); diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index b0758c96e..f9f45c65f 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -306,6 +306,8 @@ MCP · 来自 my-plugin.zip 启动失败 调用 skills/list 复核 ``` +Skill root 是当前启用 Skill 集合的完整投影;每次准备时先清理已有运行时目录,再重新复制当前有效项,避免禁用、删除、重命名或源内容变化后的旧文件继续被 Codex 发现。 + 每个独立 Skill 的启用状态单独生效。Skill 内容中的脚本、二进制和引用资源不做行为扫描。 ### 8.2 MCP