From cb7195ca79b69963ac43d07c03c36c393154a7e0 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 11 Nov 2026 21:35:27 +0800 Subject: [PATCH 1/5] fix(skills): discover shared installations and honor native state --- EXTENSIONS.md | 2 +- .../src-tauri/src/commands.rs | 195 ++-- .../src/ManagementExtra.tsx | 198 +++- .../src/ManagementPages.tsx | 4 +- apps/codex-plus-manager/src/i18n-en.ts | 7 + .../src/skill-agent-batch.ts | 14 +- .../src/skill-group-ui.test.ts | 95 +- .../src/extension_manager_data.rs | 11 +- crates/codex-plus-core/src/skills.rs | 844 ++++++++++++++++-- .../tests/all/extension_manager_data.rs | 37 + .../tests/all/skills_lifecycle.rs | 42 +- types/codex-plus-extensions.d.ts | 2 +- 12 files changed, 1227 insertions(+), 224 deletions(-) diff --git a/EXTENSIONS.md b/EXTENSIONS.md index e9bdafb9c8..1e36f35bf4 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -1109,7 +1109,7 @@ codexPlus.extension((ctx) => { | `gatewayRouting` | `{ groups, autoGroups }`:组的 `id/name/mode/sticky/members[{model,weight}]/hideMembers/pick`;自动组额外给 `hidden/customized` | | `gatewayHealth` | `{ scope: "managerProcess", providers, speeds, agents }`:供应商熔断状态、剩余冷却时间、连续错误数;模型首字耗时样本;Agent 请求/改道/失败计数及进行中的供应商 id。不返回 Key id、错误原文、完整请求轨迹 | | `gatewayQuota` | `{ scope: "managerProcess", cachedOnly: true, providers: [{ id, name, quota }] }`:已有额度缓存,`quota` 为 `{ windows: [{ windowMinutes, usedPercent, resetsAtMs }], updatedAtMs }`;无缓存时为 `null`,不是零额度 | -| `installedSkills` | `{ skills: [{ id, name, enabled, bundled, managed, agents, contentHash }] }`:本地已装技能;`enabled` 对应 Codex,其他 Agent 看 `agents`;不读远端目录,版本以内容哈希表示 | +| `installedSkills` | `{ skills: [{ id, name, enabled, bundled, managed, agents, contentHash, statusError? }] }`:本地已装技能;`enabled` 对应 Codex,无法确定时为 `null` 并提供 `statusError`;其他 Agent 看 `agents`;不读远端目录,版本以内容哈希表示 | | `installedPlugins` | `{ plugins: [{ id, name, version, source, enabled }] }`:仅 Codex++ 插件市场托管的本地安装记录,不代表全部 Codex 插件;`source` 仅 `public/full`,`id` 沿用原生 `name@market` 标识 | | `agentCollaboration` | `{ enabled, agents, tasks, total }`:Agent 的 `id/name/enabled/model/maxConcurrentTasks`;任务的 `id/agentId/status/createdAtMs/updatedAtMs/cancelRequested`。参数 `{ limit }` 默认 50、范围 1–200,按更新时间倒序、同时间按 id 升序 | diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 64069b3bf4..8d8a231d3a 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -913,6 +913,7 @@ pub struct ScriptMarketPayload { #[serde(rename_all = "camelCase")] pub struct SkillsPayload { pub skills: Vec, + pub inventory_ok: bool, pub repos: Vec, pub repository_skills: Vec, pub repo_counts: std::collections::BTreeMap, @@ -7285,7 +7286,7 @@ pub async fn extension_manager_data(key: String, name: String, params: Option public::gateway_quota(&gateway, gateway::quota::get), }) } - "installedSkills" => Ok(public::installed_skills(&default_skills_manager().local_inventory())), + "installedSkills" => Ok(public::installed_skills(&default_skills_manager().try_local_inventory()?)), "installedPlugins" => codex_plus_core::plugin_market::installed_summary(&codex_plus_core::codex_home::default_codex_home_dir()), "agentCollaboration" => { let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(50).clamp(1, 200) as usize; @@ -7405,7 +7406,7 @@ pub fn delete_user_script(key: String) -> CommandResult { /// /// 单个仓库失败(限流、网络、仓库删了)不影响其它仓库,错误单独收集回前端。 #[tauri::command] -pub async fn refresh_skill_catalog() -> CommandResult { +pub async fn refresh_skill_catalog() -> Result, String> { let manager = default_skills_manager(); let repos = manager.list_repos(); let mut repo_errors = Vec::new(); @@ -7456,20 +7457,28 @@ pub async fn refresh_skill_catalog() -> CommandResult { } else { format!("Skills 列表已刷新,{} 个仓库拉取失败。", repo_errors.len()) }; - let payload = skills_payload(&manager, &all_cached_repo_skills(), repo_errors); - if payload.repo_errors.is_empty() { - ok(&message, payload) - } else { - failed(&message, payload) - } + tauri::async_runtime::spawn_blocking(move || { + let payload = skills_payload(&manager, &all_cached_repo_skills(), repo_errors); + if payload.repo_errors.is_empty() { + ok(&message, payload) + } else { + failed(&message, payload) + } + }).await.map_err(|error| format!("读取 Skill 目录失败:{error}")) } /// 只读本地状态,不联网。切到 Skills 页时先用它把已装的列出来。 #[tauri::command] -pub fn list_installed_skills() -> CommandResult { - let manager = default_skills_manager(); - let payload = current_skills_payload(&manager); - ok("已加载本地 Skills。", payload) +pub async fn list_installed_skills() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| { + let manager = default_skills_manager(); + let payload = current_skills_payload(&manager); + if payload.inventory_ok { + ok("已加载本地 Skills。", payload) + } else { + failed("读取本地 Skills 失败。", payload) + } + }).await.map_err(|error| format!("读取本地 Skills 失败:{error}")) } /// 导入 cc-switch 已安装的 Skills。cc-switch 的 Skill 文件位于 @@ -7658,22 +7667,24 @@ async fn install_or_update_skill( } #[tauri::command] -pub fn set_skill_enabled(id: String, enabled: bool) -> CommandResult { - let manager = default_skills_manager(); - match manager.set_enabled(id.trim(), enabled) { - Ok(()) => ok( - if enabled { - "Skill 已启用,下次对话生效。" - } else { - "Skill 已停用。" - }, - current_skills_payload(&manager), - ), - Err(error) => failed( - &format!("Skill 启停失败:{error}"), - current_skills_payload(&manager), - ), - } +pub async fn set_skill_enabled(id: String, enabled: bool) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_manager(); + match manager.set_enabled(id.trim(), enabled) { + Ok(()) => ok( + if enabled { + "Skill 已启用,下次对话生效。" + } else { + "Skill 已停用。" + }, + current_skills_payload(&manager), + ), + Err(error) => failed( + &format!("Skill 启停失败:{error}"), + current_skills_payload(&manager), + ), + } + }).await.map_err(|error| format!("Skill 启停任务失败:{error}")) } #[tauri::command] @@ -7816,6 +7827,18 @@ pub async fn inspect_skill_repo( match codex_plus_core::skills::resolve_repo_skills(repo.clone(), &snapshot.skills).await { Ok((resolved, skills)) => { let count = skills.len(); + let worker_manager = manager.clone(); + let worker_skills = skills.clone(); + let entries = match tauri::async_runtime::spawn_blocking(move || { + worker_manager.try_merge_entries(&worker_skills) + }).await.map_err(|error| format!("读取 Skill 状态任务失败:{error}")) + .and_then(|result| result.map_err(|error| format!("{error:#}"))) { + Ok(entries) => entries, + Err(error) => return failed( + &format!("读取 Skill 状态失败:{error}"), + SkillRepositoryPayload { repo: resolved, skills: Vec::new(), skill_count: count }, + ), + }; // 预览未保存仓库不会修改仓库设置。 { let _coordination = skills_repo_coordination(); @@ -7829,9 +7852,7 @@ pub async fn inspect_skill_repo( &format!("已解析仓库,发现 {count} 个 Skill。"), SkillRepositoryPayload { repo: resolved, - skills: manager - .merge_entries(&skills) - .into_iter() + skills: entries.into_iter() .filter(|s| { skills .iter() @@ -7872,19 +7893,10 @@ pub fn delete_skill_repo(key: String) -> CommandResult { fn skills_payload( manager: &codex_plus_core::skills::SkillsManager, remote: &[codex_plus_core::skills::RemoteSkill], - repo_errors: Vec, + mut repo_errors: Vec, ) -> SkillsPayload { let repos = manager.list_repos(); let all_remote = all_cached_repo_skills(); - let repository_skills = manager - .merge_entries(&all_remote) - .into_iter() - .filter(|skill| { - all_remote - .iter() - .any(|r| r.id == skill.id && r.repo_key == skill.repo_key) - }) - .collect(); let repo_counts = repo_skill_cache() .lock() .ok() @@ -7909,12 +7921,43 @@ fn skills_payload( }) .cloned() .collect(); + let mut reused_inventory = None; + let repository_skills = if all_remote.is_empty() { + Vec::new() + } else { + match manager.try_merge_entries(&all_remote) { + Ok(entries) => { + let repository = entries.iter().filter(|skill| + all_remote.iter().any(|r| r.id == skill.id && r.repo_key == skill.repo_key) + ).cloned().collect(); + if remote == all_remote { reused_inventory = Some(Ok(entries)); } + repository + } + Err(error) => { + if remote == all_remote { + reused_inventory = Some(Err(error)); + } else { + repo_errors.push(format!("读取 Skill 状态失败:{error:#}")); + } + Vec::new() + } + } + }; + let skills = reused_inventory.unwrap_or_else(|| { + if remote.is_empty() { manager.try_local_inventory() } else { manager.try_merge_entries(&remote) } + }); + let inventory_ok = skills.is_ok(); + let skills = match skills { + Ok(skills) => skills, + Err(error) => { + let message = format!("读取 Skill 状态失败:{error:#}"); + if !repo_errors.contains(&message) { repo_errors.push(message); } + Vec::new() + } + }; SkillsPayload { - skills: if remote.is_empty() { - manager.local_inventory() - } else { - manager.merge_entries(&remote) - }, + skills, + inventory_ok, repos, repository_skills, repo_counts, @@ -7935,7 +7978,7 @@ fn current_skills_payload(manager: &codex_plus_core::skills::SkillsManager) -> S } pub(crate) fn default_skills_manager() -> codex_plus_core::skills::SkillsManager { - codex_plus_core::skills::SkillsManager::new( + let manager = codex_plus_core::skills::SkillsManager::new( codex_plus_core::paths::default_skills_source_dir(), codex_plus_core::paths::default_skill_backups_dir(), codex_plus_core::paths::default_skills_state_path(), @@ -7951,36 +7994,48 @@ pub(crate) fn default_skills_manager() -> codex_plus_core::skills::SkillsManager "grok".to_string(), codex_plus_core::grok_config::default_grok_home_dir().join("skills"), ), - ]) + ]); + let shared = std::env::var_os("CODEX_PLUS_SHARED_SKILLS_DIR") + .map(std::path::PathBuf::from) + .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".agents").join("skills"))); + match shared { + Some(path) => manager.with_shared_skills_dir(path), + None => manager, + } } /// 把 Skill 启用到 / 停用于某个 Agent(codex / claude / grok)。 #[tauri::command] -pub fn set_skill_enabled_for( +pub async fn set_skill_enabled_for( agent: String, id: String, enabled: bool, -) -> CommandResult { - let manager = default_skills_manager(); - let name = match agent.as_str() { - "claude" => "Claude Code", - "grok" => "Grok", - _ => "Codex", - }; - match manager.set_enabled_for(agent.trim(), id.trim(), enabled) { - Ok(()) => ok( - &if enabled { - format!("已启用到 {name},下次对话生效。") - } else { - format!("已在 {name} 停用。") - }, - current_skills_payload(&manager), - ), - Err(error) => failed( - &format!("Skill 启停失败:{error:#}"), - current_skills_payload(&manager), - ), - } + include_inventory: Option, +) -> Result>, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_manager(); + let name = match agent.as_str() { + "claude" => "Claude Code", + "grok" => "Grok", + _ => "Codex", + }; + let result = manager.set_enabled_for(agent.trim(), id.trim(), enabled); + let payload = include_inventory.unwrap_or(true).then(|| current_skills_payload(&manager)); + match result { + Ok(()) => ok( + &if enabled { + format!("已启用到 {name},下次对话生效。") + } else { + format!("已在 {name} 停用。") + }, + payload, + ), + Err(error) => failed( + &format!("Skill 启停失败:{error:#}"), + payload, + ), + } + }).await.map_err(|error| format!("Skill 启停任务失败:{error}")) } /// 上一次成功拉取的远端清单,按仓库 key 存。 diff --git a/apps/codex-plus-manager/src/ManagementExtra.tsx b/apps/codex-plus-manager/src/ManagementExtra.tsx index 5f3a42347a..811b1321d0 100644 --- a/apps/codex-plus-manager/src/ManagementExtra.tsx +++ b/apps/codex-plus-manager/src/ManagementExtra.tsx @@ -18,21 +18,31 @@ import "./ManagementPages.css"; import { parseSkillRepository } from "./skill-repository"; import { SkillListViewport } from "./SkillListViewport"; import { customSkillGroupKey, groupSkills, legacySkillCustomGroupName, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName, type SkillGroup, type SkillGroupSortDirection } from "./skill-groups"; -import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchState, skillBatchTargets, SkillBatchRegistry, type PendingSkillBatchOperation } from "./skill-agent-batch"; +import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchState, skillBatchTargets, skillCodexStatusError, SkillBatchRegistry, type PendingSkillBatchOperation } from "./skill-agent-batch"; import { TopbarActions } from "./TopbarActions"; type Result = { status: string; message: string }; type Notify = (title: string, message: string, status?: "ok" | "failed") => Promise; +type SkillLocation = { manifestPath: string; rootKind: string; managed: boolean; enabled: boolean; statusError?: string | null }; type Skill = { id: string; name: string; description: string; repoKey: string; repoPath?: string; installed: boolean; enabled: boolean; bundled: boolean; updateAvailable: boolean; /** 已经启用到哪些 Agent(codex / claude / grok)。 */ agents?: string[]; /** Codex++ 的受控源里有它;只在某个 Agent 自己目录里的 Skill 为 false。 */ - managed?: boolean }; + managed?: boolean; locations?: SkillLocation[]; conflict?: string | null }; type Repo = { owner: string; name: string; branch: string; subdir: string; enabled: boolean }; -type SkillPayload = Result & { skills: Skill[]; repos: Repo[]; repositorySkills: Skill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string; agentSkillsDirs?: Record }; +type SkillPayload = Result & { skills: Skill[]; inventoryOk: boolean; repos: Repo[]; repositorySkills: Skill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string; agentSkillsDirs?: Record }; type RepoInspection = Result & { repo: Repo; skills: Skill[]; skillCount: number }; const SKILL_GROUP_STORAGE_KEY = "codex-plus-plus.skill-groups.v1"; const skillBatchRegistry = new SkillBatchRegistry(); +const skillPageCache: { + value: SkillPayload | null; + localStartedAt: number; + catalogStartedAt: number; + localPending: Promise | null; + catalogPending: Promise | null; + mounted: number; +} = { value: null, localStartedAt: 0, catalogStartedAt: 0, localPending: null, catalogPending: null, mounted: 0 }; +skillBatchRegistry.subscribeSettled(() => { skillPageCache.localStartedAt = 0; }); const ok = (r: Result) => r.status === "ok"; function Button({ children, onClick, disabled, primary }: { children: ReactNode; onClick: () => void; disabled?: boolean; primary?: boolean }) { return ; } function SearchBox({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) { return
onChange(e.target.value)} />
; } @@ -50,7 +60,7 @@ function parseRepoInput(value: string, current: Repo): Repo | null { } /** 后端 `LOCAL_IMPORT_REPO_KEY`:从本地目录导入的 Skill(#511)。 */ const LOCAL_IMPORT_REPO_KEY = "local"; -function sourceLabel(skill: Skill) { if (skill.bundled) return t("内置"); if (skill.repoKey === "cc-switch") return "cc-switch"; if (skill.repoKey === LOCAL_IMPORT_REPO_KEY) return t("本地导入"); return skill.repoKey ? skill.repoKey.split("@", 1)[0] : t("用户"); } +function sourceLabel(skill: Skill) { if (skill.bundled) return t("内置"); if (skill.repoKey === "cc-switch" || !skill.repoKey) return t("本地"); if (skill.repoKey === LOCAL_IMPORT_REPO_KEY) return t("本地导入"); return skill.repoKey.split("@", 1)[0]; } function repoUrl(skill: Skill) { if (!skill.repoKey || skill.repoKey === "cc-switch" || skill.repoKey === LOCAL_IMPORT_REPO_KEY) return ""; const parts = skill.repoKey.split("@")[0].split("/"); @@ -187,7 +197,16 @@ function SkillRepoDrawer({ repos, catalog, counts, initialKey, busy, updates, on } function SkillDetailDrawer({ skill, count, onClose, onOpenUrl, onOpenRepo }: { skill: Skill; count?: number; onClose: () => void; onOpenUrl: (url: string) => void; onOpenRepo: () => void }) { const url = repoUrl(skill); - return
{t("来源")}
{url ? : sourceLabel(skill)}
{t("Skill ID")}
{skill.id}
{t("仓库路径")}
{skill.repoPath || "—"}
{t("介绍")}
{skill.description || t("暂无描述")}
{url && <>
GitHub
}
; + const labels: Record = { shared: t("共享"), codex: "Codex", claude: "Claude Code", grok: "Grok", system: "Codex" }; + return + {skill.conflict &&

{skill.conflict}

} +
{t("来源")}
{url ? : sourceLabel(skill)}
{t("Skill ID")}
{skill.id}
{t("仓库路径")}
{skill.repoPath || "—"}
{t("介绍")}
{skill.description || t("暂无描述")}
{url && <>
GitHub
} + {skill.locations?.flatMap((location) => [ +
{labels[location.rootKind] || location.rootKind}
, +
{location.manifestPath}
{location.managed ? t("Codex++ 受控") : t("本地安装")} · {location.statusError ? t("状态未知") : location.enabled ? t("已启用") : t("未启用")}{location.statusError && <>
{location.statusError}}
, + ])} +
+
; } function SortableSkillGroup({ group, disabled, children }: { group: SkillGroup; disabled: boolean; children: (handle: ReactNode) => ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.key, disabled }); @@ -221,8 +240,9 @@ function GroupAgentSelect({ group, agent, busy, operations, count, onChange }: { ; } export function SkillsPage({ notify }: { notify: Notify }) { - const [dataState, setDataState] = useState<{ version: number; value: SkillPayload | null }>({ version: 0, value: null }); + const [dataState, setDataState] = useState<{ version: number; value: SkillPayload | null }>(() => ({ version: 0, value: skillPageCache.value })); const data = dataState.value; + useEffect(() => { skillPageCache.value = data; }, [data]); const [tab, setTab] = useState<"discover" | "installed">("installed"); const [query, setQuery] = useState(""); const [busy, setBusy] = useState(false); @@ -231,11 +251,15 @@ export function SkillsPage({ notify }: { notify: Notify }) { const updateFailures = useSyncExternalStore(skillBatchRegistry.subscribeFailures, skillBatchRegistry.getFailures, skillBatchRegistry.getFailures); const batchOwner = useRef(Symbol("skills-page")); const mounted = useRef(false); + const loadingRequests = useRef(0); + const catalogRequests = useRef(0); const dataRequest = useRef(0); + const localReload = useRef<{ pending: Promise | null; dirty: boolean }>({ pending: null, dirty: false }); const [repoOpen, setRepoOpen] = useState(false); const [detail, setDetail] = useState(null); const [repoSelection, setRepoSelection] = useState(null); const [loading, setLoading] = useState(false); + const [refreshingCatalog, setRefreshingCatalog] = useState(() => !!skillPageCache.catalogPending); // 当前使用模式下隐藏的 Agent 不占列:只管理 Codex 时只有 Codex,只用增强功能时是 Codex、Claude Code。 const focus = useAgentFocus(); const agentColumns: McpAgentId[] = MCP_AGENTS.filter((agent) => agentVisible(focus, agent)); @@ -255,44 +279,120 @@ export function SkillsPage({ notify }: { notify: Notify }) { useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - const reloadLocal = async () => { - const request = ++dataRequest.current; - try { - const result = await invoke("list_installed_skills"); - if (!mounted.current || request !== dataRequest.current) return; - startTransition(() => setDataState((current) => mounted.current && request === dataRequest.current && request >= current.version - ? { version: request, value: result } : current)); - } catch (error) { - if (mounted.current) await notify("Skills", String(error), "failed"); + const requestPayload = (command: string) => { + const local = command === "list_installed_skills"; + const pendingKey = local ? "localPending" : "catalogPending"; + const startedKey = local ? "localStartedAt" : "catalogStartedAt"; + let pending = skillPageCache[pendingKey]; + if (!pending) { + const startedAt = Date.now(); + skillPageCache[startedKey] = startedAt; + pending = invoke(command); + skillPageCache[pendingKey] = pending; + const completed = () => { + if (skillPageCache[pendingKey] !== pending) return; + skillPageCache[pendingKey] = null; + if (skillPageCache[startedKey] === startedAt) { + skillPageCache[startedKey] = skillPageCache.mounted ? Date.now() : 0; + } + }; + void pending.then(completed, completed); } + return pending; + }; + const reloadLocal = () => { + const state = localReload.current; + if (state.pending) { state.dirty = true; return state.pending; } + state.pending = (async () => { + try { + do { + state.dirty = false; + const pending = skillPageCache.localPending; + if (pending) { + try { await pending; } + catch (error) { if (mounted.current) await notify("Skills", String(error), "failed"); } + if (!mounted.current) return; + } + state.dirty = false; + const request = ++dataRequest.current; + try { + const result = await requestPayload("list_installed_skills"); + if (!mounted.current) return; + if (request === dataRequest.current) startTransition(() => applyCurrentPayload(request, result)); + if (!ok(result)) await notify("Skills", result.message, "failed"); + } catch (error) { + if (mounted.current) await notify("Skills", String(error), "failed"); + } + } while (state.dirty && mounted.current); + } finally { + state.pending = null; + } + })(); + return state.pending; }; - const applyCurrentPayload = (request: number, payload: SkillPayload) => { + const applyCurrentPayload = (request: number, payload: SkillPayload, reloadIfStale = true) => { if (!mounted.current) return; - if (request !== dataRequest.current) { void reloadLocal(); return; } - setDataState((current) => mounted.current && request === dataRequest.current && request >= current.version - ? { version: request, value: payload } : current); + if (request !== dataRequest.current) { if (reloadIfStale) void reloadLocal(); return; } + setDataState((current) => { + if (!mounted.current || request !== dataRequest.current || request < current.version) return current; + const previous = current.value; + const value = previous && !payload.inventoryOk + ? { ...payload, skills: previous.skills, repos: previous.repos, + repositorySkills: previous.repositorySkills, repoCounts: previous.repoCounts } + : payload; + return { version: request, value }; + }); }; - const load = async (command = "refresh_skill_catalog") => { + const load = async (command = "refresh_skill_catalog", automatic = false) => { + const local = command === "list_installed_skills"; + const pendingKey = local ? "localPending" : "catalogPending"; + const startedKey = local ? "localStartedAt" : "catalogStartedAt"; + if (automatic && local && skillPageCache.value && skillBatchRegistry.getSnapshot().length) return; + if (automatic && !skillPageCache[pendingKey] && skillPageCache.value + && Date.now() - skillPageCache[startedKey] < (local ? 30_000 : 300_000)) return; + loadingRequests.current++; setLoading(true); + if (!local) { + catalogRequests.current++; + setRefreshingCatalog(true); + } const request = ++dataRequest.current; try { - const r = await invoke(command); - startTransition(() => applyCurrentPayload(request, r)); - if (!ok(r)) await notify("Skills", r.message, "failed"); + const r = await requestPayload(command); + if (local && !skillPageCache.localStartedAt) { + if (mounted.current) void reloadLocal(); + } else { + startTransition(() => applyCurrentPayload(request, r, !local)); + } + if (mounted.current && !ok(r)) await notify("Skills", r.message, "failed"); } catch (e) { - await notify("Skills", String(e), "failed"); + if (mounted.current) await notify("Skills", String(e), "failed"); } finally { - setLoading(false); + loadingRequests.current--; + if (mounted.current && !loadingRequests.current) setLoading(false); + if (!local) { + catalogRequests.current--; + if (mounted.current && !catalogRequests.current) setRefreshingCatalog(false); + } } }; useEffect(() => { let active = true; mounted.current = true; + skillPageCache.mounted++; + const catalogPending = skillPageCache.catalogPending; + if (catalogPending) void load("refresh_skill_catalog", true); + else setRefreshingCatalog(false); void (async () => { - await load("list_installed_skills"); - if (active) await load(); + await load("list_installed_skills", true); + const pending = skillPageCache.localPending; + if (active && pending) { + try { await pending; } + catch (error) { if (mounted.current) await notify("Skills", String(error), "failed"); } + } + if (active && !catalogPending) await load("refresh_skill_catalog", true); })(); - return () => { active = false; mounted.current = false; dataRequest.current++; }; + return () => { active = false; mounted.current = false; skillPageCache.mounted--; dataRequest.current++; }; }, []); useEffect(() => skillBatchRegistry.subscribeSettled((owner) => { if (mounted.current && owner !== batchOwner.current) void reloadLocal(); @@ -365,6 +465,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { skillBatchRegistry.add(operation); let completed = 0; let failed = 0; + let acceptedInventory = false; const errors: string[] = []; const recordFailure = (skill: Skill, reason: string) => { failed++; @@ -373,17 +474,28 @@ export function SkillsPage({ notify }: { notify: Notify }) { errors.push(detail.length > 240 ? `${detail.slice(0, 239).replace(/[\uD800-\uDBFF]$/, "")}…` : detail); }; try { - for (const skill of targets) { + for (const [index, skill] of targets.entries()) { + const last = index === targets.length - 1; try { const { request, result } = await skillBatchRegistry.run(async () => { const request = ++dataRequest.current; - const result = await invoke("set_skill_enabled_for", { agent, id: skill.id, enabled }); + const result = await invoke>("set_skill_enabled_for", { + agent, id: skill.id, enabled, ...(!last && { includeInventory: false }), + }); return { request, result }; }); - if (mounted.current && request === dataRequest.current) { + if (last && mounted.current && request === dataRequest.current && result.skills && result.inventoryOk) { + applyCurrentPayload(request, result as SkillPayload); + acceptedInventory = true; + } else if (mounted.current && request === dataRequest.current && ok(result)) { setDataState((current) => { - if (!mounted.current || request < current.version || !current.value || !result.skills) return current; - return { version: request, value: { ...current.value, skills: mergeSkillAgentResult(current.value.skills, result.skills, skill.id, agent) } }; + if (!mounted.current || request < current.version || !current.value) return current; + const skills = result.skills || current.value.skills.filter((item) => item.id === skill.id).map((item) => { + const agents = skillAgents(item).filter((value) => value !== agent); + if (enabled) agents.push(agent); + return { ...item, agents, enabled: agent === "codex" ? enabled : item.enabled }; + }); + return { version: request, value: { ...current.value, skills: mergeSkillAgentResult(current.value.skills, skills, skill.id, agent) } }; }); } else if (mounted.current) void reloadLocal(); if (ok(result)) completed++; @@ -397,7 +509,9 @@ export function SkillsPage({ notify }: { notify: Notify }) { } } finally { skillBatchRegistry.remove(operation); - if (mounted.current) void reloadLocal(); + skillBatchRegistry.notifySettled(batchOwner.current); + if (mounted.current && !acceptedInventory) void reloadLocal(); + if (mounted.current && acceptedInventory) skillPageCache.localStartedAt = Date.now(); } const message = tf("已批量{0} {1} 个 Skill", [enabled ? t("启用") : t("停用"), completed]); await notify("Skills", failed ? `${message};${tf("{0} 个 Skill 失败", [failed])}:${errors.join(";")}` : message, failed ? "failed" : "ok"); @@ -514,7 +628,8 @@ export function SkillsPage({ notify }: { notify: Notify }) { const renderSkillRow = (s: Skill) => { const update = s.bundled ? undefined : updates.find((item) => item.id === s.id); // 只卸载 Codex++ 管理的或 Codex 目录里的;Claude / Grok 自己目录里的 Skill 不动。 - const canUninstall = s.installed && !s.bundled && (s.managed || skillAgents(s).includes("codex") || s.agents === undefined); + const canUninstall = s.installed && !s.bundled && !s.locations?.some((location) => location.rootKind === "shared") && !s.conflict && (s.managed || skillAgents(s).includes("codex") || s.agents === undefined); + const codexError = skillCodexStatusError(s); const assignment = skillGroupAssignment(s, customGroupNames, customGroupAssignments, assignmentsByIdentity); const on = s.installed && agentColumns.some((agent) => skillAgents(s).includes(agent)); return {sourceLabel(s)} + {s.conflict ? {t("同名冲突")} : null} + {codexError ? {t("Codex 状态未知")} : null} {s.updateAvailable ? {t("可更新")} : null} } detail={{s.description || t("暂无描述")}} onClick={() => setDetail(s)}> - {update ? : !s.installed && !s.bundled ? : s.installed && s.updateAvailable ? : null} + {update ? : !s.installed && !s.bundled ? : s.installed && s.updateAvailable && !s.conflict && !s.locations?.some((location) => location.rootKind === "shared") ? : null}
{agentColumns.map((agent) => void run("set_skill_enabled_for", { agent, id: s.id, enabled: !skillAgents(s).includes(agent) })} />)}
@@ -552,7 +670,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { const sortLabel = `${t("按名称排序")} (${groupSortDirection === "asc" ? "A-Z" : groupSortDirection === "desc" ? "Z-A" : "A-Z / Z-A"})`; return
} title="Skills" actions={<> - + void importLocalSkills()} disabled={busy}>{t("从本地目录导入")} void run("import_ccs_skills", {})} disabled={busy}>{t("从 cc-switch 导入")} @@ -591,7 +709,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { group.key)} strategy={verticalListSortingStrategy}>{groups.map((group) => { const collapsed = !expandedGroups.has(group.key) && !query.trim(); const allGroupItems = allGroups.find((item) => item.key === group.key)?.items || group.items; - const updateTargets = allGroupItems.filter((skill) => skill.installed && !skill.bundled && skill.updateAvailable); + const updateTargets = allGroupItems.filter((skill) => skill.installed && !skill.bundled && !skill.conflict && !skill.locations?.some((location) => location.rootKind === "shared") && skill.updateAvailable); const pendingUpdates = allGroupItems.filter((skill) => skill.installed && !skill.bundled && updates.some((item) => item.command === "update_skill" && item.id === skill.id && item.repoKey === skill.repoKey)).length; const hasUpdates = pendingUpdates > 0 || allGroupItems.some((skill) => skill.installed && !skill.bundled && skill.updateAvailable); const updateLabel = `${group.label} · ${tf("更新组内可更新 Skill({0})", [updateTargets.length])}${pendingUpdates ? ` · ${tf("进行中或排队 {0} 项", [pendingUpdates])}` : ""}`; diff --git a/apps/codex-plus-manager/src/ManagementPages.tsx b/apps/codex-plus-manager/src/ManagementPages.tsx index 5fb7d908ab..f909fcd302 100644 --- a/apps/codex-plus-manager/src/ManagementPages.tsx +++ b/apps/codex-plus-manager/src/ManagementPages.tsx @@ -120,8 +120,8 @@ export function MatrixCell({ name, enabled, disabled, onToggle, agent = "codex", return ; } /** 行内的一个 Agent 开关胶囊:图标 + 名字,开了是实心。悬停显示它的配置文件位置。 */ -export function AgentToggle({ name, enabled, disabled, onToggle, agent, title, hint }: { name: string; enabled: boolean; disabled?: boolean; onToggle: () => void; agent: McpAgentId; title?: string; hint?: string }) { - return ; +export function AgentToggle({ name, enabled, disabled, onToggle, agent, title, hint, statusUnknown }: { name: string; enabled: boolean; disabled?: boolean; onToggle: () => void; agent: McpAgentId; title?: string; hint?: string; statusUnknown?: boolean }) { + return ; } async function copyValue(text: string, notify: Notify) { try { await navigator.clipboard.writeText(text); await notify(t("复制"), t("已复制。"), "ok"); } diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index e46979a04f..dda83f2082 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -3009,6 +3009,13 @@ export const EN_PLAIN: Record = { // Skills 面板 "Skills": "Skills", + "共享": "Shared", + "本地安装": "Local installation", + "Codex++ 受控": "Managed by Codex++", + "状态未知": "Status unknown", + "Codex 状态未知": "Codex status unknown", + "同名冲突": "Name conflict", + "刷新目录中": "Refreshing catalog", "Skills 技能": "Skills", "Skills 仓库源": "Skill repositories", "从 GitHub 仓库安装 Skill 到 Codex。启用后软链到 ~/.codex/skills/,下次对话即可用。": diff --git a/apps/codex-plus-manager/src/skill-agent-batch.ts b/apps/codex-plus-manager/src/skill-agent-batch.ts index c7fab014fb..4af1dce23c 100644 --- a/apps/codex-plus-manager/src/skill-agent-batch.ts +++ b/apps/codex-plus-manager/src/skill-agent-batch.ts @@ -1,4 +1,5 @@ -export type SkillAgentState = { installed: boolean; bundled: boolean; enabled: boolean; agents?: string[] }; +export type SkillAgentState = { installed: boolean; bundled: boolean; enabled: boolean; agents?: string[]; conflict?: string | null; + locations?: Array<{ statusError?: string | null }> }; export type SkillBatchOperation = { groupKey: string; agent: string; enabled: boolean; skillIds: string[] }; export type PendingSkillBatchOperation = SkillBatchOperation & { settledSkillIds: string[]; owner: symbol }; @@ -111,12 +112,19 @@ export function skillAgents(skill: SkillAgentState): string[] { return skill.agents ?? (skill.enabled ? ["codex"] : []); } +export function skillCodexStatusError(skill: SkillAgentState): string | undefined { + return skill.locations?.find((location) => location.statusError)?.statusError || undefined; +} + export function skillBatchTargets(skills: T[], agent: string, enabled: boolean): T[] { - return skills.filter((skill) => skill.installed && !skill.bundled && skillAgents(skill).includes(agent) !== enabled); + return skills.filter((skill) => skill.installed && !skill.bundled + && (agent !== "codex" || (!skill.conflict && !skillCodexStatusError(skill))) + && skillAgents(skill).includes(agent) !== enabled); } export function skillBatchState(skills: SkillAgentState[], agent: string) { - const eligible = skills.filter((skill) => skill.installed && !skill.bundled); + const eligible = skills.filter((skill) => skill.installed && !skill.bundled + && (agent !== "codex" || (!skill.conflict && !skillCodexStatusError(skill)))); const selected = eligible.filter((skill) => skillAgents(skill).includes(agent)).length; return { total: eligible.length, checked: eligible.length > 0 && selected === eligible.length, mixed: selected > 0 && selected < eligible.length }; } diff --git a/apps/codex-plus-manager/src/skill-group-ui.test.ts b/apps/codex-plus-manager/src/skill-group-ui.test.ts index 2444a23711..a3fba226f3 100644 --- a/apps/codex-plus-manager/src/skill-group-ui.test.ts +++ b/apps/codex-plus-manager/src/skill-group-ui.test.ts @@ -4,12 +4,14 @@ import test from "node:test"; import vm from "node:vm"; import ts from "typescript"; import { customSkillGroupKey, groupSkills, legacySkillCustomGroupName, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName } from "./skill-groups.ts"; -import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchTargets, SkillBatchRegistry } from "./skill-agent-batch.ts"; +import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchTargets, skillCodexStatusError, SkillBatchRegistry } from "./skill-agent-batch.ts"; type Element = { type: any; props: Record; children: any[] }; -type FixtureSkill = { id: string; name: string; description: string; repoKey: string; installed: boolean; enabled: boolean; bundled: boolean; updateAvailable: boolean; agents: string[]; managed: boolean }; +type FixtureSkill = { id: string; name: string; description: string; repoKey: string; installed: boolean; enabled: boolean; bundled: boolean; updateAvailable: boolean; agents: string[]; managed: boolean; conflict?: string | null; locations?: Array<{ manifestPath: string; rootKind: string; managed: boolean; enabled: boolean; statusError?: string | null }> }; type FixtureRepo = { owner: string; name: string; branch: string; subdir: string; enabled: boolean }; -type FixtureServer = { status: string; message: string; skills: FixtureSkill[]; repos: FixtureRepo[]; repositorySkills: FixtureSkill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string }; +type FixtureServer = { status: string; message: string; inventoryOk: boolean; skills: FixtureSkill[]; repos: FixtureRepo[]; repositorySkills: FixtureSkill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string }; +type FixtureCache = { value: FixtureServer | null; localStartedAt: number; catalogStartedAt: number; + localPending: Promise | null; catalogPending: Promise | null; mounted: number }; const source = readFileSync(new URL("./ManagementExtra.tsx", import.meta.url), "utf8"); const file = ts.createSourceFile("ManagementExtra.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -23,7 +25,7 @@ const skill = (id: string, repoKey: string, bundled = false): FixtureSkill => ({ installed: true, enabled: false, bundled, updateAvailable: false, agents: [], managed: true }); const tick = () => new Promise((resolve) => setImmediate(resolve)); -function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: SkillBatchRegistry }) { +function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: SkillBatchRegistry; cache?: FixtureCache }) { const slots: any[] = []; const effects: Array<() => void> = []; const cleanups = new Map void>(); @@ -33,9 +35,11 @@ function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: const requests: Array<{ command: string; args: Record }> = []; const notices: Array<{ title: string; message: string; status: string }> = []; const hooks: { mutation?: (command: string, args: Record) => Promise; list?: (command: string) => Promise } = {}; - const server = shared?.server ?? { status: "ok", message: "ok", skills: [skill("builtin", "", true), skill("alpha", "a/repo@main"), skill("zeta", "z/repo@main")], + const server = shared?.server ?? { status: "ok", message: "ok", inventoryOk: true, skills: [skill("builtin", "", true), skill("alpha", "a/repo@main"), skill("zeta", "z/repo@main")], repos: [] as FixtureRepo[], repositorySkills: [], repoCounts: {} as Record, repoErrors: [], skillsDir: "", codexSkillsDir: "" }; const registry = shared?.registry ?? new SkillBatchRegistry(); + const cache: FixtureCache = shared?.cache ?? { value: null, localStartedAt: 0, catalogStartedAt: 0, localPending: null, catalogPending: null, mounted: 0 }; + registry.subscribeSettled(() => { cache.localStartedAt = 0; }); let cursor = 0; let elements: Element[] = []; const context: Record = { @@ -73,11 +77,11 @@ function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: t: (value: string) => value, tf: (value: string, args: any[]) => value.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), groupSkills, legacySkillCustomGroupName, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName, customSkillGroupKey, matchesManagementQuery: (values: string[], query: string) => !query || values.some((value) => value?.toLowerCase().includes(query.toLowerCase())), - sourceLabel: (item: FixtureSkill) => item.bundled ? "内置" : item.repoKey === "local" ? "本地导入" : item.repoKey ? item.repoKey.split("@", 1)[0] : "用户", + sourceLabel: (item: FixtureSkill) => item.bundled ? "内置" : item.repoKey === "cc-switch" || !item.repoKey ? "本地" : item.repoKey === "local" ? "本地导入" : item.repoKey.split("@", 1)[0], repoUrl: () => "", MCP_AGENTS: ["codex", "claude", "grok"], // 使用模式会隐藏部分 Agent 列;这里按全部 Agent 渲染。 agentVisible: () => true, useAgentFocus: () => "all", - mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchTargets, skillBatchRegistry: registry, + mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchTargets, skillCodexStatusError, skillBatchRegistry: registry, skillPageCache: cache, appPrompt: async () => prompts.shift() ?? null, appConfirm: async () => true, }; for (const name of ["Header", "Button", "CxToolbar", "CxFilters", "CxRow", "CxBadge", "CxBanner", "CxCard", "CxEmpty", "CxStatusIcon", "AgentToggle", "Menu", "MenuItem", "SortableSkillGroup", "GroupAgentSelect", "DndContext", "SortableContext", @@ -147,7 +151,7 @@ function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: const drag = (from: string, to: string) => { find((node) => node.type === "DndContext").props.onDragEnd({ active: { id: from }, over: { id: to } }); render(); }; const savedState = () => JSON.parse(storage.get("codex-plus-plus.skill-groups.v1")!); const unmount = () => { for (const cleanup of cleanups.values()) cleanup(); cleanups.clear(); }; - return { render, unmount, find, group, groupHead, groupButton, groupSelect, rowElements, sort, order, selectTab, activeTab, search, drag, savedState, prompts, storage, server, registry, calls, requests, notices, hooks }; + return { render, unmount, find, group, groupHead, groupButton, groupSelect, rowElements, sort, order, selectTab, activeTab, search, drag, savedState, prompts, storage, server, registry, cache, calls, requests, notices, hooks }; } test("installed groups default closed and preserve interactions as new groups load", async () => { @@ -232,6 +236,57 @@ test("collapsed installed groups support per-agent batch enable and disable", as ui.unmount(); }); +test("batch requests scan inventory only after the last Skill", async () => { + const ui = createHarness(); + ui.server.skills = ["alpha", "beta", "gamma"].map((id) => skill(id, "a/repo@main")); + ui.render(); await tick(); ui.render(); + ui.hooks.mutation = async (_command, args) => { + const item = ui.server.skills.find((entry) => entry.id === args.id)!; + item.agents = ["codex"]; + item.enabled = true; + return args.includeInventory === false ? { status: "ok", message: "ok" } : structuredClone(ui.server); + }; + ui.groupSelect("repo:a/repo", "codex").props.onChange(true); + for (let index = 0; index < 20 && ui.registry.getSnapshot().length; index++) await tick(); + ui.render(); + assert.deepEqual(ui.requests.filter((request) => request.command === "set_skill_enabled_for") + .map(({ args }) => args.includeInventory), [false, false, undefined]); + assert.deepEqual(ui.group("repo:a/repo").props.group.items.map((item: FixtureSkill) => item.enabled), [true, true, true]); + ui.unmount(); +}); + +test("reopening displays cached inventory and manual refresh preserves it until completion", async () => { + const ui = createHarness(); + ui.render(); await tick(); ui.render(); await tick(); ui.render(); + ui.unmount(); + const reopened = createHarness("{}", { server: ui.server, registry: ui.registry, cache: ui.cache }); + reopened.render(); await tick(); reopened.render(); + assert.equal(reopened.group("repo:a/repo").props.group.items[0].id, "alpha"); + assert.equal(reopened.calls.length, 0); + + let finish!: (value: FixtureServer) => void; + reopened.hooks.list = async () => new Promise((resolve) => { finish = resolve; }); + reopened.find((node) => node.type === "Button" && node.children.includes("刷新目录")).props.onClick(); + reopened.render(); + assert.equal(reopened.find((node) => node.type === "Button" && node.children.includes("刷新目录中")).props.disabled, true); + assert.equal(reopened.group("repo:a/repo").props.group.items[0].id, "alpha"); + finish({ ...structuredClone(reopened.server), skills: [skill("new", "new/repo@main")] }); + await tick(); reopened.render(); + assert.equal(reopened.group("repo:new/repo").props.group.items[0].id, "new"); + reopened.unmount(); +}); + +test("a failed inventory refresh keeps the last displayed Skills", async () => { + const ui = createHarness(); + ui.render(); await tick(); ui.render(); await tick(); ui.render(); + ui.hooks.list = async () => ({ ...structuredClone(ui.server), status: "failed", inventoryOk: false, skills: [], repos: [] }); + ui.find((node) => node.type === "Button" && node.children.includes("刷新目录")).props.onClick(); + await tick(); ui.render(); + assert.equal(ui.group("repo:a/repo").props.group.items[0].id, "alpha"); + assert.ok(ui.notices.some((notice) => notice.status === "failed")); + ui.unmount(); +}); + test("group toolbar icons retain accessible labels and fold or expand groups", async () => { const ui = createHarness(); ui.render(); await tick(); ui.render(); ui.selectTab("已安装"); @@ -426,6 +481,28 @@ test("filtered batch survives page remount and changes only visible Skills", asy reopened.unmount(); }); +test("shared installations stay grouped and unknown Codex status cannot be toggled", async () => { + const ui = createHarness(); + ui.server.skills = [{ ...skill("alpha", ""), agents: ["claude"], + locations: [ + { manifestPath: "C:/agents/alpha/SKILL.md", rootKind: "shared", managed: false, enabled: false, statusError: "无法确定启用状态" }, + { manifestPath: "C:/claude/alpha/SKILL.md", rootKind: "claude", managed: false, enabled: true }, + ] }]; + ui.render(); await tick(); ui.render(); + assert.equal(ui.group("user").props.group.items.length, 1); + const row = ui.rowElements("user", "", "alpha"); + const codex = row.find((node) => node.type === "AgentToggle" && node.props.agent === "codex"); + const claude = row.find((node) => node.type === "AgentToggle" && node.props.agent === "claude"); + assert.equal(codex?.props.disabled, true); + assert.equal(codex?.props.statusUnknown, true); + assert.equal(claude?.props.enabled, true); + assert.equal(row.some((node) => node.type === "MenuItem" && node.children.includes("卸载")), false); + ui.groupSelect("user", "codex").props.onChange(true); + await tick(); + assert.equal(ui.requests.some((request) => request.command === "set_skill_enabled_for"), false); + ui.unmount(); +}); + test("shared IDs support independent choices while retaining legacy assignments and bundled protection", async () => { const ui = createHarness(JSON.stringify({ names: ["Manual", "Other"], assignments: { shared: "Manual" }, future: "keep" })); ui.server.skills = [skill("shared", "", true), skill("shared", "a/repo@main"), skill("shared", "z/repo@main"), @@ -435,7 +512,7 @@ test("shared IDs support independent choices while retaining legacy assignments assert.deepEqual(ui.group("custom:Manual").props.group.items.map((item: FixtureSkill) => item.repoKey).sort(), ["a/repo@main", "z/repo@main"]); assert.deepEqual(ui.group("user").props.group.items.map((item: FixtureSkill) => item.repoKey), ["local", "cc-switch"]); assert.ok(ui.rowElements("user", "local", "local-import").some((node) => node.type === "small" && node.children.includes("本地导入"))); - assert.ok(ui.rowElements("user", "cc-switch", "cc-import").some((node) => node.type === "small" && node.children.includes("cc-switch"))); + assert.ok(ui.rowElements("user", "cc-switch", "cc-import").some((node) => node.type === "small" && node.children.includes("本地"))); assert.equal(ui.rowElements("builtin", "", "shared").some((node) => node.props.className === "mg-menu-label"), false); assert.ok(ui.rowElements("custom:Manual", "a/repo@main", "shared").some((node) => node.props.className === "mg-menu-label")); assert.deepEqual(ui.savedState().assignments, { shared: "Manual" }); diff --git a/crates/codex-plus-core/src/extension_manager_data.rs b/crates/codex-plus-core/src/extension_manager_data.rs index f49d92dd22..5400dacc24 100644 --- a/crates/codex-plus-core/src/extension_manager_data.rs +++ b/crates/codex-plus-core/src/extension_manager_data.rs @@ -95,8 +95,15 @@ pub fn gateway_quota( pub fn installed_skills(entries: &[SkillEntry]) -> Value { let skills: Vec = entries.iter().filter(|entry| entry.installed).map(|entry| { - json!({"id": entry.id, "name": entry.name, "enabled": entry.enabled, "bundled": entry.bundled, - "managed": entry.managed, "agents": entry.agents, "contentHash": entry.content_hash}) + let unknown = (!entry.enabled).then(|| entry.locations.iter() + .find_map(|location| location.status_error.as_deref())).flatten(); + let mut skill = json!({"id": entry.id, "name": entry.name, "enabled": entry.enabled, "bundled": entry.bundled, + "managed": entry.managed, "agents": entry.agents, "contentHash": entry.content_hash}); + if let Some(error) = unknown { + skill["enabled"] = Value::Null; + skill["statusError"] = json!(error); + } + skill }).collect(); json!({"skills": skills}) } diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 5877952e2c..758f262413 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -1,9 +1,6 @@ //! Codex Skills 管理。 //! -//! codex 的 skill 是文件系统约定,不是配置项:它扫描 `$CODEX_HOME/skills//SKILL.md`, -//! 从 YAML frontmatter 读 `name` / `description`,然后把清单注入 ``。 -//! `config.toml` 里的 `[skills]` 是一个三字段结构体(bundled / include_instructions / -//! max_context_tokens),写 `[skills.]` 会被 serde 当未知字段静默丢弃,什么都不会发生。 +//! Codex 从技能目录发现 `SKILL.md`,并使用 `[[skills.config]]` 控制具体技能的启停。 //! //! 所以这里采用「SSOT + 软链」模型: //! @@ -199,6 +196,18 @@ pub struct SkillEntry { pub agents: Vec, /// Codex++ 的受控源目录里有它(安装或导入过);只在某个 Agent 自己目录里的 Skill 为 false。 pub managed: bool, + pub locations: Vec, + pub conflict: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillLocation { + pub manifest_path: String, + pub root_kind: String, + pub managed: bool, + pub enabled: bool, + pub status_error: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -562,6 +571,53 @@ fn skill_names_may_alias(left: &str, right: &str) -> bool { left == right } +#[derive(Debug)] +struct NativeSkillRule { + index: usize, + name: Option, + path: Option, + enabled: bool, +} + +struct NativeSkillConfig { + original: Option>, + rules: Vec, +} + +fn native_skill_paths_match(left: &Path, right: &Path) -> bool { + let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf()); + let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf()); + left == right +} + +impl NativeSkillConfig { + fn name_for_matching(&self, manifest: &Path, fallback_name: &str) -> anyhow::Result { + if !manifest.is_file() { + return Ok(fallback_name.to_string()); + } + let last_path = self.rules.iter().rposition(|rule| rule.path.as_deref() + .is_some_and(|path| native_skill_paths_match(path, manifest))); + if self.rules.iter().enumerate().any(|(index, rule)| + rule.name.is_some() && last_path.is_none_or(|last| index > last)) + { + read_native_skill_name(manifest) + } else { + Ok(fallback_name.to_string()) + } + } + + fn matching_rule(&self, manifest: &Path, name: &str) -> Option<(usize, &NativeSkillRule)> { + self.rules.iter().enumerate().rev().find(|(_, rule)| { + rule.name.as_deref() == Some(name) || rule.path.as_deref() + .is_some_and(|path| native_skill_paths_match(path, manifest)) + }) + } + + fn enabled(&self, manifest: &Path, name: &str) -> bool { + self.matching_rule(manifest, name).map_or(true, |(_, rule)| rule.enabled) + } +} + #[derive(Debug, Clone)] pub struct SkillsManager { source_dir: PathBuf, @@ -570,6 +626,7 @@ pub struct SkillsManager { codex_home: PathBuf, /// Codex 之外也读 `//SKILL.md` 的 Agent:(Agent id, skills 目录)。 agent_dirs: Vec<(String, PathBuf)>, + shared_skills_dir: Option, } impl SkillsManager { @@ -585,6 +642,7 @@ impl SkillsManager { state_path: state_path.into(), codex_home: codex_home.into(), agent_dirs: Vec::new(), + shared_skills_dir: None, } } @@ -594,6 +652,120 @@ impl SkillsManager { self } + pub fn with_shared_skills_dir(mut self, dir: impl Into) -> Self { + self.shared_skills_dir = Some(dir.into()); + self + } + + pub fn shared_skills_dir(&self) -> Option<&Path> { + self.shared_skills_dir.as_deref() + } + + fn read_native_skill_config(&self) -> anyhow::Result { + let path = self.codex_home.join("config.toml"); + let original = match std::fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error).context("无法读取 Codex 技能配置"), + }; + let text = std::str::from_utf8(original.as_deref().unwrap_or_default()) + .context("Codex 配置不是有效 UTF-8")?; + let document: toml::Value = text.parse() + .map_err(|_| anyhow::anyhow!("Codex 配置格式错误,请先修复 config.toml"))?; + let mut rules = Vec::new(); + if let Some(skills) = document.get("skills") { + let skills = skills.as_table().context("Codex skills 配置不是表")?; + if let Some(config) = skills.get("config") { + let config = config.as_array().context("Codex skills.config 不是数组")?; + for (index, entry) in config.iter().enumerate() { + let entry = entry.as_table().context("Codex skills.config 条目不是表")?; + let name = entry.get("name").map(|value| value.as_str() + .map(|name| name.trim().to_string()).context("Codex Skill 名称必须是字符串")).transpose()?; + let path = entry.get("path").map(|value| value.as_str() + .map(PathBuf::from).context("Codex Skill 路径必须是字符串")).transpose()?; + if name.is_some() == path.is_some() || path.as_ref().is_some_and(|path| !path.is_absolute()) { + anyhow::bail!("Codex skills.config 条目必须且只能指定名称或绝对路径"); + } + if name.as_deref() == Some("") { + continue; + } + let enabled = entry.get("enabled").and_then(toml::Value::as_bool) + .context("Codex skills.config 条目缺少有效的 enabled")?; + rules.push(NativeSkillRule { index, name, path, enabled }); + } + } + } + Ok(NativeSkillConfig { original, rules }) + } + + fn set_native_skill_paths(&self, manifests: &[(PathBuf, String)], enabled: bool) -> anyhow::Result<()> { + let config = self.read_native_skill_config()?; + let mut changes = Vec::new(); + for (path, name) in manifests { + let name = config.name_for_matching(path, name)?; + if config.enabled(path, &name) != enabled { + changes.push((path, name)); + } + } + if changes.is_empty() { + return Ok(()); + } + let text = std::str::from_utf8(config.original.as_deref().unwrap_or_default())?; + let mut document = text.parse::() + .map_err(|_| anyhow::anyhow!("Codex 配置格式错误,请先修复 config.toml"))?; + let skills = document.as_table_mut().entry("skills") + .or_insert(toml_edit::Item::Table(toml_edit::Table::new())); + let inline = skills.is_inline_table(); + let skills = skills.as_table_like_mut().context("Codex skills 配置不是表")?; + let item = skills.entry("config").or_insert_with(|| { + if inline { + toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new())) + } else { + toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()) + } + }); + for (path, name) in changes { + let path_text = path.to_str().context("Codex Skill 路径不是有效文本")?; + let existing = config.matching_rule(path, &name) + .filter(|(_, rule)| rule.path.as_deref() + .is_some_and(|existing| native_skill_paths_match(existing, path))) + .map(|(_, rule)| rule.index); + if let Some(tables) = item.as_array_of_tables_mut() { + if let Some(index) = existing { + tables.get_mut(index).context("Codex Skill 配置已改变")?["enabled"] = toml_edit::value(enabled); + } else { + let mut table = toml_edit::Table::new(); + table["path"] = toml_edit::value(path_text); + table["enabled"] = toml_edit::value(enabled); + tables.push(table); + } + } else if let Some(array) = item.as_array_mut() { + if let Some(index) = existing { + let table = array.get_mut(index).and_then(toml_edit::Value::as_inline_table_mut) + .context("Codex Skill 配置已改变")?; + table.insert("enabled", toml_edit::Value::from(enabled)); + } else { + let mut table = toml_edit::InlineTable::new(); + table.insert("path", toml_edit::Value::from(path_text)); + table.insert("enabled", toml_edit::Value::from(enabled)); + array.push(table); + } + } else { + anyhow::bail!("Codex skills.config 不是数组"); + } + } + let current = match std::fs::read(self.codex_home.join("config.toml")) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error).context("无法重新读取 Codex 配置"), + }; + if current != config.original { + anyhow::bail!("Codex 配置已被其他窗口更新,请重试;未覆盖新配置"); + } + crate::settings::atomic_write(&self.codex_home.join("config.toml"), document.to_string().as_bytes()) + .context("无法保存 Codex 技能配置") + } + /// 路径不完整时返回全局范围;任意受管路径相交都不能互相隔离。 fn coordination_scope(&self) -> CoordinationScope { let paths = [ @@ -603,6 +775,7 @@ impl SkillsManager { self.linked_dir(), ]; let mut scope: Vec<_> = paths.iter().chain(self.agent_dirs.iter().map(|(_, dir)| dir)) + .chain(self.shared_skills_dir.iter()) .map(|path| coordination_path(path)) .collect::>()?; // 状态文件通过原子替换写入;父目录身份和文件名足以识别共享路径。 @@ -631,8 +804,15 @@ impl SkillsManager { let mut coordination = skills_coordination(); self.ensure_skill_name_not_aliased(id, &self.load_state_checked_unlocked()?)?; let result = (|| { + if enabled && agent != "codex" && self.agent_dir(agent)?.join(id).join(SKILL_MANIFEST_FILE).is_file() { + return Ok(()); + } + let shared = self.shared_skills_dir.as_ref() + .is_some_and(|dir| dir.join(id).join(SKILL_MANIFEST_FILE).is_file()); if enabled { - self.ensure_managed_source_unlocked(id, &mut coordination)?; + if agent != "codex" || (!shared && !self.linked_dir().join(id).join(SKILL_MANIFEST_FILE).is_file()) { + self.ensure_managed_source_unlocked(id, &mut coordination)?; + } } if agent == "codex" { return self.set_enabled_unlocked(id, enabled, &mut coordination); @@ -642,13 +822,15 @@ impl SkillsManager { let metadata = std::fs::symlink_metadata(&link); if !enabled { return match metadata { - Ok(metadata) if metadata.file_type().is_symlink() => remove_link(&link), + Ok(metadata) if metadata_is_link(&metadata) + && is_managed_link(&link, &self.source_dir.join(id)) => remove_link(&link), Ok(_) if is_managed_copy(&link) => { self.backup_directory(id, &link)?; Ok(()) } Ok(_) => anyhow::bail!("{} 是这个 Agent 自己的 Skill,不由 Codex++ 管理,未删除", link.display()), - Err(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("无法读取 {}", link.display())), }; } if link.join(SKILL_MANIFEST_FILE).is_file() { @@ -670,39 +852,60 @@ impl SkillsManager { /// 没有受控源时,从 Codex 或其它 Agent 目录里找到这个 Skill 的真实目录,纳入受控源。 /// - /// - 在 Codex 目录里:走一遍「停用 → 启用」,停用会把真实目录复制成受控源(原目录进备份), - /// 启用再软链回来,Codex 照常可用; - /// - 只在其它 Agent 目录里:复制一份成受控源,那个 Agent 自己的目录保持不动。 + /// 只在各安装位置内容一致时复制受控源,原安装位置保持不动。 fn ensure_managed_source_unlocked( &self, id: &str, coordination: &mut SkillsCoordination, ) -> anyhow::Result<()> { - if self.source_dir.join(id).is_dir() { - return Ok(()); - } - let physical = |path: &Path| { - std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) && path.join(SKILL_MANIFEST_FILE).is_file() - }; + let source = self.source_dir.join(id); let codex = self.linked_dir().join(id); - if physical(&codex) { - self.set_enabled_unlocked(id, false, coordination)?; - return self.set_enabled_unlocked(id, true, coordination); + let mut roots = Vec::new(); + roots.extend(self.shared_skills_dir.iter().cloned()); + roots.push(self.linked_dir()); + roots.extend(self.agent_dirs.iter().map(|(_, dir)| dir.clone())); + let mut originals = BTreeSet::new(); + for root in roots { + let candidate = root.join(id); + if candidate.join(SKILL_MANIFEST_FILE).is_file() { + originals.insert(candidate.canonicalize()?); + } } - if std::fs::symlink_metadata(&codex).is_ok() { - // Codex 目录里同名的是别的东西(例如断掉的软链),导入会把它挪进备份;交给用户处理。 - anyhow::bail!("Codex 的 skills 目录里已有同名内容,未导入:{}", codex.display()); + if source.is_dir() { + let source_target = source.canonicalize()?; + let digest = skill_directory_digest(&source_target)?.context("Skill 受控源已不存在")?; + for original in originals { + if original != source_target && skill_directory_digest(&original)? != Some(digest) { + anyhow::bail!("Skill 安装目录与受控源内容不同,不能使用旧副本启用到其他 Agent:{}", original.display()); + } + } + return Ok(()); } - for (_, dir) in &self.agent_dirs { - let candidate = dir.join(id); - if physical(&candidate) { - let (staging, installed) = self.stage_directory_import(id, &candidate, "cc-switch")?; - let result = self.commit_staged_skill_unlocked(&staging, installed, false, true, None, false, coordination); - result.map_err(|error| retain_staging_context(error, &staging))?; - return Ok(()); + let Some(candidate) = originals.iter().next() else { + if std::fs::symlink_metadata(&codex).is_ok() { + anyhow::bail!("Codex 的 skills 目录里已有同名内容,未导入:{}", codex.display()); } + return Ok(()); + }; + let identity = skill_path_identity(candidate)?; + let digest = skill_directory_digest(candidate)?.context("Skill 原目录已不存在")?; + for original in &originals { + anyhow::ensure!(skill_directory_digest(original)? == Some(digest), + "同名 Skill 安装内容不同,请先选择要启用的版本"); } - Ok(()) + let (staging, mut installed) = self.stage_directory_import(id, candidate, LOCAL_IMPORT_REPO_KEY)?; + let result = (|| -> anyhow::Result<()> { + anyhow::ensure!(skill_path_identity(candidate)? == identity + && skill_directory_digest(candidate)? == Some(digest) + && skill_directory_digest(&staging)? == Some(digest), + "创建受控源期间 Skill 原件已改变"); + if let Some(existing) = self.load_state_checked_unlocked()?.installed.get(id) { + installed = existing.clone(); + } + self.commit_staged_skill_unlocked(&staging, installed, false, true, None, true, coordination)?; + Ok(()) + })(); + result.map_err(|error| retain_staging_context(error, &staging)) } pub fn source_dir(&self) -> &Path { @@ -751,6 +954,7 @@ impl SkillsManager { } let mut roots = vec![self.source_dir.clone(), self.linked_dir()]; roots.extend(self.agent_dirs.iter().map(|(_, dir)| dir.clone())); + roots.extend(self.shared_skills_dir.iter().cloned()); for root in roots { let entries = match std::fs::read_dir(&root) { Ok(entries) => entries, @@ -933,7 +1137,15 @@ impl SkillsManager { /// 把远端清单和本地状态合并成一份列表。`remote` 传空就是纯本地视图。 pub fn merge_entries(&self, remote: &[RemoteSkill]) -> Vec { - let state = self.load_state(); + self.try_merge_entries(remote).expect("读取 Skill 状态失败") + } + + pub fn try_merge_entries(&self, remote: &[RemoteSkill]) -> anyhow::Result> { + let native_config = self.read_native_skill_config()?; + let state = { + let _guard = skills_coordination(); + self.load_state_checked_unlocked()? + }; let linked = self.linked_dir(); let mut entries: BTreeMap<(String, String), SkillEntry> = BTreeMap::new(); @@ -965,6 +1177,8 @@ impl SkillsManager { .unwrap_or(false), agents: Vec::new(), managed: false, + locations: Vec::new(), + conflict: None, }, ); } @@ -989,6 +1203,8 @@ impl SkillsManager { update_available: false, agents: Vec::new(), managed: false, + locations: Vec::new(), + conflict: None, }); } @@ -1004,10 +1220,32 @@ impl SkillsManager { } } + if let Some(root) = &self.shared_skills_dir { + for (id, (name, description)) in scan_shared_skill_dirs(root)? { + if !entries.values().any(|item| item.id == id && item.installed) { + entries.insert((String::new(), id.clone()), SkillEntry { + id, name, description, + repo_key: String::new(), + repo_path: String::new(), + installed: true, + enabled: false, + bundled: false, + content_hash: String::new(), + remote_hash: String::new(), + update_available: false, + agents: Vec::new(), + managed: false, + locations: Vec::new(), + conflict: None, + }); + } + } + } + // 只放在 Claude / Grok 自己 skills 目录里的 Skill(不是本工具建的软链),也要能看见、能启用到别处。 for (agent, dir) in &self.agent_dirs { for skill in scan_skill_dirs(dir) { - if !entries.values().any(|item| item.id == skill.0) { + if !entries.values().any(|item| item.id == skill.0 && item.installed) { let (name, description) = skill.1; entries.insert((String::new(), skill.0.clone()), SkillEntry { id: skill.0.clone(), @@ -1023,6 +1261,8 @@ impl SkillsManager { update_available: false, agents: vec![agent.clone()], managed: false, + locations: Vec::new(), + conflict: None, }); } } @@ -1035,19 +1275,79 @@ impl SkillsManager { } entry.managed = self.source_dir.join(&entry.id).is_dir(); let mut agents = Vec::new(); - if entry.enabled || entry.bundled { + let codex = if entry.bundled { + linked.join(BUNDLED_SKILLS_DIR).join(&entry.id) + } else { + linked.join(&entry.id) + }; + let mut roots = vec![(if entry.bundled { "system" } else { "codex" }, codex)]; + if !entry.bundled { + if let Some(shared) = &self.shared_skills_dir { + roots.push(("shared", shared.join(&entry.id))); + } + } + for (root_kind, path) in roots { + let manifest = path.join(SKILL_MANIFEST_FILE); + if !manifest.is_file() { continue; } + let (name, _) = read_skill_manifest(&manifest, &entry.id); + let (enabled, status_error) = match native_config.name_for_matching(&manifest, &name) { + Ok(name) => (native_config.enabled(&manifest, &name), None), + Err(error) => (false, Some(format!("{error:#}"))), + }; + let managed = root_kind == "codex" && (is_managed_copy(&path) + || path.canonicalize().ok().zip(self.source_dir.join(&entry.id).canonicalize().ok()) + .is_some_and(|(actual, source)| actual == source)); + entry.locations.push(SkillLocation { + manifest_path: manifest.to_string_lossy().into_owned(), + root_kind: root_kind.to_string(), + managed, + enabled, + status_error, + }); + } + entry.enabled = entry.locations.iter().any(|location| location.enabled); + if entry.enabled { agents.push("codex".to_string()); } if !entry.bundled { for (agent, dir) in &self.agent_dirs { - if dir.join(&entry.id).join(SKILL_MANIFEST_FILE).is_file() && !agents.contains(agent) { - agents.push(agent.clone()); + let path = dir.join(&entry.id); + let manifest = path.join(SKILL_MANIFEST_FILE); + if manifest.is_file() { + if !agents.contains(agent) { agents.push(agent.clone()); } + let managed = is_managed_copy(&path) + || path.canonicalize().ok().zip(self.source_dir.join(&entry.id).canonicalize().ok()) + .is_some_and(|(actual, source)| actual == source); + entry.locations.push(SkillLocation { + manifest_path: manifest.to_string_lossy().into_owned(), + root_kind: agent.clone(), + managed, + enabled: true, + status_error: None, + }); + } + } + if let (Some(shared), Some(codex)) = ( + self.shared_skills_dir.as_ref().map(|root| root.join(&entry.id)) + .filter(|path| path.join(SKILL_MANIFEST_FILE).is_file()), + linked.join(&entry.id).join(SKILL_MANIFEST_FILE).is_file().then(|| linked.join(&entry.id)), + ) { + let comparison = (|| -> anyhow::Result { + let shared = shared.canonicalize()?; + let codex = codex.canonicalize()?; + Ok(shared == codex + || skill_directory_digest(&shared)? == skill_directory_digest(&codex)?) + })(); + match comparison { + Ok(true) => {} + Ok(false) => entry.conflict = Some("共享目录与 Codex 目录的同名 Skill 内容不同".into()), + Err(error) => entry.conflict = Some(format!("无法比较同名 Skill 内容:{error:#}")), } } } entry.agents = agents; } - entries + Ok(entries) } /// 本地目录 inventory 不混入仅出现在远端/缓存清单中的未安装条目,也不写 TOML。 @@ -1055,6 +1355,10 @@ impl SkillsManager { self.merge_entries(&[]) } + pub fn try_local_inventory(&self) -> anyhow::Result> { + self.try_merge_entries(&[]) + } + /// 扫描 `$CODEX_HOME/skills` 下带 `SKILL.md` 的目录,收拢前面几轮没覆盖到的条目。 /// /// 软链(本工具安装的)与 `.system` 已在别的分支处理过,这里只兜底「本地自建」, @@ -1095,6 +1399,8 @@ impl SkillsManager { update_available: false, agents: Vec::new(), managed: false, + locations: Vec::new(), + conflict: None, }); } skills @@ -1125,6 +1431,20 @@ impl SkillsManager { repo_key: &str, ) -> anyhow::Result { validate_skill_id(id)?; + if repo_key == LOCAL_IMPORT_REPO_KEY { + if let Ok(external) = external_dir.canonicalize() { + let mut roots = vec![(self.linked_dir(), "Codex 的 skills 目录"), + (self.source_dir.clone(), "Codex++ 的 Skills 受控目录")]; + if let Some(shared) = &self.shared_skills_dir { + roots.push((shared.clone(), "共享 Skill 目录")); + } + for (root, label) in roots { + if root.canonicalize().is_ok_and(|root| external.starts_with(root)) { + anyhow::bail!("{} 已在{label}里,Codex++ 会直接识别,无需导入", external_dir.display()); + } + } + } + } let generation = { let coordination = skills_coordination(); self.ensure_skill_name_not_aliased(id, &self.load_state_checked_unlocked()?)?; @@ -1159,16 +1479,6 @@ impl SkillsManager { repo_key: &str, ) -> anyhow::Result<(PathBuf, InstalledSkill)> { validate_skill_id(id)?; - if repo_key == LOCAL_IMPORT_REPO_KEY { - // Codex 的 skills 目录本来就会被扫描显示;从这里「导入」会把原目录挪进备份,反而让它从 Codex 消失。 - if let Ok(external) = external_dir.canonicalize() { - for (root, label) in [(self.linked_dir(), "Codex 的 skills 目录"), (self.source_dir.clone(), "Codex++ 的 Skills 受控目录")] { - if root.canonicalize().is_ok_and(|root| external.starts_with(root)) { - anyhow::bail!("{} 已在{label}里,Codex++ 会直接识别,无需导入", external_dir.display()); - } - } - } - } let manifest = external_dir.join(SKILL_MANIFEST_FILE); if !manifest.is_file() { anyhow::bail!("{} 下没有 SKILL.md,不是有效的 Skill", external_dir.display()); @@ -1243,6 +1553,9 @@ impl SkillsManager { coordination: &mut SkillsCoordination, ) -> anyhow::Result { let id = installed.id.clone(); + anyhow::ensure!(require_empty || !self.shared_skills_dir.as_ref() + .is_some_and(|root| root.join(&id).join(SKILL_MANIFEST_FILE).is_file()), + "共享 Skill 的原目录不由此安装流程更新"); let destination = self.source_dir.join(&id); let mut state = self.load_state_checked_unlocked()?; self.ensure_skill_name_not_aliased(&id, &state)?; @@ -1378,6 +1691,8 @@ impl SkillsManager { update_available: false, agents: Vec::new(), managed: false, + locations: Vec::new(), + conflict: None, }); } skills @@ -1391,6 +1706,9 @@ impl SkillsManager { install_only: bool, ) -> anyhow::Result { validate_skill_id(id)?; + anyhow::ensure!(!self.shared_skills_dir.as_ref() + .is_some_and(|root| root.join(id).join(SKILL_MANIFEST_FILE).is_file()), + "共享 Skill 的原目录不由此安装流程更新"); let coordination = skills_coordination(); let state = self.load_state_checked_unlocked()?; self.ensure_skill_name_not_aliased(id, &state)?; @@ -1639,10 +1957,39 @@ impl SkillsManager { ) -> anyhow::Result<()> { let source = self.source_dir.join(id); let link = self.linked_dir().join(id); + let shared = self.shared_skills_dir.as_ref().map(|root| root.join(id)) + .filter(|path| path.join(SKILL_MANIFEST_FILE).is_file()); + if let Some(shared) = shared { + let mut paths = vec![shared.clone()]; + if link.join(SKILL_MANIFEST_FILE).is_file() { + let shared_target = shared.canonicalize()?; + let link_target = link.canonicalize()?; + anyhow::ensure!(shared_target == link_target + || skill_directory_digest(&shared_target)? == skill_directory_digest(&link_target)?, + "共享目录与 Codex 目录的同名 Skill 内容不同,请分别处理"); + paths.push(link.clone()); + } + let manifests = paths.into_iter().map(|path| { + let manifest = path.join(SKILL_MANIFEST_FILE); + let (name, _) = read_skill_manifest(&manifest, id); + (manifest, name) + }).collect::>(); + return self.set_native_skill_paths(&manifests, enabled); + } + let existing_manifest = link.join(SKILL_MANIFEST_FILE); + if existing_manifest.is_file() { + let (name, _) = read_skill_manifest(&existing_manifest, id); + if std::fs::symlink_metadata(&link).is_ok_and(|metadata| + (metadata.file_type().is_dir() && !metadata_is_link(&metadata)) + || (metadata_is_link(&metadata) && !is_managed_link(&link, &source))) + { + return self.set_native_skill_paths(&[(existing_manifest, name)], enabled); + } + } if !enabled { let state = self.load_state_checked_unlocked()?; let physical_copy = std::fs::symlink_metadata(&link) - .is_ok_and(|metadata| !metadata.file_type().is_symlink()); + .is_ok_and(|metadata| !metadata_is_link(&metadata)); if link.is_dir() && (physical_copy || !source.is_dir() || !state.installed.contains_key(id)) { // 复制回退后的安装位置可能已有用户修改;未纳管软链也需要保留可再启用的源。 std::fs::create_dir_all(&self.source_dir)?; @@ -1680,7 +2027,8 @@ impl SkillsManager { return remove_link(&link); } if link.join(SKILL_MANIFEST_FILE).is_file() { - return Ok(()); + let (name, _) = read_skill_manifest(&existing_manifest, id); + return self.set_native_skill_paths(&[(existing_manifest, name)], true); } if std::fs::symlink_metadata(&link).is_ok() { anyhow::bail!("启用位置已存在其他内容,未覆盖:{}", link.display()); @@ -1690,12 +2038,25 @@ impl SkillsManager { } std::fs::create_dir_all(self.linked_dir()) .with_context(|| format!("创建 {} 失败", self.linked_dir().display()))?; - link_or_copy(&source, &link).map(|_| ()) + let identity = link_or_copy(&source, &link)?; + let manifest = link.join(SKILL_MANIFEST_FILE); + let (name, _) = read_skill_manifest(&manifest, id); + if let Err(error) = self.set_native_skill_paths(&[(manifest, name)], true) { + return match recover_published_path(&link, identity) { + Ok(Some(path)) => Err(error.context(format!("新建的 Skill 内容保留在 {}", path.display()))), + Ok(None) => Err(error), + Err(rollback) => Err(error.context(format!("回滚新建 Skill 副本失败:{rollback:#}"))), + }; + } + Ok(()) } /// 卸载:删软链,源目录整体移进备份目录。不做自动轮转删除。 pub fn uninstall(&self, id: &str) -> anyhow::Result { validate_skill_id(id)?; + anyhow::ensure!(!self.shared_skills_dir.as_ref() + .is_some_and(|root| root.join(id).join(SKILL_MANIFEST_FILE).is_file()), + "共享 Skill 不由 Codex++ 卸载,可通过 Codex 开关停用"); let mut coordination = skills_coordination(); let mut state = self.load_state_checked_unlocked()?; self.ensure_skill_name_not_aliased(id, &state)?; @@ -1704,6 +2065,7 @@ impl SkillsManager { for (_, dir) in &self.agent_dirs { let link = dir.join(id); if std::fs::symlink_metadata(&link).is_ok_and(|metadata| metadata.file_type().is_symlink()) + && is_managed_link(&link, &self.source_dir.join(id)) || is_managed_copy(&link) { let identity = skill_path_identity(&link)?; @@ -2307,6 +2669,102 @@ fn unquote_scalar(value: &str) -> String { unquoted.to_string() } +fn parse_single_line_yaml_scalar(value: &str) -> Option { + let value = value.trim(); + if let Some(rest) = value.strip_prefix('\'') { + let mut result = String::new(); + let mut chars = rest.char_indices().peekable(); + while let Some((offset, ch)) = chars.next() { + if ch == '\'' { + if chars.peek().is_some_and(|(_, next)| *next == '\'') { + chars.next(); + result.push('\''); + continue; + } + let suffix = &rest[offset + ch.len_utf8()..]; + return (suffix.is_empty() || suffix.starts_with(char::is_whitespace) + && (suffix.trim().is_empty() || suffix.trim_start().starts_with('#'))) + .then_some(result); + } + result.push(ch); + } + return None; + } + if value.starts_with('"') { + let mut escaped = false; + for (offset, ch) in value.char_indices().skip(1) { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + let suffix = &value[offset + 1..]; + if suffix.is_empty() || suffix.starts_with(char::is_whitespace) + && (suffix.trim().is_empty() || suffix.trim_start().starts_with('#')) + { + return serde_json::from_str(&value[..=offset]).ok(); + } + return None; + } + } + return None; + } + if value.is_empty() || value.starts_with(['&', '*', '!', '[', '{', '|', '>', '@', '`', '#']) + || matches!(value, "~" | "null" | "Null" | "NULL" | "true" | "True" | "TRUE" + | "false" | "False" | "FALSE") + || value.parse::().is_ok() + { + return None; + } + let mut previous = None; + for (offset, ch) in value.char_indices() { + if ch == '#' && (offset == 0 || previous.is_some_and(char::is_whitespace)) { + return parse_single_line_yaml_scalar(value[..offset].trim_end()); + } + if ch == ':' && value[offset + 1..].chars().next().is_none_or(char::is_whitespace) { + return None; + } + previous = Some(ch); + } + Some(value.to_string()) +} + +fn read_native_skill_name(manifest: &Path) -> anyhow::Result { + let text = read_bounded_skill_manifest(std::fs::File::open(manifest)?) + .context("无法读取完整 Skill frontmatter,Codex 启用状态未知")?; + let mut lines = text.lines(); + anyhow::ensure!(lines.next().is_some_and(|line| line.trim() == "---"), + "Skill 缺少有效 frontmatter,Codex 启用状态未知"); + let mut found = None; + let mut ended = false; + let mut after_name = false; + for line in lines { + if line.trim() == "---" { + ended = true; + break; + } + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if line.starts_with([' ', '\t']) { + anyhow::ensure!(!after_name, "Skill 名称可能包含多行 YAML 标量,Codex 启用状态未知"); + continue; + } + after_name = false; + if line.starts_with(['\'', '"', '?', '{', '[', '%']) || line.starts_with("<<:") { + anyhow::bail!("Skill 名称使用未支持的 YAML 结构,Codex 启用状态未知"); + } + if let Some(value) = line.strip_prefix("name:") { + anyhow::ensure!(found.is_none(), "Skill 名称重复,Codex 启用状态未知"); + found = Some(parse_single_line_yaml_scalar(value).filter(|value| !value.is_empty()) + .context("Skill 名称使用未支持的 YAML 语法,Codex 启用状态未知")?); + after_name = true; + } + } + anyhow::ensure!(ended, "Skill frontmatter 未结束,Codex 启用状态未知"); + found.context("Skill 名称缺失,Codex 启用状态未知") +} + fn read_skill_manifest(manifest: &Path, fallback_id: &str) -> (String, String) { let text = (|| -> Option { if !std::fs::metadata(manifest).ok()?.is_file() { @@ -3058,26 +3516,31 @@ fn skill_directory_digest(path: &Path) -> anyhow::Result> { if !metadata.is_dir() { anyhow::bail!("Skill 内容不是可安全检查的实体目录:{}", path.display()); } - fn hash_directory(dir: &Path, hasher: &mut Sha256) -> anyhow::Result<()> { + fn hash_directory(dir: &Path, hasher: &mut Sha256, root: bool) -> anyhow::Result<()> { let mut entries = std::fs::read_dir(dir)?.collect::>>()?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let name = entry.file_name(); - if name == MANAGED_COPY_MARKER { continue; } + if root && name == MANAGED_COPY_MARKER { continue; } let name = name.to_str().context("Skill 文件名无法安全比较")?; let path = entry.path(); let metadata = std::fs::symlink_metadata(&path)?; - if metadata.file_type().is_symlink() { + if metadata_is_link(&metadata) { anyhow::bail!("Skill 包含无法安全比较的符号链接:{}", path.display()); } hasher.update((name.len() as u64).to_le_bytes()); hasher.update(name.as_bytes()); if metadata.is_dir() { hasher.update(b"D"); - hash_directory(&path, hasher)?; + hash_directory(&path, hasher, false)?; hasher.update(b"E"); } else if metadata.is_file() { hasher.update(b"F"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + hasher.update((metadata.permissions().mode() & 0o777).to_le_bytes()); + } hasher.update(metadata.len().to_le_bytes()); let mut file = std::fs::File::open(&path)?; let mut buffer = [0u8; 64 * 1024]; @@ -3094,6 +3557,14 @@ fn skill_directory_digest(path: &Path) -> anyhow::Result> { { anyhow::bail!("读取期间 Skill 文件发生变化:{}", path.display()); } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + anyhow::ensure!( + after.permissions().mode() & 0o777 == metadata.permissions().mode() & 0o777, + "读取期间 Skill 文件权限发生变化:{}", path.display() + ); + } } else { anyhow::bail!("Skill 包含无法安全比较的特殊文件:{}", path.display()); } @@ -3101,7 +3572,7 @@ fn skill_directory_digest(path: &Path) -> anyhow::Result> { Ok(()) } let mut hasher = Sha256::new(); - hash_directory(path, &mut hasher)?; + hash_directory(path, &mut hasher, true)?; Ok(Some(hasher.finalize().into())) } @@ -3348,10 +3819,15 @@ fn move_path_no_replace(_source: &Path, _target: &Path) -> anyhow::Result<()> { /// 复制回退建的副本(不是软链,但带标记)。 fn is_managed_copy(path: &Path) -> bool { - std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir() && !metadata_is_link(&metadata)) && path.join(MANAGED_COPY_MARKER).is_file() } +fn is_managed_link(path: &Path, source: &Path) -> bool { + path.canonicalize().ok().zip(source.canonicalize().ok()) + .is_some_and(|(target, source)| target == source) +} + #[cfg(unix)] fn symlink_dir(source: &Path, link: &Path) -> std::io::Result<()> { std::os::unix::fs::symlink(source, link) @@ -3362,6 +3838,16 @@ fn symlink_dir(source: &Path, link: &Path) -> std::io::Result<()> { std::os::windows::fs::symlink_dir(source, link) } +fn metadata_is_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { metadata.file_type().is_symlink() } +} + fn copy_dir_all(source: &Path, destination: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(destination) .with_context(|| format!("创建目录失败:{}", destination.display()))?; @@ -3397,7 +3883,7 @@ fn remove_link(link: &Path) -> anyhow::Result<()> { match std::fs::symlink_metadata(link) { Ok(metadata) => { let file_type = metadata.file_type(); - let result = if file_type.is_symlink() { + let result = if metadata_is_link(&metadata) { remove_symlink(link, &file_type) } else if file_type.is_dir() { std::fs::remove_dir_all(link) @@ -3417,7 +3903,7 @@ fn remove_link(link: &Path) -> anyhow::Result<()> { #[cfg(windows)] fn remove_symlink(link: &Path, file_type: &std::fs::FileType) -> std::io::Result<()> { use std::os::windows::fs::FileTypeExt; - if file_type.is_symlink_dir() { + if file_type.is_symlink_dir() || file_type.is_dir() { std::fs::remove_dir(link) } else { std::fs::remove_file(link) @@ -3429,7 +3915,7 @@ fn remove_symlink(link: &Path, _file_type: &std::fs::FileType) -> std::io::Resul std::fs::remove_file(link) } -/// 扫描一个 skills 目录里「真实目录 + SKILL.md」的 Skill(跳过软链和隐藏目录),返回 (id, (名称, 描述))。 +/// 扫描一个 skills 目录中包含 `SKILL.md` 的目录和有效链接,跳过隐藏目录。 fn scan_skill_dirs(root: &Path) -> Vec<(String, (String, String))> { let Ok(dir) = std::fs::read_dir(root) else { return Vec::new(); @@ -3441,9 +3927,8 @@ fn scan_skill_dirs(root: &Path) -> Vec<(String, (String, String))> { if id.starts_with('.') { continue; } - let physical = std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir()); let manifest = path.join(SKILL_MANIFEST_FILE); - if physical && manifest.is_file() { + if manifest.is_file() { let meta = read_skill_manifest(&manifest, &id); skills.push((id, meta)); } @@ -3452,6 +3937,26 @@ fn scan_skill_dirs(root: &Path) -> Vec<(String, (String, String))> { skills } +fn scan_shared_skill_dirs(root: &Path) -> anyhow::Result> { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).with_context(|| format!("读取共享 Skill 目录失败:{}", root.display())), + }; + let mut skills = Vec::new(); + for entry in entries { + let entry = entry?; + let Some(id) = entry.file_name().to_str().map(str::to_string) else { continue }; + if id.starts_with('.') { continue; } + let manifest = entry.path().join(SKILL_MANIFEST_FILE); + if manifest.is_file() { + skills.push((id.clone(), read_skill_manifest(&manifest, &id))); + } + } + skills.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(skills) +} + fn is_linked(link: &Path) -> bool { std::fs::symlink_metadata(link).is_ok() } @@ -3506,6 +4011,183 @@ mod tests { entries.iter().find(|item| item.id == id).unwrap() } + #[test] + fn shared_skill_is_discovered_and_toggled_without_creating_a_copy() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "---\nname: alpha\n---\noriginal"); + write_skill(&temp.path().join("claude-skills/alpha"), "---\nname: alpha\n---\nanother agent"); + + let entries = manager.try_local_inventory().unwrap(); + let found = entries.iter().filter(|item| item.id == "alpha").collect::>(); + assert_eq!(found.len(), 1); + assert!(found[0].enabled); + assert!(found[0].conflict.is_none()); + assert_eq!(found[0].locations.len(), 2); + assert!(found[0].agents.contains(&"codex".to_string())); + assert!(found[0].agents.contains(&"claude".to_string())); + + for enabled in [true, false, false, true] { + manager.set_enabled_for("codex", "alpha", enabled).unwrap(); + assert_eq!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled, enabled); + assert!(shared.join("alpha/SKILL.md").is_file()); + assert!(!manager.linked_dir().join("alpha").exists()); + assert!(!manager.source_dir.join("alpha").exists()); + } + assert!(temp.path().join("claude-skills/alpha/SKILL.md").is_file()); + } + + #[test] + fn shared_and_codex_conflict_compares_complete_contents_and_preserves_both() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp).with_shared_skills_dir(&shared); + let original = shared.join("alpha"); + let codex = manager.linked_dir().join("alpha"); + write_skill(&original, "---\nname: alpha\n---\n"); + write_skill(&codex, "---\nname: alpha\n---\n"); + std::fs::write(original.join("script.js"), "one").unwrap(); + std::fs::write(codex.join("script.js"), "two").unwrap(); + + let entries = manager.try_local_inventory().unwrap(); + assert!(entry(&entries, "alpha").conflict.is_some()); + assert!(manager.set_enabled_for("codex", "alpha", false).is_err()); + assert!(manager.set_enabled_for("codex", "alpha", true).is_err()); + assert!(!manager.codex_home.join("config.toml").exists()); + assert_eq!(std::fs::read_to_string(original.join("script.js")).unwrap(), "one"); + assert_eq!(std::fs::read_to_string(codex.join("script.js")).unwrap(), "two"); + } + + #[test] + fn native_rules_use_last_match_and_preserve_other_configuration() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "---\nname: 'alpha' # comment\n---\n"); + std::fs::create_dir_all(&manager.codex_home).unwrap(); + std::fs::write(manager.codex_home.join("config.toml"), + "model = 'example'\n[[skills.config]]\nname = 'alpha'\nenabled = false\n").unwrap(); + assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + manager.set_enabled_for("codex", "alpha", true).unwrap(); + let updated = std::fs::read_to_string(manager.codex_home.join("config.toml")).unwrap(); + assert!(updated.contains("model = 'example'")); + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + manager.set_enabled_for("codex", "alpha", false).unwrap(); + assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + } + + #[test] + fn enabling_existing_agent_does_not_adopt_or_copy_skill() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + write_skill(&temp.path().join("claude-skills/alpha"), "---\nname: alpha\n---\n"); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + assert!(!manager.source_dir.join("alpha").exists()); + manager.set_enabled_for("codex", "alpha", true).unwrap(); + assert_eq!(manager.load_state().installed["alpha"].repo_key, LOCAL_IMPORT_REPO_KEY); + assert!(temp.path().join("claude-skills/alpha/SKILL.md").is_file()); + } + + #[test] + fn shared_skill_scan_follows_directory_aliases() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let actual = temp.path().join("actual"); + write_skill(&actual, "---\nname: alpha\n---\n"); + std::fs::create_dir_all(&shared).unwrap(); + let alias = shared.join("alpha"); + #[cfg(unix)] + std::os::unix::fs::symlink(&actual, &alias).unwrap(); + #[cfg(windows)] + { + let result = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&alias) + .arg(&actual) + .output().unwrap(); + assert!(result.status.success(), "mklink failed: {}", + String::from_utf8_lossy(&result.stderr)); + } + let manager = manager(&temp).with_shared_skills_dir(&shared); + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + manager.set_enabled_for("codex", "alpha", false).unwrap(); + assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + assert!(actual.join("SKILL.md").is_file()); + } + + #[test] + fn externally_created_agent_link_is_discovered_and_preserved() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let actual = temp.path().join("actual"); + let root = temp.path().join("claude-skills"); + let alias = root.join("alpha"); + write_skill(&actual, "---\nname: alpha\n---\n"); + std::fs::create_dir_all(&root).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&actual, &alias).unwrap(); + #[cfg(windows)] + { + let result = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&alias) + .arg(&actual) + .output().unwrap(); + assert!(result.status.success()); + } + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").agents.contains(&"claude".to_string())); + assert!(manager.set_enabled_for("claude", "alpha", false).is_err()); + assert!(alias.join("SKILL.md").is_file()); + assert!(actual.join("SKILL.md").is_file()); + } + + #[cfg(windows)] + #[test] + fn managed_junctions_are_removed_without_touching_the_source() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let external = temp.path().join("external/alpha"); + write_skill(&external, "---\nname: alpha\n---\n"); + manager.import_from_directory("alpha", &external).unwrap(); + let source = manager.source_dir.join("alpha"); + let codex = manager.linked_dir().join("alpha"); + let claude = temp.path().join("claude-skills").join("alpha"); + for link in [&codex, &claude] { + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + let result = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(&source) + .output().unwrap(); + assert!(result.status.success(), "mklink failed: {} {}", + String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); + } + manager.set_enabled_for("codex", "alpha", false).unwrap(); + assert!(!codex.exists()); + assert!(claude.join(SKILL_MANIFEST_FILE).is_file()); + manager.set_enabled_for("claude", "alpha", false).unwrap(); + assert!(!claude.exists()); + assert!(source.join(SKILL_MANIFEST_FILE).is_file()); + assert!(manager.list_backups().is_empty()); + } + + #[test] + fn unsupported_native_name_rule_reports_unknown_without_hiding_other_skills() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "---\nname: &alias alpha\n---\n"); + write_skill(&shared.join("beta"), "---\nname: beta\n---\n"); + std::fs::create_dir_all(&manager.codex_home).unwrap(); + std::fs::write(manager.codex_home.join("config.toml"), + "[[skills.config]]\nname = 'alpha'\nenabled = false\n").unwrap(); + let entries = manager.try_local_inventory().unwrap(); + assert!(entry(&entries, "alpha").locations[0].status_error.is_some()); + assert!(entry(&entries, "beta").enabled); + assert!(manager.set_enabled_for("codex", "alpha", false).is_err()); + } + #[test] fn managed_skill_enables_into_other_agents_as_symlink() { let temp = tempfile::tempdir().unwrap(); @@ -4515,7 +5197,7 @@ mod tests { let new_zip = repo_zip(&[("kit-main/alpha/SKILL.md", "new")]); let new = zip_skill(&repo, &new_zip, "alpha"); first.install_downloaded_zip_if_unchanged(&new, &new_zip, &guard).unwrap(); - assert!(!is_linked(&first.linked_dir().join(&old.id))); + assert!(!entry(&first.try_local_inventory().unwrap(), &old.id).enabled); assert!(!first.list_repos().iter().find(|item| item.key() == repo.key()).unwrap().enabled); let guard = first.capture_download_guard(&old.id, &repo.key(), false).unwrap(); @@ -4524,6 +5206,7 @@ mod tests { let newer = zip_skill(&repo, &newer_zip, "alpha"); first.install_downloaded_zip_if_unchanged(&newer, &newer_zip, &guard).unwrap(); assert!(is_linked(&first.linked_dir().join(&old.id))); + assert!(entry(&first.try_local_inventory().unwrap(), &old.id).enabled); assert_eq!(first.load_state().installed[&old.id].content_hash, newer.content_hash); } @@ -4592,7 +5275,7 @@ mod tests { } #[test] - fn disabling_edited_copy_preserves_content_and_invalidates_download() { + fn disabling_edited_copy_preserves_both_versions_and_rejects_stale_download() { let temp = tempfile::tempdir().unwrap(); let first = manager(&temp); let second = manager(&temp); @@ -4609,14 +5292,12 @@ mod tests { second.set_enabled(&skill.id, false).unwrap(); assert_eq!( std::fs::read_to_string(first.source_dir.join(&skill.id).join(SKILL_MANIFEST_FILE)).unwrap(), - "copy edit", + "original", ); + assert_eq!(std::fs::read_to_string(link.join(SKILL_MANIFEST_FILE)).unwrap(), "copy edit"); + assert!(!entry(&first.try_local_inventory().unwrap(), &skill.id).enabled); assert!(first.install_downloaded_zip_if_unchanged(&skill, &zip, &guard).is_err()); - let contents: BTreeSet<_> = first.list_backups().iter() - .map(|backup| std::fs::read_to_string(first.backups_dir.join(&backup.id).join(SKILL_MANIFEST_FILE)).unwrap()) - .collect(); - assert!(contents.contains("original")); - assert!(contents.contains("copy edit")); + assert!(first.list_backups().is_empty()); } #[test] @@ -4637,7 +5318,8 @@ mod tests { let new_zip = repo_zip(&[("kit-main/alpha/SKILL.md", "updated")]); let updated = zip_skill(&repo, &new_zip, "alpha"); first.install_downloaded_zip_if_unchanged(&updated, &new_zip, &guard).unwrap(); - assert!(!is_linked(&link)); + assert!(link.join(SKILL_MANIFEST_FILE).is_file()); + assert!(!entry(&first.try_local_inventory().unwrap(), &skill.id).enabled); assert_eq!(first.load_state().installed[&skill.id].content_hash, updated.content_hash); } @@ -6962,7 +7644,7 @@ mod tests { } #[test] - fn disabling_removes_the_link_but_keeps_the_source() { + fn disabling_keeps_the_source_and_stops_codex_discovery() { let temp = tempfile::tempdir().unwrap(); let manager = manager(&temp); let zip = repo_zip(&[("skills-main/alpha/SKILL.md", "---\nname: alpha\n---\n")]); @@ -6970,8 +7652,11 @@ mod tests { .install_from_zip(&sample_skill("alpha", "alpha", "hash-1"), &zip) .unwrap(); + let link = manager.linked_dir().join("alpha"); + let was_symlink = metadata_is_link(&std::fs::symlink_metadata(&link).unwrap()); manager.set_enabled("alpha", false).unwrap(); - assert!(!manager.linked_dir().join("alpha").exists()); + assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + assert_eq!(link.exists(), !was_symlink); assert!(manager.source_dir().join("alpha").is_dir()); // 停用只能删链接:如果误用 remove_dir_all 顺着软链删下去, // SSOT 里的真实文件会一起没掉,停用就成了毁数据。 @@ -6984,6 +7669,7 @@ mod tests { ); manager.set_enabled("alpha", true).unwrap(); + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); assert!( manager .linked_dir() @@ -6993,8 +7679,7 @@ mod tests { ); } - /// 停用 → 重新启用 → 再停用,反复切换不该残留或报错。 - /// Windows 上目录软链必须用 remove_dir 删,用 remove_file 会 Access denied。 + /// 停用和启用应始终反映 Codex 原生状态。 #[test] fn toggling_enabled_repeatedly_is_stable() { let temp = tempfile::tempdir().unwrap(); @@ -7008,8 +7693,9 @@ mod tests { for _ in 0..3 { manager.set_enabled("alpha", false).unwrap(); - assert!(!manager.linked_dir().join("alpha").exists()); + assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); manager.set_enabled("alpha", true).unwrap(); + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); assert!( manager .linked_dir() @@ -7222,15 +7908,19 @@ mod tests { let local = manager.linked_dir().join("my-own-skill"); std::fs::create_dir_all(&local).unwrap(); std::fs::write(local.join(SKILL_MANIFEST_FILE), "---\nname: local\n---\n").unwrap(); + let identity = skill_path_identity(&local).unwrap(); manager.set_enabled("my-own-skill", false).unwrap(); - assert!(!manager.linked_dir().join("my-own-skill").exists()); - assert!(manager.source_dir().join("my-own-skill").join(SKILL_MANIFEST_FILE).exists()); + assert_eq!(skill_path_identity(&local).unwrap(), identity); + assert!(!manager.source_dir().join("my-own-skill").exists()); + assert!(manager.list_backups().is_empty()); assert!(!manager.merge_entries(&[]).iter().find(|entry| entry.id == "my-own-skill").unwrap().enabled); manager.set_enabled("my-own-skill", true).unwrap(); - assert!(manager.linked_dir().join("my-own-skill").exists()); + assert_eq!(skill_path_identity(&local).unwrap(), identity); + assert!(manager.merge_entries(&[]).iter().find(|entry| entry.id == "my-own-skill").unwrap().enabled); + assert!(manager.list_backups().is_empty()); } /// 没有 SKILL.md 的目录、以及 `.system` 自带目录,都不应被当成「本地自建」重复收录。 diff --git a/crates/codex-plus-core/tests/all/extension_manager_data.rs b/crates/codex-plus-core/tests/all/extension_manager_data.rs index 7104ad6222..129859db57 100644 --- a/crates/codex-plus-core/tests/all/extension_manager_data.rs +++ b/crates/codex-plus-core/tests/all/extension_manager_data.rs @@ -200,6 +200,8 @@ fn skills_view_excludes_paths_descriptions_and_uninstalled_entries() { update_available: false, agents: vec!["codex".into()], managed: true, + locations: Vec::new(), + conflict: None, }; let mut remote = entry.clone(); remote.installed = false; @@ -220,6 +222,41 @@ fn skills_view_excludes_paths_descriptions_and_uninstalled_entries() { assert!(!view.to_string().contains("SECRET")); } +#[test] +fn public_skills_follow_shared_native_enable_rules() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = SkillsManager::new( + temp.path().join("library"), temp.path().join("backups"), + temp.path().join("state.json"), temp.path().join("codex"), + ).with_shared_skills_dir(&shared); + fs::create_dir_all(shared.join("example")).unwrap(); + fs::write(shared.join("example/SKILL.md"), "---\nname: example\n---\n").unwrap(); + assert_eq!(public::installed_skills(&manager.try_local_inventory().unwrap())["skills"][0]["enabled"], true); + manager.set_enabled_for("codex", "example", false).unwrap(); + assert_eq!(public::installed_skills(&manager.try_local_inventory().unwrap())["skills"][0]["enabled"], false); + manager.set_enabled_for("codex", "example", true).unwrap(); + assert_eq!(public::installed_skills(&manager.try_local_inventory().unwrap())["skills"][0]["enabled"], true); +} + +#[test] +fn public_skills_distinguish_unknown_from_disabled() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let codex = temp.path().join("codex"); + let manager = SkillsManager::new( + temp.path().join("library"), temp.path().join("backups"), + temp.path().join("state.json"), &codex, + ).with_shared_skills_dir(&shared); + fs::create_dir_all(shared.join("example")).unwrap(); + fs::create_dir_all(&codex).unwrap(); + fs::write(shared.join("example/SKILL.md"), "---\nname: &alias example\n---\n").unwrap(); + fs::write(codex.join("config.toml"), "[[skills.config]]\nname = \"example\"\nenabled = false\n").unwrap(); + let view = public::installed_skills(&manager.try_local_inventory().unwrap()); + assert!(view["skills"][0]["enabled"].is_null()); + assert!(view["skills"][0]["statusError"].as_str().unwrap().contains("状态未知")); +} + #[test] fn missing_stores_are_read_without_creating_directories() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/all/skills_lifecycle.rs b/crates/codex-plus-core/tests/all/skills_lifecycle.rs index 8d855d0828..1bb9c03e60 100644 --- a/crates/codex-plus-core/tests/all/skills_lifecycle.rs +++ b/crates/codex-plus-core/tests/all/skills_lifecycle.rs @@ -80,7 +80,7 @@ fn failed_restore_preserves_the_original_backup() { } #[test] -fn disabling_rejects_staging_inside_the_original_skill() { +fn disabling_does_not_stage_inside_the_original_skill() { let temp = tempfile::tempdir().unwrap(); let home = temp.path().join("home"); let local = home.join("skills/alpha"); @@ -88,10 +88,11 @@ fn disabling_rejects_staging_inside_the_original_skill() { std::fs::write(local.join("SKILL.md"), "original").unwrap(); let source = local.join("managed"); let manager = SkillsManager::new(&source, temp.path().join("backups"), temp.path().join("skills.json"), &home); - assert!(manager.set_enabled("alpha", false).is_err()); + manager.set_enabled("alpha", false).unwrap(); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "original"); assert!(manager.list_backups().is_empty()); - assert_eq!(std::fs::read_dir(source).unwrap().count(), 0); + assert!(!source.exists()); + assert!(!manager.local_inventory().iter().find(|skill| skill.id == "alpha").unwrap().enabled); } #[test] @@ -109,13 +110,10 @@ fn disabling_a_copied_installation_must_preserve_user_edits() { std::fs::write(local.join("SKILL.md"), "user edits").unwrap(); std::fs::write(&state, r#"{"repos":[],"installed":{"alpha":{"id":"alpha","repoKey":"acme/kit@main","repoPath":"alpha","contentHash":"old"}}}"#).unwrap(); manager.set_enabled("alpha", false).unwrap(); - let mut preserved = std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap() == "user edits"; - for backup in manager.list_backups() { - preserved |= std::fs::read_to_string(backups.join(backup.id).join("SKILL.md")).unwrap() == "user edits"; - } - assert!(preserved, "disable succeeded but removed the only copy of user edits; source is unchanged and backups are empty"); - assert!(!local.exists()); - assert_eq!(std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap(), "user edits"); + assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user edits"); + assert_eq!(std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap(), "original source"); + assert!(manager.list_backups().is_empty()); + assert!(!manager.local_inventory().iter().find(|skill| skill.id == "alpha").unwrap().enabled); let installed = &manager.load_state().installed["alpha"]; assert_eq!(installed.repo_key, "acme/kit@main"); assert_eq!(installed.repo_path, "alpha"); @@ -162,20 +160,23 @@ fn failed_local_disable_must_not_hide_the_skill() { let backups = temp.path().join("backups"); let home = temp.path().join("home"); let state = temp.path().join("skills.json"); + let config = home.join("config.toml"); let manager = SkillsManager::new(&source, &backups, &state, &home); let local = home.join("skills/alpha"); std::fs::create_dir_all(&local).unwrap(); std::fs::write(local.join("SKILL.md"), "user original").unwrap(); std::fs::write(&state, r#"{"repos":[],"installed":{}}"#).unwrap(); assert!(manager.local_inventory().iter().any(|skill| skill.id == "alpha")); - let mut permissions = std::fs::metadata(&state).unwrap().permissions(); + std::fs::write(&config, "model = 'example'\n").unwrap(); + let mut permissions = std::fs::metadata(&config).unwrap().permissions(); permissions.set_readonly(true); - std::fs::set_permissions(&state, permissions).unwrap(); + std::fs::set_permissions(&config, permissions).unwrap(); let result = manager.set_enabled("alpha", false); - let mut permissions = std::fs::metadata(&state).unwrap().permissions(); + let mut permissions = std::fs::metadata(&config).unwrap().permissions(); permissions.set_readonly(false); - std::fs::set_permissions(&state, permissions).unwrap(); - assert!(result.is_err(), "readonly state must reject persistence"); + std::fs::set_permissions(&config, permissions).unwrap(); + assert!(result.is_err(), "readonly config must reject persistence"); + assert_eq!(std::fs::read_to_string(config).unwrap(), "model = 'example'\n"); assert!(manager.local_inventory().iter().any(|skill| skill.id == "alpha"), "failed disable moved the only local directory to source without registering it; inventory and backups no longer expose the skill"); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user original"); assert!(!source.join("alpha").exists()); @@ -190,6 +191,7 @@ fn failed_copy_disable_restores_both_directories_and_metadata() { let backups = temp.path().join("backups"); let home = temp.path().join("home"); let state = temp.path().join("skills.json"); + let config = home.join("config.toml"); let manager = SkillsManager::new(&source, &backups, &state, &home); let local = home.join("skills/alpha"); std::fs::create_dir_all(&local).unwrap(); @@ -198,14 +200,16 @@ fn failed_copy_disable_restores_both_directories_and_metadata() { std::fs::write(source.join("alpha/SKILL.md"), "old source").unwrap(); std::fs::write(&state, r#"{"repos":[],"installed":{"alpha":{"id":"alpha","repoKey":"acme/kit@main","repoPath":"alpha"}}}"#).unwrap(); let before = manager.load_state(); - let mut permissions = std::fs::metadata(&state).unwrap().permissions(); + std::fs::write(&config, "model = 'example'\n").unwrap(); + let mut permissions = std::fs::metadata(&config).unwrap().permissions(); permissions.set_readonly(true); - std::fs::set_permissions(&state, permissions).unwrap(); + std::fs::set_permissions(&config, permissions).unwrap(); let result = manager.set_enabled("alpha", false); - let mut permissions = std::fs::metadata(&state).unwrap().permissions(); + let mut permissions = std::fs::metadata(&config).unwrap().permissions(); permissions.set_readonly(false); - std::fs::set_permissions(&state, permissions).unwrap(); + std::fs::set_permissions(&config, permissions).unwrap(); assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(config).unwrap(), "model = 'example'\n"); assert_eq!(manager.load_state(), before); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user edits"); assert_eq!(std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap(), "old source"); diff --git a/types/codex-plus-extensions.d.ts b/types/codex-plus-extensions.d.ts index cacc64c779..0916247e92 100644 --- a/types/codex-plus-extensions.d.ts +++ b/types/codex-plus-extensions.d.ts @@ -743,7 +743,7 @@ interface CodexPlusManagerPublicData { } }>; }; installedSkills: { skills: Array<{ - id: string; name: string; enabled: boolean; bundled: boolean; managed: boolean; agents: string[]; contentHash: string; + id: string; name: string; enabled: boolean | null; statusError?: string; bundled: boolean; managed: boolean; agents: string[]; contentHash: string; }> }; /** 仅 Codex++ 市场托管的插件,不是全部 Codex 插件。 */ installedPlugins: { plugins: Array<{ id: string; name: string; version: string; source: "public" | "full"; enabled: boolean }> }; From f5b7d2ee01d3a18c13fe74c73efb71832c6e3ef7 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 00:15:29 +0800 Subject: [PATCH 2/5] fix(skills): address shared discovery review --- CHANGELOG.md | 1 + crates/codex-plus-core/src/skills.rs | 365 +++++++++++++++++++++++---- 2 files changed, 314 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a82ecee169..03d03820fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ - **「移除 Responses 图片 detail」开关打开后真正生效**:请求改走本地代理,和界面说明一致。 - **用量统计区分不同供应商的同名模型**:结构化用量索引保留完整的 `cpx-…/模型` 名,超长模型名不再沿用上一轮的模型。 - **最近会话路径不再显示 Windows 的 `\\?\` 前缀**(#3030)。 +- **共享 Skill 的发现与启停**:识别 Codex 共享目录及原生配置,重新安装 Skill 时恢复启用;拓展只读数据 `installedSkills.enabled` 在状态无法确定时为 `null`,并提供 `statusError`。 ### 新增 diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 758f262413..2a81747b8b 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -106,6 +106,8 @@ pub struct SkillsState { pub repos: Vec, #[serde(default)] pub installed: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub shared_agent_links: BTreeMap, } impl Default for SkillsState { @@ -113,6 +115,7 @@ impl Default for SkillsState { Self { repos: default_skill_repos(), installed: BTreeMap::new(), + shared_agent_links: BTreeMap::new(), } } } @@ -576,7 +579,7 @@ struct NativeSkillRule { index: usize, name: Option, path: Option, - enabled: bool, + enabled: Option, } struct NativeSkillConfig { @@ -595,10 +598,12 @@ impl NativeSkillConfig { if !manifest.is_file() { return Ok(fallback_name.to_string()); } - let last_path = self.rules.iter().rposition(|rule| rule.path.as_deref() + let last_path = self.rules.iter().rposition(|rule| rule.name.is_none() && rule.path.as_deref() .is_some_and(|path| native_skill_paths_match(path, manifest))); if self.rules.iter().enumerate().any(|(index, rule)| - rule.name.is_some() && last_path.is_none_or(|last| index > last)) + rule.name.is_some() && rule.path.as_deref() + .is_none_or(|path| native_skill_paths_match(path, manifest)) + && last_path.is_none_or(|last| index > last)) { read_native_skill_name(manifest) } else { @@ -608,13 +613,16 @@ impl NativeSkillConfig { fn matching_rule(&self, manifest: &Path, name: &str) -> Option<(usize, &NativeSkillRule)> { self.rules.iter().enumerate().rev().find(|(_, rule)| { - rule.name.as_deref() == Some(name) || rule.path.as_deref() - .is_some_and(|path| native_skill_paths_match(path, manifest)) + rule.name.as_deref().is_none_or(|expected| expected == name) + && rule.path.as_deref().is_none_or(|path| native_skill_paths_match(path, manifest)) }) } - fn enabled(&self, manifest: &Path, name: &str) -> bool { - self.matching_rule(manifest, name).map_or(true, |(_, rule)| rule.enabled) + fn enabled(&self, manifest: &Path, name: &str) -> anyhow::Result { + match self.matching_rule(manifest, name) { + Some((_, rule)) => rule.enabled.context("Codex Skill 配置无法识别,启用状态未知"), + None => Ok(true), + } } } @@ -658,7 +666,15 @@ impl SkillsManager { } pub fn shared_skills_dir(&self) -> Option<&Path> { - self.shared_skills_dir.as_deref() + let shared = self.shared_skills_dir.as_deref()?; + let linked = self.linked_dir(); + if shared == linked || shared.canonicalize().ok().zip(linked.canonicalize().ok()) + .is_some_and(|(shared, linked)| shared == linked) + { + None + } else { + Some(shared) + } } fn read_native_skill_config(&self) -> anyhow::Result { @@ -678,19 +694,20 @@ impl SkillsManager { if let Some(config) = skills.get("config") { let config = config.as_array().context("Codex skills.config 不是数组")?; for (index, entry) in config.iter().enumerate() { - let entry = entry.as_table().context("Codex skills.config 条目不是表")?; - let name = entry.get("name").map(|value| value.as_str() - .map(|name| name.trim().to_string()).context("Codex Skill 名称必须是字符串")).transpose()?; - let path = entry.get("path").map(|value| value.as_str() - .map(PathBuf::from).context("Codex Skill 路径必须是字符串")).transpose()?; - if name.is_some() == path.is_some() || path.as_ref().is_some_and(|path| !path.is_absolute()) { - anyhow::bail!("Codex skills.config 条目必须且只能指定名称或绝对路径"); - } - if name.as_deref() == Some("") { + let Some(entry) = entry.as_table() else { continue }; + let invalid = entry.get("name").is_some_and(|value| value.as_str().is_none()) + || entry.get("path").is_some_and(|value| value.as_str().is_none()); + let name = entry.get("name").and_then(toml::Value::as_str) + .map(str::trim).filter(|name| !name.is_empty()).map(str::to_string); + let path = entry.get("path").and_then(toml::Value::as_str) + .filter(|path| !path.is_empty()).map(PathBuf::from) + .map(|path| if path.is_absolute() { path } else { self.codex_home.join(path) }); + if name.is_none() && path.is_none() { continue; } - let enabled = entry.get("enabled").and_then(toml::Value::as_bool) - .context("Codex skills.config 条目缺少有效的 enabled")?; + let enabled = if invalid { None } else { + entry.get("enabled").and_then(toml::Value::as_bool) + }; rules.push(NativeSkillRule { index, name, path, enabled }); } } @@ -698,23 +715,30 @@ impl SkillsManager { Ok(NativeSkillConfig { original, rules }) } - fn set_native_skill_paths(&self, manifests: &[(PathBuf, String)], enabled: bool) -> anyhow::Result<()> { + fn set_native_skill_paths( + &self, + manifests: &[(PathBuf, String)], + enabled: bool, + ) -> anyhow::Result>, Vec)>> { let config = self.read_native_skill_config()?; let mut changes = Vec::new(); for (path, name) in manifests { let name = config.name_for_matching(path, name)?; - if config.enabled(path, &name) != enabled { + if config.enabled(path, &name)? != enabled { changes.push((path, name)); } } if changes.is_empty() { - return Ok(()); + return Ok(None); } let text = std::str::from_utf8(config.original.as_deref().unwrap_or_default())?; let mut document = text.parse::() .map_err(|_| anyhow::anyhow!("Codex 配置格式错误,请先修复 config.toml"))?; - let skills = document.as_table_mut().entry("skills") - .or_insert(toml_edit::Item::Table(toml_edit::Table::new())); + let skills = document.as_table_mut().entry("skills").or_insert_with(|| { + let mut table = toml_edit::Table::new(); + table.set_implicit(true); + toml_edit::Item::Table(table) + }); let inline = skills.is_inline_table(); let skills = skills.as_table_like_mut().context("Codex skills 配置不是表")?; let item = skills.entry("config").or_insert_with(|| { @@ -762,8 +786,10 @@ impl SkillsManager { if current != config.original { anyhow::bail!("Codex 配置已被其他窗口更新,请重试;未覆盖新配置"); } - crate::settings::atomic_write(&self.codex_home.join("config.toml"), document.to_string().as_bytes()) - .context("无法保存 Codex 技能配置") + let updated = document.to_string().into_bytes(); + crate::settings::atomic_write(&self.codex_home.join("config.toml"), &updated) + .context("无法保存 Codex 技能配置")?; + Ok(Some((config.original, updated))) } /// 路径不完整时返回全局范围;任意受管路径相交都不能互相隔离。 @@ -774,8 +800,9 @@ impl SkillsManager { self.state_path.clone(), self.linked_dir(), ]; - let mut scope: Vec<_> = paths.iter().chain(self.agent_dirs.iter().map(|(_, dir)| dir)) - .chain(self.shared_skills_dir.iter()) + let mut scope: Vec<_> = paths.iter().map(PathBuf::as_path) + .chain(self.agent_dirs.iter().map(|(_, dir)| dir.as_path())) + .chain(self.shared_skills_dir()) .map(|path| coordination_path(path)) .collect::>()?; // 状态文件通过原子替换写入;父目录身份和文件名足以识别共享路径。 @@ -795,23 +822,43 @@ impl SkillsManager { .with_context(|| format!("不支持的 Agent:{agent}")) } - /// 把 Skill 启用到 / 停用于某个 Agent。Codex 沿用 [`Self::set_enabled`];其它 Agent 同样是软链到受控源目录。 + /// 把 Skill 启用到 / 停用于某个 Agent。共享 Skill 优先链接到共享原件。 /// /// 启用时若还没有受控源(Codex 或其它 Agent 自建的 Skill),先从它所在的目录导入一份。 /// 停用只删本工具建的软链;Agent 自己目录里的真实 Skill 不删,报错说明。 pub fn set_enabled_for(&self, agent: &str, id: &str, enabled: bool) -> anyhow::Result<()> { validate_skill_id(id)?; let mut coordination = skills_coordination(); - self.ensure_skill_name_not_aliased(id, &self.load_state_checked_unlocked()?)?; + let initial_state = self.load_state_checked_unlocked()?; + self.ensure_skill_name_not_aliased(id, &initial_state)?; let result = (|| { if enabled && agent != "codex" && self.agent_dir(agent)?.join(id).join(SKILL_MANIFEST_FILE).is_file() { return Ok(()); } - let shared = self.shared_skills_dir.as_ref() - .is_some_and(|dir| dir.join(id).join(SKILL_MANIFEST_FILE).is_file()); + let shared = self.shared_skills_dir().map(|dir| dir.join(id)) + .filter(|dir| dir.join(SKILL_MANIFEST_FILE).is_file()); if enabled { - if agent != "codex" || (!shared && !self.linked_dir().join(id).join(SKILL_MANIFEST_FILE).is_file()) { + if shared.is_none() && (agent != "codex" || !self.linked_dir().join(id).join(SKILL_MANIFEST_FILE).is_file()) { self.ensure_managed_source_unlocked(id, &mut coordination)?; + } else if agent != "codex" { + if let Some(shared) = &shared { + let shared_target = shared.canonicalize()?; + let shared_digest = skill_directory_digest(&shared_target)?; + let managed_source = self.source_dir.join(id).canonicalize().ok(); + let roots = std::iter::once(self.linked_dir()) + .chain(self.agent_dirs.iter().map(|(_, root)| root.clone())); + for root in roots { + let candidate = root.join(id); + if !candidate.join(SKILL_MANIFEST_FILE).is_file() || is_managed_copy(&candidate) { + continue; + } + let target = candidate.canonicalize()?; + if target != shared_target && Some(&target) != managed_source.as_ref() { + anyhow::ensure!(skill_directory_digest(&target)? == shared_digest, + "同名 Skill 安装内容不同,请先选择要启用的版本"); + } + } + } } } if agent == "codex" { @@ -822,6 +869,27 @@ impl SkillsManager { let metadata = std::fs::symlink_metadata(&link); if !enabled { return match metadata { + Ok(metadata) if metadata_is_link(&metadata) && shared.as_ref().is_some_and(|source| + is_managed_link(&link, source) && skill_path_identity(&link).ok().is_some_and(|identity| + initial_state.shared_agent_links.get(&link.to_string_lossy().into_owned()).copied() + == Some([identity.device, identity.file]))) => { + let mut state = initial_state.clone(); + let staging = dir.join(format!(".codex-plus-link-{}", uuid::Uuid::new_v4())); + move_path_no_replace(&link, &staging)?; + let expected = state.shared_agent_links[&link.to_string_lossy().into_owned()]; + if skill_path_identity(&staging).map(|identity| [identity.device, identity.file])? != expected { + move_path_no_replace(&staging, &link)?; + anyhow::bail!("Skill 链接在停用期间发生变化,未删除"); + } + state.shared_agent_links.remove(&link.to_string_lossy().into_owned()); + if let Err(error) = self.save_state_unlocked(&state) { + return match move_path_no_replace(&staging, &link) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!("恢复 Skill 链接失败:{rollback:#}"))), + }; + } + remove_link(&staging) + } Ok(metadata) if metadata_is_link(&metadata) && is_managed_link(&link, &self.source_dir.join(id)) => remove_link(&link), Ok(_) if is_managed_copy(&link) => { @@ -839,12 +907,24 @@ impl SkillsManager { if metadata.is_ok() { anyhow::bail!("启用位置已存在其他内容,未覆盖:{}", link.display()); } - let source = self.source_dir.join(id); + let source = shared.clone().unwrap_or_else(|| self.source_dir.join(id)); if !source.is_dir() { anyhow::bail!("skill 源目录不存在:{}", source.display()); } std::fs::create_dir_all(&dir).with_context(|| format!("创建 {} 失败", dir.display()))?; - link_or_copy(&source, &link).map(|_| ()) + let identity = link_or_copy(&source, &link)?; + if shared.is_some() && std::fs::symlink_metadata(&link).is_ok_and(|metadata| metadata_is_link(&metadata)) { + let mut state = initial_state.clone(); + state.shared_agent_links.insert(link.to_string_lossy().into_owned(), [identity.device, identity.file]); + if let Err(error) = self.save_state_unlocked(&state) { + return match recover_published_path(&link, identity) { + Ok(Some(path)) => Err(error.context(format!("新建的 Skill 链接保留在 {}", path.display()))), + Ok(None) => Err(error), + Err(rollback) => Err(error.context(format!("回滚 Skill 链接失败:{rollback:#}"))), + }; + } + } + Ok(()) })(); coordination.changed_nested_skill(self.coordination_scope(), id); result @@ -861,7 +941,7 @@ impl SkillsManager { let source = self.source_dir.join(id); let codex = self.linked_dir().join(id); let mut roots = Vec::new(); - roots.extend(self.shared_skills_dir.iter().cloned()); + roots.extend(self.shared_skills_dir().map(Path::to_path_buf)); roots.push(self.linked_dir()); roots.extend(self.agent_dirs.iter().map(|(_, dir)| dir.clone())); let mut originals = BTreeSet::new(); @@ -954,7 +1034,7 @@ impl SkillsManager { } let mut roots = vec![self.source_dir.clone(), self.linked_dir()]; roots.extend(self.agent_dirs.iter().map(|(_, dir)| dir.clone())); - roots.extend(self.shared_skills_dir.iter().cloned()); + roots.extend(self.shared_skills_dir().map(Path::to_path_buf)); for root in roots { let entries = match std::fs::read_dir(&root) { Ok(entries) => entries, @@ -1147,6 +1227,7 @@ impl SkillsManager { self.load_state_checked_unlocked()? }; let linked = self.linked_dir(); + let shared_root = self.shared_skills_dir(); let mut entries: BTreeMap<(String, String), SkillEntry> = BTreeMap::new(); for skill in remote { @@ -1220,7 +1301,7 @@ impl SkillsManager { } } - if let Some(root) = &self.shared_skills_dir { + if let Some(root) = shared_root { for (id, (name, description)) in scan_shared_skill_dirs(root)? { if !entries.values().any(|item| item.id == id && item.installed) { entries.insert((String::new(), id.clone()), SkillEntry { @@ -1282,7 +1363,7 @@ impl SkillsManager { }; let mut roots = vec![(if entry.bundled { "system" } else { "codex" }, codex)]; if !entry.bundled { - if let Some(shared) = &self.shared_skills_dir { + if let Some(shared) = shared_root { roots.push(("shared", shared.join(&entry.id))); } } @@ -1290,8 +1371,9 @@ impl SkillsManager { let manifest = path.join(SKILL_MANIFEST_FILE); if !manifest.is_file() { continue; } let (name, _) = read_skill_manifest(&manifest, &entry.id); - let (enabled, status_error) = match native_config.name_for_matching(&manifest, &name) { - Ok(name) => (native_config.enabled(&manifest, &name), None), + let (enabled, status_error) = match native_config.name_for_matching(&manifest, &name) + .and_then(|name| native_config.enabled(&manifest, &name)) { + Ok(enabled) => (enabled, None), Err(error) => (false, Some(format!("{error:#}"))), }; let managed = root_kind == "codex" && (is_managed_copy(&path) @@ -1315,7 +1397,13 @@ impl SkillsManager { let manifest = path.join(SKILL_MANIFEST_FILE); if manifest.is_file() { if !agents.contains(agent) { agents.push(agent.clone()); } - let managed = is_managed_copy(&path) + let managed_shared_link = state.shared_agent_links + .get(&path.to_string_lossy().into_owned()).is_some_and(|recorded| + skill_path_identity(&path).ok().is_some_and(|identity| + recorded == &[identity.device, identity.file]) + && shared_root.is_some_and(|shared| + is_managed_link(&path, &shared.join(&entry.id)))); + let managed = is_managed_copy(&path) || managed_shared_link || path.canonicalize().ok().zip(self.source_dir.join(&entry.id).canonicalize().ok()) .is_some_and(|(actual, source)| actual == source); entry.locations.push(SkillLocation { @@ -1328,7 +1416,7 @@ impl SkillsManager { } } if let (Some(shared), Some(codex)) = ( - self.shared_skills_dir.as_ref().map(|root| root.join(&entry.id)) + shared_root.map(|root| root.join(&entry.id)) .filter(|path| path.join(SKILL_MANIFEST_FILE).is_file()), linked.join(&entry.id).join(SKILL_MANIFEST_FILE).is_file().then(|| linked.join(&entry.id)), ) { @@ -1435,8 +1523,8 @@ impl SkillsManager { if let Ok(external) = external_dir.canonicalize() { let mut roots = vec![(self.linked_dir(), "Codex 的 skills 目录"), (self.source_dir.clone(), "Codex++ 的 Skills 受控目录")]; - if let Some(shared) = &self.shared_skills_dir { - roots.push((shared.clone(), "共享 Skill 目录")); + if let Some(shared) = self.shared_skills_dir() { + roots.push((shared.to_path_buf(), "共享 Skill 目录")); } for (root, label) in roots { if root.canonicalize().is_ok_and(|root| external.starts_with(root)) { @@ -1553,12 +1641,13 @@ impl SkillsManager { coordination: &mut SkillsCoordination, ) -> anyhow::Result { let id = installed.id.clone(); - anyhow::ensure!(require_empty || !self.shared_skills_dir.as_ref() + anyhow::ensure!(require_empty || !self.shared_skills_dir() .is_some_and(|root| root.join(&id).join(SKILL_MANIFEST_FILE).is_file()), "共享 Skill 的原目录不由此安装流程更新"); let destination = self.source_dir.join(&id); let mut state = self.load_state_checked_unlocked()?; self.ensure_skill_name_not_aliased(&id, &state)?; + let new_install = !state.installed.contains_key(&id); let identity_changed = replace_identity || state.installed.get(&id) != Some(&installed); state.installed.insert(id.clone(), installed); let link = self.linked_dir().join(&id); @@ -1581,6 +1670,7 @@ impl SkillsManager { let mut displaced = Vec::new(); let mut published = None; let mut created_links = Vec::new(); + let mut native_change = None; let result = (|| -> anyhow::Result<()> { // 两处原内容都先保留,成功后仍作为可恢复备份,不覆盖同名用户目录。 if !require_empty { @@ -1612,6 +1702,11 @@ impl SkillsManager { created_links.push((link.clone(), identity)); } result?; + if new_install { + let manifest = link.join(SKILL_MANIFEST_FILE); + let (name, _) = read_skill_manifest(&manifest, &id); + native_change = self.set_native_skill_paths(&[(manifest, name)], true)?; + } } for (copy, _) in &agent_copies { let mut published_copy = None; @@ -1627,6 +1722,20 @@ impl SkillsManager { if let Err(error) = result { let mut rollback_errors = Vec::new(); let mut recoveries = Vec::new(); + if let Some((original, updated)) = native_change { + let config_path = self.codex_home.join("config.toml"); + let restore = match std::fs::read(&config_path) { + Ok(current) if current == updated => match original { + Some(original) => crate::settings::atomic_write(&config_path, &original), + None => std::fs::remove_file(&config_path).map_err(Into::into), + }, + Ok(_) => Err(anyhow::anyhow!("Codex 配置已被其他窗口更新,未覆盖新配置")), + Err(error) => Err(error.into()), + }; + if let Err(error) = restore { + rollback_errors.push(format!("恢复 Codex 配置失败:{error:#}")); + } + } for (created, identity) in created_links.iter().rev() .map(|(path, identity)| (path, identity)) .chain(published.iter().map(|identity| (&destination, identity))) @@ -1706,7 +1815,7 @@ impl SkillsManager { install_only: bool, ) -> anyhow::Result { validate_skill_id(id)?; - anyhow::ensure!(!self.shared_skills_dir.as_ref() + anyhow::ensure!(!self.shared_skills_dir() .is_some_and(|root| root.join(id).join(SKILL_MANIFEST_FILE).is_file()), "共享 Skill 的原目录不由此安装流程更新"); let coordination = skills_coordination(); @@ -1957,7 +2066,7 @@ impl SkillsManager { ) -> anyhow::Result<()> { let source = self.source_dir.join(id); let link = self.linked_dir().join(id); - let shared = self.shared_skills_dir.as_ref().map(|root| root.join(id)) + let shared = self.shared_skills_dir().map(|root| root.join(id)) .filter(|path| path.join(SKILL_MANIFEST_FILE).is_file()); if let Some(shared) = shared { let mut paths = vec![shared.clone()]; @@ -1974,7 +2083,7 @@ impl SkillsManager { let (name, _) = read_skill_manifest(&manifest, id); (manifest, name) }).collect::>(); - return self.set_native_skill_paths(&manifests, enabled); + return self.set_native_skill_paths(&manifests, enabled).map(|_| ()); } let existing_manifest = link.join(SKILL_MANIFEST_FILE); if existing_manifest.is_file() { @@ -1983,7 +2092,7 @@ impl SkillsManager { (metadata.file_type().is_dir() && !metadata_is_link(&metadata)) || (metadata_is_link(&metadata) && !is_managed_link(&link, &source))) { - return self.set_native_skill_paths(&[(existing_manifest, name)], enabled); + return self.set_native_skill_paths(&[(existing_manifest, name)], enabled).map(|_| ()); } } if !enabled { @@ -2028,7 +2137,7 @@ impl SkillsManager { } if link.join(SKILL_MANIFEST_FILE).is_file() { let (name, _) = read_skill_manifest(&existing_manifest, id); - return self.set_native_skill_paths(&[(existing_manifest, name)], true); + return self.set_native_skill_paths(&[(existing_manifest, name)], true).map(|_| ()); } if std::fs::symlink_metadata(&link).is_ok() { anyhow::bail!("启用位置已存在其他内容,未覆盖:{}", link.display()); @@ -2054,7 +2163,7 @@ impl SkillsManager { /// 卸载:删软链,源目录整体移进备份目录。不做自动轮转删除。 pub fn uninstall(&self, id: &str) -> anyhow::Result { validate_skill_id(id)?; - anyhow::ensure!(!self.shared_skills_dir.as_ref() + anyhow::ensure!(!self.shared_skills_dir() .is_some_and(|root| root.join(id).join(SKILL_MANIFEST_FILE).is_file()), "共享 Skill 不由 Codex++ 卸载,可通过 Codex 开关停用"); let mut coordination = skills_coordination(); @@ -4035,9 +4144,56 @@ mod tests { assert!(!manager.linked_dir().join("alpha").exists()); assert!(!manager.source_dir.join("alpha").exists()); } + let config = std::fs::read_to_string(manager.codex_home.join("config.toml")).unwrap(); + assert!(!config.lines().any(|line| line.trim() == "[skills]")); assert!(temp.path().join("claude-skills/alpha/SKILL.md").is_file()); } + #[test] + fn shared_agent_enable_uses_current_source_and_preserves_unowned_links() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "shared one"); + write_skill(&manager.source_dir.join("alpha"), "old managed snapshot"); + + manager.set_enabled_for("claude", "alpha", true).unwrap(); + let claude = temp.path().join("claude-skills/alpha"); + assert_eq!(std::fs::read_to_string(claude.join("SKILL.md")).unwrap(), "shared one"); + std::fs::write(shared.join("alpha/SKILL.md"), "shared two").unwrap(); + if std::fs::symlink_metadata(&claude).is_ok_and(|metadata| metadata_is_link(&metadata)) { + assert_eq!(std::fs::read_to_string(claude.join("SKILL.md")).unwrap(), "shared two"); + } + manager.set_enabled_for("claude", "alpha", false).unwrap(); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + manager.set_enabled_for("grok", "alpha", true).unwrap(); + assert_eq!(std::fs::read_to_string(claude.join("SKILL.md")).unwrap(), "shared two"); + assert_eq!(std::fs::read_to_string(temp.path().join("grok-skills/alpha/SKILL.md")).unwrap(), "shared two"); + assert_eq!(std::fs::read_to_string(manager.source_dir.join("alpha/SKILL.md")).unwrap(), "old managed snapshot"); + assert_eq!(std::fs::read_to_string(shared.join("alpha/SKILL.md")).unwrap(), "shared two"); + } + + #[test] + fn shared_agent_unowned_link_is_not_removed() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "shared"); + let link = temp.path().join("claude-skills").join("alpha"); + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(shared.join("alpha"), &link).unwrap(); + #[cfg(windows)] + { + let result = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) + .arg(&link).arg(shared.join("alpha")).output().unwrap(); + assert!(result.status.success(), "mklink failed: {}", String::from_utf8_lossy(&result.stderr)); + } + assert!(manager.set_enabled_for("claude", "alpha", false).is_err()); + assert!(link.join("SKILL.md").is_file()); + assert!(shared.join("alpha/SKILL.md").is_file()); + } + #[test] fn shared_and_codex_conflict_compares_complete_contents_and_preserves_both() { let temp = tempfile::tempdir().unwrap(); @@ -4077,6 +4233,111 @@ mod tests { assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); } + #[test] + fn native_relative_and_combined_rules_do_not_hide_inventory() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + write_skill(&manager.linked_dir().join("alpha"), "---\nname: alpha\n---\n"); + write_skill(&manager.linked_dir().join("beta"), "---\nname: beta\n---\n"); + let config = manager.codex_home.join("config.toml"); + std::fs::write(&config, concat!( + "[[skills.config]]\npath = 'skills/alpha/SKILL.md'\nenabled = false\n", + "[[skills.config]]\nname = 'alpha'\npath = 'skills/beta/SKILL.md'\nenabled = true\n", + )).unwrap(); + let entries = manager.try_local_inventory().unwrap(); + assert!(!entry(&entries, "alpha").enabled); + assert!(entry(&entries, "beta").enabled); + + std::fs::write(&config, concat!( + "[[skills.config]]\nname = 'alpha'\npath = 'skills/alpha/SKILL.md'\nenabled = false\n", + "[[skills.config]]\npath = 'skills/beta/SKILL.md'\nenabled = 'invalid'\n", + )).unwrap(); + let entries = manager.try_local_inventory().unwrap(); + assert!(!entry(&entries, "alpha").enabled); + assert!(entry(&entries, "alpha").locations[0].status_error.is_none()); + assert!(entry(&entries, "beta").locations[0].status_error.is_some()); + } + + #[test] + fn shared_root_aliasing_codex_root_remains_managed() { + let temp = tempfile::tempdir().unwrap(); + let base = manager(&temp); + let linked = base.linked_dir(); + std::fs::create_dir_all(&linked).unwrap(); + let shared = temp.path().join("shared"); + #[cfg(unix)] + std::os::unix::fs::symlink(&linked, &shared).unwrap(); + #[cfg(windows)] + { + let result = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) + .arg(&shared).arg(&linked).output().unwrap(); + assert!(result.status.success(), "mklink failed: {}", String::from_utf8_lossy(&result.stderr)); + } + let manager = base.with_shared_skills_dir(&shared); + assert!(manager.shared_skills_dir().is_none()); + let skill = sample_skill("alpha", "alpha", "first"); + let first = repo_zip(&[("kit-main/alpha/SKILL.md", "---\nname: alpha\n---\nfirst")]); + manager.install_from_zip(&skill, &first).unwrap(); + let updated = repo_zip(&[("kit-main/alpha/SKILL.md", "---\nname: alpha\n---\nupdated")]); + manager.install_from_zip(&skill, &updated).unwrap(); + assert!(std::fs::read_to_string(linked.join("alpha/SKILL.md")).unwrap().ends_with("updated")); + manager.uninstall("alpha").unwrap(); + assert!(!manager.source_dir.join("alpha").exists()); + assert!(!linked.join("alpha").exists()); + } + + #[test] + fn reinstall_reenables_previously_disabled_copy() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let zip = repo_zip(&[("kit-main/alpha/SKILL.md", "---\nname: alpha\n---\n")]); + let skill = sample_skill("alpha", "alpha", "hash"); + manager.install_from_zip(&skill, &zip).unwrap(); + let linked = manager.linked_dir().join("alpha"); + manager.backup_directory("alpha", &linked).unwrap(); + copy_dir_all(&manager.source_dir.join("alpha"), &linked).unwrap(); + std::fs::write(linked.join(MANAGED_COPY_MARKER), "").unwrap(); + manager.set_enabled("alpha", false).unwrap(); + assert!(!entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + manager.uninstall("alpha").unwrap(); + manager.install_from_zip(&skill, &zip).unwrap(); + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); + } + + #[test] + fn failed_install_restores_native_disabled_rule() { + let temp = tempfile::tempdir().unwrap(); + let manager = SkillsManager::new( + temp.path().join("skills"), + temp.path().join("backups"), + temp.path().join("blocked/state.json"), + temp.path().join("codex-home"), + ); + std::fs::create_dir_all(&manager.source_dir).unwrap(); + std::fs::create_dir_all(&manager.codex_home).unwrap(); + let original = "[[skills.config]]\npath = 'skills/alpha/SKILL.md'\nenabled = false\n"; + let config = manager.codex_home.join("config.toml"); + std::fs::write(&config, original).unwrap(); + let staging = manager.source_dir.join(".staging"); + write_skill(&staging, "---\nname: alpha\n---\n"); + let blocked = temp.path().join("blocked"); + SOURCE_PUBLICATION_HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(move |_| { + std::fs::write(&blocked, "block state save")?; + Ok(()) + }))); + let installed = InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: String::new(), repo_path: String::new(), content_hash: String::new(), + installed_at: String::new(), + }; + let mut coordination = skills_coordination(); + assert!(manager.commit_staged_skill_unlocked( + &staging, installed, true, false, None, true, &mut coordination, + ).is_err()); + assert_eq!(std::fs::read_to_string(config).unwrap(), original); + assert!(!manager.linked_dir().join("alpha").exists()); + } + #[test] fn enabling_existing_agent_does_not_adopt_or_copy_skill() { let temp = tempfile::tempdir().unwrap(); From bd24a4952c1397ba3a8918e6140ff795c9f26d00 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 00:21:57 +0800 Subject: [PATCH 3/5] fix(skills): track shared copy origins --- crates/codex-plus-core/src/skills.rs | 63 ++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 2a81747b8b..41853c5837 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -107,7 +107,7 @@ pub struct SkillsState { #[serde(default)] pub installed: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub shared_agent_links: BTreeMap, + pub shared_agent_paths: BTreeMap, } impl Default for SkillsState { @@ -115,7 +115,7 @@ impl Default for SkillsState { Self { repos: default_skill_repos(), installed: BTreeMap::new(), - shared_agent_links: BTreeMap::new(), + shared_agent_paths: BTreeMap::new(), } } } @@ -871,17 +871,17 @@ impl SkillsManager { return match metadata { Ok(metadata) if metadata_is_link(&metadata) && shared.as_ref().is_some_and(|source| is_managed_link(&link, source) && skill_path_identity(&link).ok().is_some_and(|identity| - initial_state.shared_agent_links.get(&link.to_string_lossy().into_owned()).copied() + initial_state.shared_agent_paths.get(&link.to_string_lossy().into_owned()).copied() == Some([identity.device, identity.file]))) => { let mut state = initial_state.clone(); let staging = dir.join(format!(".codex-plus-link-{}", uuid::Uuid::new_v4())); move_path_no_replace(&link, &staging)?; - let expected = state.shared_agent_links[&link.to_string_lossy().into_owned()]; + let expected = state.shared_agent_paths[&link.to_string_lossy().into_owned()]; if skill_path_identity(&staging).map(|identity| [identity.device, identity.file])? != expected { move_path_no_replace(&staging, &link)?; anyhow::bail!("Skill 链接在停用期间发生变化,未删除"); } - state.shared_agent_links.remove(&link.to_string_lossy().into_owned()); + state.shared_agent_paths.remove(&link.to_string_lossy().into_owned()); if let Err(error) = self.save_state_unlocked(&state) { return match move_path_no_replace(&staging, &link) { Ok(()) => Err(error), @@ -893,7 +893,19 @@ impl SkillsManager { Ok(metadata) if metadata_is_link(&metadata) && is_managed_link(&link, &self.source_dir.join(id)) => remove_link(&link), Ok(_) if is_managed_copy(&link) => { - self.backup_directory(id, &link)?; + let identity = skill_path_identity(&link)?; + let backup = self.backup_directory_checked(id, &link, identity)?; + let key = link.to_string_lossy().into_owned(); + if initial_state.shared_agent_paths.get(&key) == Some(&[identity.device, identity.file]) { + let mut state = initial_state.clone(); + state.shared_agent_paths.remove(&key); + if let Err(error) = self.save_state_unlocked(&state) { + return match restore_skill_backup(&backup, &link, identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!("恢复 Skill 副本失败:{rollback:#}"))), + }; + } + } Ok(()) } Ok(_) => anyhow::bail!("{} 是这个 Agent 自己的 Skill,不由 Codex++ 管理,未删除", link.display()), @@ -913,14 +925,14 @@ impl SkillsManager { } std::fs::create_dir_all(&dir).with_context(|| format!("创建 {} 失败", dir.display()))?; let identity = link_or_copy(&source, &link)?; - if shared.is_some() && std::fs::symlink_metadata(&link).is_ok_and(|metadata| metadata_is_link(&metadata)) { + if shared.is_some() { let mut state = initial_state.clone(); - state.shared_agent_links.insert(link.to_string_lossy().into_owned(), [identity.device, identity.file]); + state.shared_agent_paths.insert(link.to_string_lossy().into_owned(), [identity.device, identity.file]); if let Err(error) = self.save_state_unlocked(&state) { return match recover_published_path(&link, identity) { - Ok(Some(path)) => Err(error.context(format!("新建的 Skill 链接保留在 {}", path.display()))), + Ok(Some(path)) => Err(error.context(format!("新建的 Skill 内容保留在 {}", path.display()))), Ok(None) => Err(error), - Err(rollback) => Err(error.context(format!("回滚 Skill 链接失败:{rollback:#}"))), + Err(rollback) => Err(error.context(format!("回滚 Skill 内容失败:{rollback:#}"))), }; } } @@ -1397,7 +1409,7 @@ impl SkillsManager { let manifest = path.join(SKILL_MANIFEST_FILE); if manifest.is_file() { if !agents.contains(agent) { agents.push(agent.clone()); } - let managed_shared_link = state.shared_agent_links + let managed_shared_link = state.shared_agent_paths .get(&path.to_string_lossy().into_owned()).is_some_and(|recorded| skill_path_identity(&path).ok().is_some_and(|identity| recorded == &[identity.device, identity.file]) @@ -3612,6 +3624,7 @@ const MANAGED_COPY_MARKER: &str = ".codex-plus-managed"; #[cfg(test)] thread_local! { static DIGEST_HOOK: std::cell::RefCell>> = std::cell::RefCell::new(None); + static FORCE_SKILL_COPY: std::cell::Cell = const { std::cell::Cell::new(false) }; } fn skill_directory_digest(path: &Path) -> anyhow::Result> { @@ -3855,7 +3868,11 @@ fn link_or_copy_at( } let mut created_staging = false; let result = (|| -> anyhow::Result { - if symlink_dir(source, staging).is_ok() { + #[cfg(test)] + let force_copy = FORCE_SKILL_COPY.with(|flag| flag.get()); + #[cfg(not(test))] + let force_copy = false; + if !force_copy && symlink_dir(source, staging).is_ok() { created_staging = true; } else { std::fs::create_dir(&staging) @@ -4194,6 +4211,28 @@ mod tests { assert!(shared.join("alpha/SKILL.md").is_file()); } + #[test] + fn shared_agent_copy_records_origin_and_reloads_on_reenable() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "first"); + let copy = temp.path().join("claude-skills").join("alpha"); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("claude", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + result.unwrap(); + assert!(is_managed_copy(©)); + let identity = skill_path_identity(©).unwrap(); + assert_eq!(manager.load_state().shared_agent_paths.get(©.to_string_lossy().into_owned()), + Some(&[identity.device, identity.file])); + std::fs::write(shared.join("alpha/SKILL.md"), "updated").unwrap(); + manager.set_enabled_for("claude", "alpha", false).unwrap(); + assert!(!manager.load_state().shared_agent_paths.contains_key(©.to_string_lossy().into_owned())); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + assert_eq!(std::fs::read_to_string(copy.join("SKILL.md")).unwrap(), "updated"); + } + #[test] fn shared_and_codex_conflict_compares_complete_contents_and_preserves_both() { let temp = tempfile::tempdir().unwrap(); From 89c8954d6bccfe4c962c989a8f9329e50778b675 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 03:42:10 +0800 Subject: [PATCH 4/5] fix(skills): preserve installs with unknown native rules --- CHANGELOG.md | 1 - crates/codex-plus-core/src/skills.rs | 52 ++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d03820fc..a82ecee169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,6 @@ - **「移除 Responses 图片 detail」开关打开后真正生效**:请求改走本地代理,和界面说明一致。 - **用量统计区分不同供应商的同名模型**:结构化用量索引保留完整的 `cpx-…/模型` 名,超长模型名不再沿用上一轮的模型。 - **最近会话路径不再显示 Windows 的 `\\?\` 前缀**(#3030)。 -- **共享 Skill 的发现与启停**:识别 Codex 共享目录及原生配置,重新安装 Skill 时恢复启用;拓展只读数据 `installedSkills.enabled` 在状态无法确定时为 `null`,并提供 `statusError`。 ### 新增 diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 41853c5837..a464744fbd 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -719,11 +719,21 @@ impl SkillsManager { &self, manifests: &[(PathBuf, String)], enabled: bool, + skip_unknown: bool, ) -> anyhow::Result>, Vec)>> { let config = self.read_native_skill_config()?; let mut changes = Vec::new(); for (path, name) in manifests { - let name = config.name_for_matching(path, name)?; + let name = match config.name_for_matching(path, name) { + Ok(name) => name, + Err(_) if skip_unknown => continue, + Err(error) => return Err(error), + }; + if skip_unknown && config.matching_rule(path, &name) + .is_some_and(|(_, rule)| rule.enabled.is_none()) + { + continue; + } if config.enabled(path, &name)? != enabled { changes.push((path, name)); } @@ -1717,7 +1727,7 @@ impl SkillsManager { if new_install { let manifest = link.join(SKILL_MANIFEST_FILE); let (name, _) = read_skill_manifest(&manifest, &id); - native_change = self.set_native_skill_paths(&[(manifest, name)], true)?; + native_change = self.set_native_skill_paths(&[(manifest, name)], true, true)?; } } for (copy, _) in &agent_copies { @@ -2095,7 +2105,7 @@ impl SkillsManager { let (name, _) = read_skill_manifest(&manifest, id); (manifest, name) }).collect::>(); - return self.set_native_skill_paths(&manifests, enabled).map(|_| ()); + return self.set_native_skill_paths(&manifests, enabled, false).map(|_| ()); } let existing_manifest = link.join(SKILL_MANIFEST_FILE); if existing_manifest.is_file() { @@ -2104,7 +2114,7 @@ impl SkillsManager { (metadata.file_type().is_dir() && !metadata_is_link(&metadata)) || (metadata_is_link(&metadata) && !is_managed_link(&link, &source))) { - return self.set_native_skill_paths(&[(existing_manifest, name)], enabled).map(|_| ()); + return self.set_native_skill_paths(&[(existing_manifest, name)], enabled, false).map(|_| ()); } } if !enabled { @@ -2149,7 +2159,7 @@ impl SkillsManager { } if link.join(SKILL_MANIFEST_FILE).is_file() { let (name, _) = read_skill_manifest(&existing_manifest, id); - return self.set_native_skill_paths(&[(existing_manifest, name)], true).map(|_| ()); + return self.set_native_skill_paths(&[(existing_manifest, name)], true, false).map(|_| ()); } if std::fs::symlink_metadata(&link).is_ok() { anyhow::bail!("启用位置已存在其他内容,未覆盖:{}", link.display()); @@ -2162,7 +2172,7 @@ impl SkillsManager { let identity = link_or_copy(&source, &link)?; let manifest = link.join(SKILL_MANIFEST_FILE); let (name, _) = read_skill_manifest(&manifest, id); - if let Err(error) = self.set_native_skill_paths(&[(manifest, name)], true) { + if let Err(error) = self.set_native_skill_paths(&[(manifest, name)], true, false) { return match recover_published_path(&link, identity) { Ok(Some(path)) => Err(error.context(format!("新建的 Skill 内容保留在 {}", path.display()))), Ok(None) => Err(error), @@ -4343,6 +4353,36 @@ mod tests { assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").enabled); } + #[test] + fn install_preserves_unrecognized_native_rule_and_reports_unknown() { + for (original, manifest) in [ + ("[[skills.config]]\nname = 'alpha'\nenabled = 'yes'\n", "---\nname: alpha\n---\n"), + ("[[skills.config]]\nname = 'alpha'\nenabled = false\n", "---\nname: &alias alpha\n---\n"), + ] { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + std::fs::create_dir_all(&manager.codex_home).unwrap(); + let config = manager.codex_home.join("config.toml"); + std::fs::write(&config, original).unwrap(); + let zip = repo_zip(&[("kit-main/alpha/SKILL.md", manifest)]); + let skill = sample_skill("alpha", "alpha", "hash"); + + manager.install_from_zip(&skill, &zip).unwrap(); + + assert_eq!(std::fs::read_to_string(config).unwrap(), original); + assert!(manager.source_dir.join("alpha/SKILL.md").is_file()); + assert!(manager.linked_dir().join("alpha/SKILL.md").is_file()); + assert!(manager.load_state().installed.contains_key("alpha")); + assert!(entry(&manager.try_local_inventory().unwrap(), "alpha").locations[0].status_error.is_some()); + for root in [&manager.source_dir, &manager.linked_dir()] { + assert!(!std::fs::read_dir(root).unwrap().any(|entry| { + entry.unwrap().file_name().to_string_lossy().starts_with(".codex-plus-recovery-") + })); + } + assert!(manager.set_enabled("alpha", true).is_err()); + } + } + #[test] fn failed_install_restores_native_disabled_rule() { let temp = tempfile::tempdir().unwrap(); From 039553d3ab724c4c8b01359aaf69eee92c0fff99 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 04:42:59 +0800 Subject: [PATCH 5/5] fix(skills): report manifest read failures --- crates/codex-plus-core/src/skills.rs | 63 +++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index a464744fbd..83cc5d5629 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -595,9 +595,6 @@ fn native_skill_paths_match(left: &Path, right: &Path) -> bool { impl NativeSkillConfig { fn name_for_matching(&self, manifest: &Path, fallback_name: &str) -> anyhow::Result { - if !manifest.is_file() { - return Ok(fallback_name.to_string()); - } let last_path = self.rules.iter().rposition(|rule| rule.name.is_none() && rule.path.as_deref() .is_some_and(|path| native_skill_paths_match(path, manifest))); if self.rules.iter().enumerate().any(|(index, rule)| @@ -726,7 +723,7 @@ impl SkillsManager { for (path, name) in manifests { let name = match config.name_for_matching(path, name) { Ok(name) => name, - Err(_) if skip_unknown => continue, + Err(error) if skip_unknown && error.downcast_ref::().is_some() => continue, Err(error) => return Err(error), }; if skip_unknown && config.matching_rule(path, &name) @@ -2860,9 +2857,18 @@ fn parse_single_line_yaml_scalar(value: &str) -> Option { Some(value.to_string()) } +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct NativeSkillNameUnknown(anyhow::Error); + fn read_native_skill_name(manifest: &Path) -> anyhow::Result { - let text = read_bounded_skill_manifest(std::fs::File::open(manifest)?) - .context("无法读取完整 Skill frontmatter,Codex 启用状态未知")?; + let text = read_bounded_skill_manifest(std::fs::File::open(manifest)?)? + .context("无法读取完整 Skill frontmatter,Codex 启用状态未知") + .map_err(NativeSkillNameUnknown)?; + parse_native_skill_name(&text).map_err(|error| NativeSkillNameUnknown(error).into()) +} + +fn parse_native_skill_name(text: &str) -> anyhow::Result { let mut lines = text.lines(); anyhow::ensure!(lines.next().is_some_and(|line| line.trim() == "---"), "Skill 缺少有效 frontmatter,Codex 启用状态未知"); @@ -2905,20 +2911,19 @@ fn read_skill_manifest(manifest: &Path, fallback_id: &str) -> (String, String) { if !file.metadata().ok()?.is_file() { return None; } - read_bounded_skill_manifest(file) + read_bounded_skill_manifest(file).ok()? })(); text.map(|text| parse_skill_frontmatter(&text, fallback_id)) .unwrap_or_else(|| (fallback_id.to_string(), String::new())) } -fn read_bounded_skill_manifest(reader: impl Read) -> Option { +fn read_bounded_skill_manifest(reader: impl Read) -> std::io::Result> { let mut bytes = Vec::new(); reader .take((SKILL_FRONTMATTER_LIMIT_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .ok()?; + .read_to_end(&mut bytes)?; if bytes.len() <= SKILL_FRONTMATTER_LIMIT_BYTES { - return String::from_utf8(bytes).ok(); + return Ok(String::from_utf8(bytes).ok()); } let mut reader = BufReader::new(bytes.as_slice()); @@ -2926,17 +2931,22 @@ fn read_bounded_skill_manifest(reader: impl Read) -> Option { let mut first = true; loop { let start = text.len(); - if reader.read_line(&mut text).ok()? == 0 || text.len() > SKILL_FRONTMATTER_LIMIT_BYTES { - return None; + let count = match reader.read_line(&mut text) { + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => return Ok(None), + Err(error) => return Err(error), + }; + if count == 0 || text.len() > SKILL_FRONTMATTER_LIMIT_BYTES { + return Ok(None); } let delimiter = text[start..].trim() == "---"; if first { if !delimiter { - return None; + return Ok(None); } first = false; } else if delimiter { - return Some(text); + return Ok(Some(text)); } } } @@ -4383,6 +4393,28 @@ mod tests { } } + #[test] + fn install_rule_reset_reports_manifest_read_errors() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + std::fs::create_dir_all(&manager.codex_home).unwrap(); + std::fs::write(manager.codex_home.join("config.toml"), + "[[skills.config]]\nname = 'alpha'\nenabled = false\n").unwrap(); + let error = manager.set_native_skill_paths( + &[(temp.path().join("missing/SKILL.md"), "alpha".into())], true, true, + ).unwrap_err(); + assert!(error.downcast_ref::().is_some()); + + struct FailingReader; + impl Read for FailingReader { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::ErrorKind::PermissionDenied.into()) + } + } + assert_eq!(read_bounded_skill_manifest(FailingReader).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied); + } + #[test] fn failed_install_restores_native_disabled_rule() { let temp = tempfile::tempdir().unwrap(); @@ -7433,6 +7465,7 @@ mod tests { bytes: &bytes, count: &mut count, }) + .unwrap() .unwrap(); assert_eq!(count, SKILL_FRONTMATTER_LIMIT_BYTES + 1);