diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 8d8a231d3a..63b8abd3ec 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -7481,6 +7481,131 @@ pub async fn list_installed_skills() -> Result, Str }).await.map_err(|error| format!("读取本地 Skills 失败:{error}")) } +async fn resolve_skill_repo_for_operation( + manager: &codex_plus_core::skills::SkillsManager, + repo_key: &str, +) -> anyhow::Result> { + let (guard, snapshot) = { + let _coordination = skills_repo_coordination(); + (manager.capture_repo_guard(repo_key)?, repo_skill_cache_snapshot(repo_key)) + }; + let (resolved, skills) = + codex_plus_core::skills::resolve_repo_skills(guard.repo.clone(), &snapshot.skills).await?; + anyhow::ensure!(resolved.key() == repo_key, "仓库分支已变化,请先重新检查仓库"); + let _coordination = skills_repo_coordination(); + anyhow::ensure!(manager.repo_guard_is_current(&guard)?, "仓库在检查期间已改变,请重试"); + store_repo_skills_if_current(repo_key, &skills, &snapshot); + Ok(skills) +} + +#[tauri::command] +pub async fn find_skill_repository_matches( + repo_key: String, + id: Option, + local_path: Option, +) -> CommandResult { + let manager = default_skills_manager(); + let result = async { + let skills = resolve_skill_repo_for_operation(&manager, &repo_key).await?; + let repo = manager.list_repos().into_iter().find(|repo| repo.key() == repo_key) + .ok_or_else(|| anyhow::anyhow!("仓库已不存在"))?; + tauri::async_runtime::spawn_blocking(move || { + match (id.as_deref(), local_path.as_deref()) { + (Some(id), Some(path)) => manager.preview_repository_matches_for_instance( + &repo, &skills, id, std::path::Path::new(path), + ), + (None, Some(_)) => anyhow::bail!("选择安装位置时必须指定 Skill"), + (_, None) => manager.preview_repository_matches(&repo, &skills, id.as_deref()), + } + }).await? + }.await; + match result { + Ok(preview) => ok("来源候选已检查,请确认后关联。", preview), + Err(error) => failed( + &format!("匹配 Skill 来源失败:{error:#}"), + codex_plus_core::skills::SkillRepositoryMatchPreview::default(), + ), + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRepositoryApplyPayload { + #[serde(flatten)] + skills: SkillsPayload, + applied: Vec, + errors: Vec, +} + +#[tauri::command] +pub async fn apply_skill_repository_matches( + selections: Vec, +) -> Result, String> { + let manager = default_skills_manager(); + let keys: std::collections::BTreeSet<_> = selections.iter() + .map(|selection| selection.repo_key.clone()).collect(); + let mut remote = Vec::new(); + let mut errors = Vec::new(); + let mut available = std::collections::BTreeSet::new(); + for key in keys { + match resolve_skill_repo_for_operation(&manager, &key).await { + Ok(skills) => { + available.insert(key); + remote.extend(skills); + } + Err(error) => errors.push(format!("{key}:{error:#}")), + } + } + let valid: Vec<_> = selections.into_iter() + .filter(|selection| available.contains(&selection.repo_key)).collect(); + tauri::async_runtime::spawn_blocking(move || { + let applied = match manager.apply_repository_matches(&valid, &remote) { + Ok(outcome) => { + errors.extend(outcome.errors); + outcome.applied + } + Err(error) => { + errors.push(format!("{error:#}")); + Vec::new() + } + }; + let payload = SkillRepositoryApplyPayload { + skills: current_skills_payload(&manager), + applied, + errors, + }; + if payload.errors.is_empty() { + ok("已关联所选来源,更新方式未改变。", payload) + } else { + failed(&format!("部分来源未关联:{}", payload.errors.join(";")), payload) + } + }).await.map_err(|error| format!("关联 Skill 来源失败:{error}")) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillSourceEvidencePayload { + evidence: Vec, +} + +#[tauri::command] +pub async fn discover_skill_source_evidence(id: String, local_path: Option) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_manager(); + let evidence = match local_path.as_deref() { + Some(path) => manager.discover_skill_source_evidence_for_instance(id.trim(), std::path::Path::new(path)), + None => manager.discover_skill_source_evidence(id.trim()), + }; + match evidence { + Ok(evidence) => ok("已检查本地来源线索。", SkillSourceEvidencePayload { evidence }), + Err(error) => failed( + &format!("检查来源线索失败:{error:#}"), + SkillSourceEvidencePayload { evidence: Vec::new() }, + ), + } + }).await.map_err(|error| format!("检查来源线索失败:{error}")) +} + /// 导入 cc-switch 已安装的 Skills。cc-switch 的 Skill 文件位于 /// `%USERPROFILE%/.cc-switch/skills//SKILL.md`,不在其 SQLite 数据库中。 #[tauri::command] diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index 4df3f5af10..711e3a86bd 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -588,6 +588,9 @@ pub fn run() { commands::delete_user_script, commands::refresh_skill_catalog, commands::list_installed_skills, + commands::find_skill_repository_matches, + commands::apply_skill_repository_matches, + commands::discover_skill_source_evidence, commands::import_ccs_skills, commands::import_local_skills, commands::install_skill, diff --git a/apps/codex-plus-manager/src/ManagementExtra.tsx b/apps/codex-plus-manager/src/ManagementExtra.tsx index 811b1321d0..601ac674b1 100644 --- a/apps/codex-plus-manager/src/ManagementExtra.tsx +++ b/apps/codex-plus-manager/src/ManagementExtra.tsx @@ -17,21 +17,28 @@ import { useAgentFocus } from "./agent-focus-store"; 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 { aggregateSkillGroupItems, customSkillGroupKey, groupSkills, legacySkillCustomGroupName, migrateSkillGroupAssignments, migrateSkillInstanceAssignments, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillGroupIdentityKey, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName, type SkillGroup, type SkillGroupSortDirection } from "./skill-groups"; import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchState, skillBatchTargets, skillCodexStatusError, SkillBatchRegistry, type PendingSkillBatchOperation } from "./skill-agent-batch"; +import { SkillSourceMatchDrawer, type SourceSelection } from "./SkillSourceMatchDrawer"; 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; +type SkillLocation = { manifestPath: string; rootKind: string; managed: boolean; enabled: boolean; statusError?: string | null; + originalPath?: string | null; associationPath?: string | null; selectable?: boolean; repoKey?: string; repoPath?: string; updateOwner?: string }; +type SkillMatchContext = { agentLabel?: string; currentRepoKey?: string; currentRepoPath?: string }; +type Skill = { id: string; name: string; description: string; repoKey: string; repoPath?: string; displayGroupRepoKey?: string | null; installed: boolean; enabled: boolean; bundled: boolean; updateAvailable: boolean; /** 已经启用到哪些 Agent(codex / claude / grok)。 */ agents?: string[]; /** Codex++ 的受控源里有它;只在某个 Agent 自己目录里的 Skill 为 false。 */ - managed?: boolean; locations?: SkillLocation[]; conflict?: string | null }; + managed?: boolean; locations?: SkillLocation[]; conflict?: string | null; instanceKey?: string; + conflictKind?: "independent" | "equal-independent" | "managed-modified" | null; safeToggleAgents?: string[]; + originalDir?: string | null; managedSourceDir?: string | null; updateOwner?: string; installChannel?: string; + displayInstances?: Skill[] }; type Repo = { owner: string; name: string; branch: string; subdir: string; enabled: boolean }; 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 }; +type SourceApplyResult = SkillPayload & { applied: Array<{ localId: string; fromRepoKey: string; toRepoKey: string; localPath?: string | null }> }; const SKILL_GROUP_STORAGE_KEY = "codex-plus-plus.skill-groups.v1"; const skillBatchRegistry = new SkillBatchRegistry(); const skillPageCache: { @@ -195,16 +202,39 @@ 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 }) { +function SkillDetailDrawer({ skill, count, busy, onClose, onOpenUrl, onOpenRepo, onMatch }: { + skill: Skill; count?: number; busy: boolean; onClose: () => void; onOpenUrl: (url: string) => void; + onOpenRepo: () => void; onMatch: (path?: string, context?: SkillMatchContext) => void; +}) { const url = repoUrl(skill); const labels: Record = { shared: t("共享"), codex: "Codex", claude: "Claude Code", grok: "Grok", system: "Codex" }; + const targets = new Set(skill.locations?.flatMap((location) => + location.selectable && location.originalPath ? [location.originalPath] + : location.associationPath ? [location.associationPath] : []) || []); + const matchAtRepoPath = skill.installed && !skill.bundled && !skill.conflict && !skill.displayInstances + && (targets.size === 1 || (targets.size === 0 && !!skill.managedSourceDir)); + const matchLabel = t(url ? "修改 GitHub 仓库关联" : "关联 GitHub 仓库"); 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}}
, - ])} +
{t("来源")}
{skill.displayInstances ? t("按安装位置查看") : url ? : sourceLabel(skill)}
+
{t("Skill ID")}
{skill.id}
{t("仓库路径")}
{skill.repoPath || "—"}{matchAtRepoPath && }
+
{t("介绍")}
{skill.description || t("暂无描述")}
{url && <>
GitHub
} + {skill.locations?.flatMap((location) => { + const instance = skill.displayInstances?.find((item) => item.locations?.some((itemLocation) => itemLocation.manifestPath === location.manifestPath)); + const locationRepo = location.repoKey || instance?.repoKey || (!skill.displayInstances ? skill.repoKey : ""); + const locationRepoPath = location.repoPath || instance?.repoPath || (!skill.displayInstances ? skill.repoPath : "") || ""; + const associationPath = location.selectable && location.originalPath ? location.originalPath : location.associationPath; + return [ +
{labels[location.rootKind] || location.rootKind}
, +
{location.manifestPath}
{location.managed ? t("Codex++ 受控") : t("本地安装")} · {location.statusError ? t("状态未知") : location.enabled ? t("已启用") : t("未启用")} + {location.statusError && <>
{location.statusError}} + {(instance || associationPath) && <>
{t("来源")}:{repoFromKey(locationRepo) ? `${locationRepo}${locationRepoPath ? ` / ${locationRepoPath}` : ""}` : t("本地")}} + {associationPath && !matchAtRepoPath &&
}
, + ]; + })}
; } @@ -216,14 +246,15 @@ function SortableSkillGroup({ group, disabled, children }: { group: SkillGroup{children(handle)}; } /** 分组标题上的批量开关:和行里的 Agent 胶囊同一列,一组全开是实心、部分开是半实心;点一下全开或全关。 */ -function GroupAgentSelect({ group, agent, busy, operations, count, onChange }: { group: SkillGroup; agent: McpAgentId; busy: boolean; operations: PendingSkillBatchOperation[]; count?: number; onChange: (enabled: boolean) => void }) { +function GroupAgentSelect({ group, agent, busy, operations, count, prepareSkill, onChange }: { group: SkillGroup; agent: McpAgentId; busy: boolean; operations: PendingSkillBatchOperation[]; count?: number; prepareSkill: (skill: Skill, agent: McpAgentId) => Skill; onChange: (enabled: boolean) => void }) { const pendingById = new Map(); for (const operation of operations) { if (operation.agent !== agent) continue; const settled = new Set(operation.settledSkillIds); for (const id of operation.skillIds) if (!settled.has(id)) pendingById.set(id, operation.enabled); } - const state = skillBatchState(group.items.map((skill) => { + const state = skillBatchState(group.items.map((item) => { + const skill = prepareSkill(item, agent); const pending = pendingById.get(skill.id); if (pending === undefined) return skill; const agents = skillAgents(skill).filter((value) => value !== agent); @@ -257,6 +288,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { const localReload = useRef<{ pending: Promise | null; dirty: boolean }>({ pending: null, dirty: false }); const [repoOpen, setRepoOpen] = useState(false); const [detail, setDetail] = useState(null); + const [matchTarget, setMatchTarget] = useState<({ id?: string; localPath?: string } & SkillMatchContext) | null>(null); const [repoSelection, setRepoSelection] = useState(null); const [loading, setLoading] = useState(false); const [refreshingCatalog, setRefreshingCatalog] = useState(() => !!skillPageCache.catalogPending); @@ -456,7 +488,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { const runBatch = async (group: SkillGroup, agent: McpAgentId, enabled: boolean) => { const groupItems = allGroups.find((item) => item.key === group.key)?.items || group.items; if (skillBatchHasPending(skillBatchRegistry.getSnapshot(), groupItems, agent)) return; - const targets = skillBatchTargets(group.items, agent, enabled); + const targets = skillBatchTargets(group.items.map((skill) => batchSkill(skill, agent)), agent, enabled); if (!targets.length) { await notify("Skills", enabled ? t("该组没有可启用的 Skill") : t("该组没有可停用的 Skill"), "ok"); return; @@ -556,26 +588,105 @@ export function SkillsPage({ notify }: { notify: Notify }) { if (ok(result)) await reloadLocal(); return result; }; + const addMatchingRepo = async (repo: Repo) => { + try { + const result = await inspectRepo(repo); + if (!ok(result)) { await notify("Skills", result.message, "failed"); return null; } + return await saveRepo(result.repo) ? result.repo : null; + } catch (error) { + await notify("Skills", String(error), "failed"); + return null; + } + }; + const applySourceMatches = async (selections: SourceSelection[]): Promise => { + setBusy(true); + try { + const result = await skillBatchRegistry.run(async () => { + try { + const request = ++dataRequest.current; + const response = await invoke("apply_skill_repository_matches", { selections }); + if (response.skills) applyCurrentPayload(request, response); + else void reloadLocal(); + return response; + } finally { skillBatchRegistry.notifySettled(batchOwner.current); } + }); + if (result.applied?.length) setAssignmentsByIdentity((old) => { + const changes = result.applied.map((item) => ({ + ...item, localPath: item.localPath || selections.find((selection) => selection.localId === item.localId)?.localPath, + })); + const next = migrateSkillGroupAssignments(old, changes.filter((item) => !item.localPath).map((item) => ({ + id: item.localId, fromRepoKey: item.fromRepoKey, toRepoKey: item.toRepoKey, + }))); + for (const change of changes) { + if (!change.localPath) continue; + const includesPath = (skill: Skill) => skill.id === change.localId + && skill.locations?.some((location) => location.originalPath === change.localPath || location.associationPath === change.localPath); + const previous = data?.skills.find(includesPath); + const current = result.skills?.find(includesPath); + if (!previous || !current) continue; + const from = skillGroupIdentityKey(previous); + const to = skillGroupIdentityKey(current); + const value = Object.prototype.hasOwnProperty.call(next, from) ? next[from] + : skillGroupAssignment(previous, customGroupNames, customGroupAssignments, next); + if (from !== to && (Object.prototype.hasOwnProperty.call(next, from) || value) + && !Object.prototype.hasOwnProperty.call(next, to)) next[to] = value; + } + return next; + }); + await notify("Skills", result.message, ok(result) ? "ok" : "failed"); + return result; + } catch (error) { + const message = String(error); + await notify("Skills", message, "failed"); + void reloadLocal(); + return { status: "failed", message }; + } finally { setBusy(false); } + }; const openRepo = (key: string) => { setRepoSelection(key); setDetail(null); setRepoOpen(true); }; const deferredQuery = useDeferredValue(query); - const items = useMemo(() => (data?.skills || []) - .filter((s) => (tab === "installed" ? s.installed : !s.installed || s.bundled)) - .filter((s) => matchesManagementQuery([s.id, s.name, s.description, s.repoKey, sourceLabel(s)], deferredQuery)), [data, tab, deferredQuery]); const knownSkills = useMemo(() => data?.skills || [], [data]); + const guardedIds = useMemo(() => { + const counts = new Map(); + const result = new Set(); + for (const skill of knownSkills) { + if (skill.conflict) result.add(skill.id); + if (skill.installed && !skill.bundled) counts.set(skill.id, (counts.get(skill.id) || 0) + 1); + } + for (const [id, count] of counts) if (count > 1) result.add(id); + return result; + }, [knownSkills]); + const canToggleCodex = (skill: Skill) => Boolean( + (skill.locations?.some((location) => location.rootKind === "shared") + && knownSkills.filter((item) => item.id === skill.id && item.installed + && item.locations?.some((location) => location.rootKind === "codex" || location.rootKind === "shared")).length === 1) + || (skill.displayInstances || [skill]).some((instance) => instance.safeToggleAgents?.includes("codex"))); + const batchSkill = (skill: Skill, agent: McpAgentId): Skill => !guardedIds.has(skill.id) ? skill : { + ...skill, conflict: agent === "codex" && canToggleCodex(skill) ? null : skill.conflict || t("请按安装位置管理"), + }; + useEffect(() => { + setAssignmentsByIdentity((old) => migrateSkillInstanceAssignments(old, customGroupAssignments, knownSkills, customGroupNames)); + }, [knownSkills, customGroupAssignments, customGroupNames]); + const items = useMemo(() => knownSkills.filter((s) => tab === "installed" ? s.installed : !s.installed || s.bundled), [knownSkills, tab]); const knownRepositories = useMemo(() => data?.repos || [], [data]); const builtinGroupLabel = t("内置"); const userGroupLabel = t("用户"); const localGroupLabel = t("本地导入"); const allGroups = useMemo(() => { const result = groupSkills(knownSkills, knownSkills, customGroupNames, customGroupAssignments, groupOrder, - { builtin: builtinGroupLabel, user: userGroupLabel, source: sourceLabel }, true, groupAliases, assignmentsByIdentity, knownRepositories); + { builtin: builtinGroupLabel, user: userGroupLabel, source: sourceLabel }, true, groupAliases, assignmentsByIdentity, knownRepositories) + .map((group) => ({ ...group, items: aggregateSkillGroupItems(group.items) })); return groupSortDirection ? sortSkillGroupsByName(result, groupSortDirection) : result; }, [knownSkills, knownRepositories, customGroupNames, customGroupAssignments, assignmentsByIdentity, groupOrder, groupSortDirection, groupAliases, builtinGroupLabel, userGroupLabel, localGroupLabel]); const groups = useMemo(() => { const result = groupSkills(items, knownSkills, customGroupNames, customGroupAssignments, groupOrder, - { builtin: builtinGroupLabel, user: userGroupLabel, source: sourceLabel }, !query.trim(), groupAliases, assignmentsByIdentity, knownRepositories); + { builtin: builtinGroupLabel, user: userGroupLabel, source: sourceLabel }, !query.trim(), groupAliases, assignmentsByIdentity, knownRepositories) + .map((group) => ({ ...group, items: aggregateSkillGroupItems(group.items).filter((skill) => + (skill.displayInstances || [skill]).some((item) => matchesManagementQuery( + [item.id, item.name, item.description, item.repoKey, sourceLabel(item), ...(item.locations || []).map((location) => location.manifestPath)], deferredQuery))) })) + .filter((group) => (tab !== "installed" || group.kind !== "repo" || group.items.length > 0) + && (!query.trim() || group.items.length)); return groupSortDirection ? sortSkillGroupsByName(result, groupSortDirection) : result; - }, [items, knownSkills, knownRepositories, customGroupNames, customGroupAssignments, assignmentsByIdentity, groupOrder, groupSortDirection, groupAliases, builtinGroupLabel, userGroupLabel, localGroupLabel, query]); + }, [items, knownSkills, knownRepositories, customGroupNames, customGroupAssignments, assignmentsByIdentity, groupOrder, groupSortDirection, groupAliases, builtinGroupLabel, userGroupLabel, localGroupLabel, query, deferredQuery, tab]); const openUrl = async (url: string) => { try { await invoke("open_external_url", { url }); } catch (e) { await notify("Skills", String(e), "failed"); } @@ -613,7 +724,11 @@ export function SkillsPage({ notify }: { notify: Notify }) { setExpandedGroups((old) => { const next = new Set(old); next.delete(customSkillGroupKey(name)); return next; }); }; const assignSkillGroup = (skill: Skill, group: string) => { - setAssignmentsByIdentity((old) => ({ ...old, [skillIdentityKey(skill)]: group ? customSkillGroupKey(group) : "" })); + setAssignmentsByIdentity((old) => { + const next = { ...old }; + for (const item of skill.displayInstances || [skill]) next[skillGroupIdentityKey(item)] = group ? customSkillGroupKey(group) : ""; + return next; + }); }; const handleGroupDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id || query.trim() || busy || loading) return; @@ -627,8 +742,11 @@ export function SkillsPage({ notify }: { notify: Notify }) { // 标题右侧同一列位置是这一组的批量开关。详情、分组、卸载收进行末的三点菜单。 const renderSkillRow = (s: Skill) => { const update = s.bundled ? undefined : updates.find((item) => item.id === s.id); + const guarded = guardedIds.has(s.id); + const managerUpdate = !s.originalDir && !(s.updateOwner === "" && s.installChannel) + || s.updateOwner === "manager"; // 只卸载 Codex++ 管理的或 Codex 目录里的;Claude / Grok 自己目录里的 Skill 不动。 - 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 canUninstall = s.installed && !s.bundled && !guarded && !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)); @@ -637,17 +755,17 @@ export function SkillsPage({ notify }: { notify: Notify }) { title={s.name || s.id} badges={<> {sourceLabel(s)} - {s.conflict ? {t("同名冲突")} : null} + {s.conflict && !s.displayInstances ? {t(s.conflictKind === "equal-independent" ? "多个独立安装" : "同名冲突")} : 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 && !s.conflict && !s.locations?.some((location) => location.rootKind === "shared") ? : null} + {update ? : !s.installed && !s.bundled ? : s.installed && s.updateAvailable && !guarded && managerUpdate && !s.locations?.some((location) => location.rootKind === "shared") ? : null}
{agentColumns.map((agent) => setDetail(s)}>{t("查看详情")} + {s.installed && !s.bundled && !s.displayInstances ? setMatchTarget({ + id: s.id, localPath: s.conflictKind === "independent" ? s.originalDir || undefined : undefined, + currentRepoKey: s.repoKey, currentRepoPath: s.repoPath, + })}>{t("重新关联来源")} : null} {repoUrl(s) ? openRepo(s.repoKey)}>{t("管理仓库")} : null} {!s.bundled && customGroupNames.length ? <>
{t("分组")}
@@ -666,8 +788,10 @@ export function SkillsPage({ notify }: { notify: Notify }) { ; }; const discoverCount = (data?.skills || []).filter((s) => !s.installed || s.bundled).length; - const installedCount = (data?.skills || []).filter((s) => s.installed).length; + const installedCount = aggregateSkillGroupItems((data?.skills || []).filter((s) => s.installed)).length; const sortLabel = `${t("按名称排序")} (${groupSortDirection === "asc" ? "A-Z" : groupSortDirection === "desc" ? "Z-A" : "A-Z / Z-A"})`; + const currentDetail = detail ? allGroups.flatMap((group) => group.items).find((skill) => skillIdentityKey(skill) === skillIdentityKey(detail)) + || (guardedIds.has(detail.id) ? null : detail) : null; return
} title="Skills" actions={<> @@ -682,6 +806,8 @@ export function SkillsPage({ notify }: { notify: Notify }) { +
{repoOpen && setRepoOpen(false)} onSave={saveRepo} onDelete={(r) => void deleteRepo(r)} onInspect={inspectRepo} onOpenUrl={(url) => void openUrl(url)} onOpenSkill={setDetail} onRun={(command, args) => { queueUpdate(command, args); }} />} - {detail && setDetail(null)} onOpenUrl={(url) => void openUrl(url)} onOpenRepo={() => repoOpen ? setDetail(null) : openRepo(detail.repoKey)} />} + {currentDetail && setDetail(null)} onOpenUrl={(url) => void openUrl(url)} onOpenRepo={() => repoOpen ? setDetail(null) : openRepo(currentDetail.repoKey)} + onMatch={(localPath, context) => { setDetail(null); setMatchTarget({ id: currentDetail.id, localPath, ...context }); }}/>} + {matchTarget && skill.installed && !skill.bundled + && (!skill.repoPath || !skill.repoKey.includes("/"))).map((skill) => skill.id)} + onClose={() => setMatchTarget(null)} onAddRepo={addMatchingRepo} onApply={applySourceMatches}/>}
; } export function SessionPage({ sessions, onRefresh, onDelete, onDeleteMany, notify, repairPanel }: { sessions: { sessions: ManagedSession[]; totalCount: number; hasMore: boolean; offset: number; limit: number } | null; onRefresh: (offset?: number) => Promise; onDelete: (s: ManagedSession) => Promise; onDeleteMany: (s: ManagedSession[]) => Promise; notify: Notify; repairPanel?: ReactNode }) { const [query, setQuery] = useState(""); const [mode, setMode] = useState<"project" | "time">("project"); const [expanded, setExpanded] = useState>(new Set()); const [selection, setSelection] = useState(false); const [selected, setSelected] = useState>(new Set()); const [detail, setDetail] = useState(null); const all = sessions?.sessions || []; const visible = all.filter((s) => matchesManagementQuery([s.title, s.cwd, s.id, s.modelProvider], query)); const groups = sessionGroups(visible, mode); const selectionScope = visible.map((s) => s.id).join("\0"); useEffect(() => { const ids = new Set(selectionScope.split("\0")); setSelected((old) => { const next = new Set([...old].filter((id) => ids.has(id))); return next.size === old.size ? old : next; }); }, [selectionScope]); const selectedRows = visible.filter((s) => selected.has(s.id)); const toggle = (id: string) => setSelected((old) => { const next = new Set(old); next.has(id) ? next.delete(id) : next.add(id); return next; }); return
} title={t("会话")} actions={<>} />{repairPanel}
{selection && <>}
{!visible.length ?

{t("还没有匹配的会话")}

: groups.map((g) => { const open = query.trim() || expanded.has(g.key) || groups.length === 1; return
{open && g.sessions.map((s) =>
{selection && toggle(s.id)}/>}
{s.modelProvider || "Codex"}
)}
; })}
{sessions ? tf("第 {0} 页 · 共 {1} 个会话", [Math.floor(sessions.offset / sessions.limit) + 1, sessions.totalCount]) : "—"}
{detail && setDetail(null)} footer={<>}>
{t("项目目录")}
{detail.cwd || "—"}
{t("模型供应商")}
{detail.modelProvider || "Codex"}
{t("数据库")}
{detail.dbPath || "—"}
}
; } diff --git a/apps/codex-plus-manager/src/SkillSourceMatchDrawer.css b/apps/codex-plus-manager/src/SkillSourceMatchDrawer.css new file mode 100644 index 0000000000..7c788f65a9 --- /dev/null +++ b/apps/codex-plus-manager/src/SkillSourceMatchDrawer.css @@ -0,0 +1,10 @@ +.mg-source-add { display:flex; gap:8px; align-items:center; } +.mg-source-add input { flex:1; min-width:0; } +.mg-source-search { width:100%; min-width:0; } +.mg-source-warnings { font-size:12px; overflow-wrap:anywhere; } +.mg-source-list { display:flex; flex-direction:column; border-top:1px solid hsl(var(--border)); } +.mg-source-row { display:grid; grid-template-columns:minmax(125px, .7fr) minmax(0, 1fr); gap:12px; padding:12px 0; border-bottom:1px solid hsl(var(--border)); } +.mg-source-row label { display:flex; align-items:flex-start; gap:8px; min-width:0; } +.mg-source-row label span { display:flex; flex-direction:column; min-width:0; overflow-wrap:anywhere; } +.mg-source-row small { display:block; margin-top:4px; color:hsl(var(--muted-foreground)); overflow-wrap:anywhere; } +@media (max-width:480px) { .mg-source-add { flex-wrap:wrap; } .mg-source-add input { flex-basis:100%; } .mg-source-row { grid-template-columns:minmax(0, 1fr); } } diff --git a/apps/codex-plus-manager/src/SkillSourceMatchDrawer.tsx b/apps/codex-plus-manager/src/SkillSourceMatchDrawer.tsx new file mode 100644 index 0000000000..3ea2718823 --- /dev/null +++ b/apps/codex-plus-manager/src/SkillSourceMatchDrawer.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Pause, Play, Plus, RefreshCw, Search } from "lucide-react"; +import { AppSelect } from "./AppSelect"; +import { ManagementDrawer } from "./ManagementPages"; +import { t, tf } from "./i18n"; +import { parseSkillRepository, type SkillRepository } from "./skill-repository"; +import { discardSkillSourceScan, getSkillSourceScan, skillSourceRepoKey as keyOfRepo, type SkillSourceMatch as Match } from "./skill-source-scan"; +import "./SkillSourceMatchDrawer.css"; + +export type SourceSelection = Pick & { manual?: boolean; localPath?: string }; +type ApplyResult = { status: string; message: string; applied?: { localId: string }[] }; +const keyOfCandidate = (candidate: Match) => JSON.stringify([candidate.repoKey, candidate.repoPath]); +const evidenceLabel = (kind: string) => { + const labels: Record = { + "manifest-link": t("说明中的链接"), + "script-link": t("脚本中的链接"), + package: t("包元数据"), + receipt: t("安装收据"), + lock: t("锁文件"), + "git-remote": "Git remote", + }; + return labels[kind] || kind; +}; + +export function SkillSourceMatchDrawer({ repos, id, localPath, agentLabel, currentRepoKey, currentRepoPath, localSkillIds, onClose, onAddRepo, onApply }: { + repos: SkillRepository[]; + id?: string; + localPath?: string; + agentLabel?: string; + currentRepoKey?: string; + currentRepoPath?: string; + localSkillIds: string[]; + onClose: () => void; + onAddRepo: (repo: SkillRepository) => Promise; + onApply: (selections: SourceSelection[]) => Promise; +}) { + const scan = useMemo(() => getSkillSourceScan((command, args) => invoke(command, args), id, localPath), [id, localPath]); + const [snapshot, setSnapshot] = useState(scan.getSnapshot); + const [action, setAction] = useState<"add" | "apply" | null>(null); + const [selected, setSelected] = useState>({}); + const [input, setInput] = useState(""); + const [query, setQuery] = useState(""); + const [feedback, setFeedback] = useState(""); + const pending = useRef(false); + const evidenceIds = (id ? [id] : [...new Set(localSkillIds)].sort()).join("\0"); + const repoKeys = repos.map(keyOfRepo).join("\0"); + const { status, candidates, warnings, progress, currentRepo, evidence, evidenceErrors, evidenceScanning } = snapshot; + const knownRepos = snapshot.repos; + const scanning = status === "scanning" || status === "idle"; + const busy = action !== null; + + useEffect(() => { + const unsubscribe = scan.subscribe(() => setSnapshot(scan.getSnapshot())); + setSnapshot(scan.getSnapshot()); + scan.ensure(repos, evidenceIds ? evidenceIds.split("\0") : []); + return unsubscribe; + }, [scan, repoKeys, evidenceIds]); + + const groups = useMemo(() => { + const result = new Map(); + for (const candidate of candidates) { + const group = result.get(candidate.localId) || []; + if (!group.some((item) => keyOfCandidate(item) === keyOfCandidate(candidate))) group.push(candidate); + result.set(candidate.localId, group); + } + for (const group of result.values()) group.sort((a, b) => Number(b.exact) - Number(a.exact) + || Number(a.manual) - Number(b.manual) || keyOfCandidate(a).localeCompare(keyOfCandidate(b))); + return result; + }, [candidates]); + const choices = Object.entries(selected).map(([localId, key]) => groups.get(localId)?.find((item) => keyOfCandidate(item) === key)); + const complete = choices.length > 0 && choices.every((item) => item && !item.conflict) && new Set(choices.map((item) => keyOfCandidate(item!))).size === choices.length; + + const add = async () => { + const parsed = parseSkillRepository(input); + if (!parsed) { + setFeedback(t("无法识别仓库地址,请输入 GitHub 链接或 owner/repo。")); + return; + } + if (knownRepos.some((repo) => keyOfRepo(repo).toLowerCase() === keyOfRepo(parsed).toLowerCase())) { + setFeedback(t("该仓库已添加。")); + return; + } + if (pending.current) return; + pending.current = true; setAction("add"); setFeedback(""); + try { + const added = await onAddRepo(parsed); + if (added) { scan.addRepo(added); setInput(""); } + else setFeedback(t("仓库未添加,请检查地址和网络。")); + } catch (error) { setFeedback(String(error)); } + finally { pending.current = false; setAction(null); } + }; + const apply = async () => { + if (!complete || scanning || pending.current) return; + pending.current = true; setAction("apply"); setFeedback(""); + try { + const selections = choices.map((choice) => { + const { previewId, localId, repoKey, repoPath, manual } = choice!; + return { previewId, localId, repoKey, repoPath, ...(manual ? { manual: true } : {}), ...(localPath ? { localPath } : {}) }; + }); + const result = await onApply(selections); + if (result.status === "ok") { + discardSkillSourceScan(scan, id, localPath); + onClose(); + } else { + if (result.applied?.length) { + const applied = result.applied.map((item) => item.localId); + scan.consume(applied); + setSelected((old) => Object.fromEntries(Object.entries(old).filter(([localId]) => !applied.includes(localId)))); + } + setFeedback(result.message); + } + } catch (error) { setFeedback(String(error)); } + finally { pending.current = false; setAction(null); } + }; + + return {} : onClose} + footer={<> + {!scanning && } + {status === "paused" && } + + {scanning ? + : } + }> + {localPath &&
{t("所选安装位置")}
{agentLabel ? `${agentLabel} · ${localPath}` : localPath}
} +
+ { setInput(event.currentTarget.value); setFeedback(""); }}/> + +
+ {feedback &&

{feedback}

} + {evidenceErrors.length > 0 &&

{evidenceErrors.join(";")}

} + {evidenceScanning &&

{t(status === "paused" ? "正在完成当前线索" : "正在检查本地来源线索")}

} + {evidence.length > 0 &&
{t("本地发现的仓库候选")}{[...evidence].sort((a, b) => + Number(["manifest-link", "script-link"].includes(a.kind)) - Number(["manifest-link", "script-link"].includes(b.kind)) + || a.localId.localeCompare(b.localId) || a.repo.localeCompare(b.repo)).map((item, index) =>
+
{item.repo}{id ? evidenceLabel(item.kind) : `${item.localId} · ${evidenceLabel(item.kind)}`}
+
{item.path}
+
)}
} +

{scanning + ? `${tf("正在检查仓库 {0}/{1}", [progress, knownRepos.length])}${currentRepo ? `:${currentRepo}` : ""}` + : status === "paused" + ? `${tf("已暂停,已检查 {0}/{1} 个仓库", [progress, knownRepos.length])}${currentRepo ? ` · ${t("正在完成当前仓库")}:${currentRepo}` : ""}` + : tf("已检查 {0} 个仓库,{1} 个 Skill 有候选来源", [progress, groups.size])}

+ {id && } + {!!warnings.length &&
{tf("{0} 项检查提示", [warnings.length])}{warnings.map((warning, index) =>

{warning}

)}
} + {!scanning && !groups.size &&

{status === "paused" && progress < knownRepos.length + ? t("已检查的仓库中没有候选来源,可继续扫描。") : t("没有找到候选来源,可添加 GitHub 仓库后重试。")}

} + {groups.size > 0 &&
{[...groups].map(([localId, options]) => { + const filtered = options.filter((item) => `${item.repoKey} ${item.repoPath} ${item.remoteName}`.toLowerCase().includes(query.trim().toLowerCase())); + const choice = options.find((item) => keyOfCandidate(item) === selected[localId]); + const visible = choice && !filtered.includes(choice) ? [choice, ...filtered] : filtered; + return
+ +
({ value: keyOfCandidate(item), label: `${item.repoKey} / ${item.repoPath}`, disabled: !!item.conflict, hint: item.conflict || (item.exact ? t("内容一致") : item.manual ? t("手动选择,内容不同") : t("名称相似,内容不同")) }))} + onChange={(value) => setSelected((old) => ({ ...old, [localId]: value }))}/> + {choice && {choice.exact ? t("内容一致") : choice.manual ? t("手动选择,内容不同") : t("名称相似,内容不同")}} + {(localPath ? [localPath] : options[0].localPaths)?.map((path) => {path})} + {options.some((item) => item.conflict) && {options.find((item) => item.conflict)?.conflict}} +
+
; + })}
} + {choices.length > 0 && !complete &&

{t(choices.some((item) => !item || item.conflict) + ? "请选择有效的仓库路径。" : "同一个仓库路径不能关联不同 Skill。")}

} +
; +} diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index dda83f2082..e6917e0553 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -5,6 +5,45 @@ // Plain strings: t("中文") -> EN_PLAIN["中文"]. export const EN_PLAIN: Record = { + "说明中的链接": "Link in documentation", + "脚本中的链接": "Link in a script", + "包元数据": "Package metadata", + "安装收据": "Installation receipt", + "锁文件": "Lock file", + "无法识别仓库地址,请输入 GitHub 链接或 owner/repo。": "Enter a GitHub URL or owner/repo.", + "该仓库已添加。": "This repository has already been added.", + "仓库未添加,请检查地址和网络。": "Repository not added. Check the address and network connection.", + "重新关联仓库来源": "Change repository source", + "匹配仓库来源": "Match repository sources", + "继续扫描": "Resume scan", + "暂停扫描": "Pause scan", + "正在关联来源…": "Associating sources…", + "所选安装位置": "Selected installation", + "GitHub 仓库地址": "GitHub repository URL", + "正在检查仓库…": "Checking repository…", + "检查并添加": "Check and add", + "本地发现的仓库候选": "Repository candidates found locally", + "填入仓库地址": "Use repository address", + "正在完成当前线索": "Finishing the current source clue", + "正在检查本地来源线索": "Checking local source clues", + "正在完成当前仓库": "Finishing the current repository", + "搜索候选来源": "Search source candidates", + "已检查的仓库中没有候选来源,可继续扫描。": "No candidates in the repositories checked so far. Resume scanning to continue.", + "没有找到候选来源,可添加 GitHub 仓库后重试。": "No candidates found. Add a GitHub repository and try again.", + "选择仓库来源": "Select repository source", + "内容一致": "Content matches", + "手动选择,内容不同": "Manually selected; content differs", + "名称相似,内容不同": "Similar name; content differs", + "请选择有效的仓库路径。": "Select a valid repository path.", + "同一个仓库路径不能关联不同 Skill。": "A repository path cannot be associated with different Skills.", + "按安装位置查看": "See each installation", + "修改 GitHub 仓库关联": "Change GitHub repository association", + "关联 GitHub 仓库": "Associate a GitHub repository", + "关联此安装位置": "Associate this installation", + "请按安装位置管理": "Manage each installation separately", + "重新关联来源": "Change source association", + "匹配仓库": "Match repositories", + "多个独立安装": "Multiple independent installations", "Codex 登录": "Codex sign-in", "额度待刷新": "Usage awaiting refresh", "待刷新": "Awaiting refresh", @@ -3528,6 +3567,11 @@ export const EN_PLAIN: Record = { // Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled. export const EN_TEMPLATE: Record = { + "关联所选 {0} 项": "Associate {0} selected", + "正在检查仓库 {0}/{1}": "Checking repositories {0}/{1}", + "已暂停,已检查 {0}/{1} 个仓库": "Paused after checking {0}/{1} repositories", + "已检查 {0} 个仓库,{1} 个 Skill 有候选来源": "Checked {0} repositories; {1} Skills have source candidates", + "{0} 项检查提示": "{0} scan notices", "客户端能力与始终允许的应用 · {0} 个": "Client capabilities and always-allowed apps · {0}", "已设 {0} 个": "{0} set", "查看全部 {0} 条": "Show all {0}", diff --git a/apps/codex-plus-manager/src/skill-agent-batch.test.ts b/apps/codex-plus-manager/src/skill-agent-batch.test.ts index a2ab6c2c1e..4b75df0062 100644 --- a/apps/codex-plus-manager/src/skill-agent-batch.test.ts +++ b/apps/codex-plus-manager/src/skill-agent-batch.test.ts @@ -279,12 +279,32 @@ test("batch state and targets only include installed mutable Skills for the requ assert.equal(skillBatchState([{ installed: true, bundled: false, enabled: true }], "claude").checked, false); }); +test("an unknown Codex location is excluded from Codex batches but not other Agents", () => { + const unknown = { id: "unknown", installed: true, bundled: false, enabled: false, agents: [], + locations: [{ statusError: "无法确定名称" }] }; + const known = { id: "known", installed: true, bundled: false, enabled: true, agents: ["codex"] }; + assert.deepEqual(skillBatchState([unknown, known], "codex"), { total: 1, checked: true, mixed: false }); + assert.deepEqual(skillBatchTargets([unknown, known], "codex", true), []); + assert.deepEqual(skillBatchTargets([unknown, known], "codex", false).map((item) => item.id), ["known"]); + assert.deepEqual(skillBatchTargets([unknown, known], "claude", true).map((item) => item.id), ["unknown", "known"]); +}); + +test("conflicting same-name entries are excluded from batch actions and merges", () => { + const first = { id: "shared", installed: true, bundled: false, enabled: true, agents: ["codex"], conflict: "不同内容" }; + const second = { id: "shared", installed: true, bundled: false, enabled: false, agents: [], conflict: "不同内容" }; + assert.deepEqual(skillBatchState([first, second], "codex"), { total: 0, checked: false, mixed: false }); + assert.deepEqual(skillBatchTargets([first, second], "codex", true), []); + const current = [first, second]; + assert.equal(mergeSkillAgentResult(current, [{ ...first, agents: [] }], "shared", "codex"), current); + assert.equal(mergeSkillAgentResult([{ ...first, conflict: null as string | null }], current, "shared", "codex")[0].agents[0], "codex"); +}); + test("pending operations remain visible and only lock matching Skills and Agents", () => { const registry = new SkillBatchRegistry(); const first = { groupKey: "one", agent: "codex", enabled: true, skillIds: ["a"], settledSkillIds: [] as string[], owner: Symbol("first") }; const second = { groupKey: "one", agent: "claude", enabled: true, skillIds: ["a"], settledSkillIds: [] as string[], owner: Symbol("second") }; const settled: symbol[] = []; - const unsubscribe = registry.subscribeSettled((owner) => settled.push(owner)); + const unsubscribe = registry.subscribeSettled((owner) => { settled.push(owner); }); registry.add(first); registry.add(second); assert.equal(skillBatchHasPending(registry.getSnapshot(), [{ id: "a" }], "codex"), true); diff --git a/apps/codex-plus-manager/src/skill-agent-batch.ts b/apps/codex-plus-manager/src/skill-agent-batch.ts index 4af1dce23c..c08a4fbd2e 100644 --- a/apps/codex-plus-manager/src/skill-agent-batch.ts +++ b/apps/codex-plus-manager/src/skill-agent-batch.ts @@ -117,14 +117,13 @@ export function skillCodexStatusError(skill: SkillAgentState): string | undefine } export function skillBatchTargets(skills: T[], agent: string, enabled: boolean): T[] { - return skills.filter((skill) => skill.installed && !skill.bundled - && (agent !== "codex" || (!skill.conflict && !skillCodexStatusError(skill))) - && skillAgents(skill).includes(agent) !== enabled); + return skills.filter((skill) => skill.installed && !skill.bundled && !skill.conflict + && (agent !== "codex" || !skillCodexStatusError(skill)) && skillAgents(skill).includes(agent) !== enabled); } export function skillBatchState(skills: SkillAgentState[], agent: string) { - const eligible = skills.filter((skill) => skill.installed && !skill.bundled - && (agent !== "codex" || (!skill.conflict && !skillCodexStatusError(skill)))); + const eligible = skills.filter((skill) => skill.installed && !skill.bundled && !skill.conflict + && (agent !== "codex" || !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 }; } @@ -136,8 +135,10 @@ export function skillBatchHasPending(operations: SkillBatchOperation[], skills: // 只合并目标 Agent 的结果,避免覆盖其他操作产生的状态。 export function mergeSkillAgentResult(current: T[], result: T[], id: string, agent: string): T[] { - const updated = result.find((skill) => skill.id === id && skill.installed && !skill.bundled); - if (!updated) return current; + const matches = result.filter((skill) => skill.id === id && skill.installed && !skill.bundled); + const existing = current.filter((skill) => skill.id === id && skill.installed && !skill.bundled); + if (matches.length !== 1 || existing.length !== 1 || matches[0].conflict || existing[0].conflict) return current; + const updated = matches[0]; const enabled = skillAgents(updated).includes(agent); return current.map((skill) => { if (skill.id !== id || !skill.installed || skill.bundled) return skill; 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 a3fba226f3..e76ce76d40 100644 --- a/apps/codex-plus-manager/src/skill-group-ui.test.ts +++ b/apps/codex-plus-manager/src/skill-group-ui.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; 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 { aggregateSkillGroupItems, customSkillGroupKey, groupSkills, legacySkillCustomGroupName, migrateSkillGroupAssignments, migrateSkillInstanceAssignments, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillGroupIdentityKey, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName } from "./skill-groups.ts"; import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchTargets, skillCodexStatusError, SkillBatchRegistry } from "./skill-agent-batch.ts"; type Element = { type: any; props: Record; children: any[] }; @@ -75,7 +75,7 @@ function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: }, ok: (result: { status: string }) => result.status === "ok", SKILL_GROUP_STORAGE_KEY: "codex-plus-plus.skill-groups.v1", 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, + aggregateSkillGroupItems, groupSkills, legacySkillCustomGroupName, migrateSkillGroupAssignments, migrateSkillInstanceAssignments, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillGroupIdentityKey, 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 === "cc-switch" || !item.repoKey ? "本地" : item.repoKey === "local" ? "本地导入" : item.repoKey.split("@", 1)[0], repoUrl: () => "", MCP_AGENTS: ["codex", "claude", "grok"], @@ -85,7 +85,7 @@ function createHarness(saved = "{}", shared?: { server: FixtureServer; registry: 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", - "SkillDetailDrawer", "SkillRepoDrawer", "BookOpen", "RefreshCw", "FolderOpen", "FolderPlus", "Link", "ArrowDownAZ", "ArrowDownZA", "ArrowDownUp", "ChevronDown", "ChevronsUp", "ChevronsDown", "CloudDownload", "GripVertical", "X"]) context[name] = name; + "SkillDetailDrawer", "SkillRepoDrawer", "SkillSourceMatchDrawer", "BookOpen", "RefreshCw", "FolderOpen", "FolderPlus", "Link", "Search", "ArrowDownAZ", "ArrowDownZA", "ArrowDownUp", "ChevronDown", "ChevronsUp", "ChevronsDown", "CloudDownload", "GripVertical", "X"]) context[name] = name; vm.createContext(context); vm.runInContext(code, context); const collect = (node: any, result: Element[]) => { @@ -523,7 +523,7 @@ test("shared IDs support independent choices while retaining legacy assignments assert.deepEqual(ui.savedState().assignments, { shared: "Manual" }); assert.deepEqual(ui.savedState().assignmentsByIdentity, { [skillIdentityKey(skill("shared", "a/repo@main"))]: "" }); assert.equal(ui.group("repo:a/repo").props.group.items.length, 1); - assert.equal(ui.group("repo:z/repo").props.group.items.length, 0); + assert.ok(!ui.order().includes("repo:z/repo")); assert.equal(ui.group("custom:Manual").props.group.items.length, 1); assert.equal(ui.group("builtin").props.group.items.length, 1); assert.equal(ui.rowElements("repo:a/repo", "a/repo@main", "shared").find((node) => node.type === "MenuItem" && node.children.includes("按来源"))?.props.checked, true); @@ -535,7 +535,7 @@ test("shared IDs support independent choices while retaining legacy assignments assert.equal(ui.group("custom:Manual").props.group.items.length, 1); assert.equal(ui.group("custom:Other").props.group.items.length, 1); assert.equal(ui.group("builtin").props.group.items.length, 1); - assert.equal(ui.group("repo:a/repo").props.group.items.length, 0); + assert.ok(!ui.order().includes("repo:a/repo")); const restored = createHarness(ui.storage.get("codex-plus-plus.skill-groups.v1")); restored.server.skills = [...ui.server.skills].reverse(); @@ -572,12 +572,12 @@ test("shared IDs support independent choices while retaining legacy assignments assert.equal(afterDelete.group("repo:z/repo").props.group.items.length, 1); }); -test("known repository groups stay empty in installed view after individual moves and sorting", async () => { +test("repository groups hide after their items move, while their sort order is retained", async () => { const ui = createHarness(JSON.stringify({ names: ["Manual"], assignments: {} })); ui.render(); await tick(); ui.render(); ui.selectTab("已安装"); const move = ui.rowElements("repo:a/repo", "a/repo@main", "alpha").find((node) => node.type === "MenuItem" && node.children.includes("Manual")); assert.ok(move); move.props.onClick(); ui.render(); - assert.equal(ui.group("repo:a/repo").props.group.items.length, 0); + assert.ok(!ui.order().includes("repo:a/repo")); assert.equal(ui.group("custom:Manual").props.group.items.length, 1); ui.sort().props.onClick(); ui.render(); assert.ok(ui.savedState().order.includes("repo:a/repo")); @@ -586,12 +586,14 @@ test("known repository groups stay empty in installed view after individual move ui.search(""); const restored = createHarness(ui.storage.get("codex-plus-plus.skill-groups.v1")); restored.render(); await tick(); restored.render(); restored.selectTab("已安装"); - assert.equal(restored.group("repo:a/repo").props.group.items.length, 0); + assert.ok(!restored.order().includes("repo:a/repo")); assert.equal(restored.group("custom:Manual").props.group.items.length, 1); - assert.ok(restored.order().includes("repo:a/repo")); + assert.ok(restored.savedState().order.includes("repo:a/repo")); + restored.selectTab("发现"); + assert.equal(restored.group("repo:a/repo").props.group.items.length, 0); }); -test("configured empty repositories appear in installed view and retain sort and drag order", async () => { +test("configured empty repositories hide in installed view but remain sortable in discovery", async () => { const ui = createHarness(JSON.stringify({ names: ["Manual"], aliases: { "repo:a/empty": "First" } })); ui.server.skills = [skill("local-import", "local"), skill("cc-import", "cc-switch")]; ui.server.repos = [ @@ -603,10 +605,9 @@ test("configured empty repositories appear in installed view and retain sort and // a/empty has no cache entry; z/empty has a cached empty scan. ui.server.repoCounts = { "z/empty@main": 0 }; ui.render(); await tick(); ui.render(); ui.selectTab("已安装"); - assert.equal(ui.order().filter((key: string) => key === "repo:a/empty").length, 1); - assert.equal(ui.group("repo:a/empty").props.group.label, "First"); - assert.equal(ui.group("repo:a/empty").props.group.items.length, 0); - assert.equal(ui.group("repo:z/empty").props.group.items.length, 0); + assert.ok(!ui.order().includes("repo:a/empty")); + assert.ok(!ui.order().includes("repo:z/empty")); + assert.equal(ui.group("custom:Manual").props.group.items.length, 0); assert.deepEqual(ui.group("user").props.group.items.map((item: FixtureSkill) => item.repoKey), ["local", "cc-switch"]); assert.ok(!ui.order().includes("repo:local") && !ui.order().includes("repo:cc-switch")); @@ -615,6 +616,11 @@ test("configured empty repositories appear in installed view and retain sort and ui.sort().props.onClick(); ui.render(); assert.deepEqual(ui.savedState().order, ["repo:a/empty", "custom:Manual", "repo:z/empty"]); ui.search(""); + ui.selectTab("发现"); + assert.equal(ui.order().filter((key: string) => key === "repo:a/empty").length, 1); + assert.equal(ui.group("repo:a/empty").props.group.label, "First"); + assert.equal(ui.group("repo:a/empty").props.group.items.length, 0); + assert.equal(ui.group("repo:z/empty").props.group.items.length, 0); ui.drag("repo:z/empty", "repo:a/empty"); assert.deepEqual(ui.order(), ["builtin", "user", "repo:z/empty", "repo:a/empty", "custom:Manual"]); assert.equal(ui.savedState().sortDirection, null); @@ -624,6 +630,8 @@ test("configured empty repositories appear in installed view and retain sort and restored.server.repos = ui.server.repos; restored.server.repoCounts = ui.server.repoCounts; restored.render(); await tick(); restored.render(); restored.selectTab("已安装"); + assert.deepEqual(restored.order(), ["builtin", "user", "custom:Manual"]); + restored.selectTab("发现"); assert.deepEqual(restored.order(), ui.order()); assert.equal(restored.group("repo:a/empty").props.group.items.length, 0); assert.equal(restored.group("repo:z/empty").props.group.items.length, 0); @@ -634,17 +642,66 @@ test("configured empty repositories appear in installed view and retain sort and assert.ok(restored.order().includes("repo:a/empty")); }); -test("an empty skill inventory still shows configured repositories", async () => { +test("an empty inventory hides configured repositories only in installed view", async () => { const ui = createHarness(); ui.server.skills = []; ui.server.repos = [{ owner: "empty", name: "repo", branch: "main", subdir: "", enabled: true }]; ui.render(); await tick(); ui.render(); ui.selectTab("已安装"); - assert.deepEqual(ui.order(), ["builtin", "user", "repo:empty/repo"]); + assert.deepEqual(ui.order(), ["builtin", "user"]); + ui.selectTab("发现"); assert.equal(ui.group("repo:empty/repo").props.group.items.length, 0); ui.search("empty"); assert.deepEqual(ui.order(), []); }); +test("repository matching is next to new group and preserves successful custom assignments", async () => { + const original = skill("first", "local"); + const failed = skill("second", "local"); + const ui = createHarness(JSON.stringify({ names: ["Manual"], assignmentsByIdentity: { + [skillIdentityKey(original)]: "custom:Manual", [skillIdentityKey(failed)]: "", + } })); + ui.server.skills = [original, failed]; + ui.render(); await tick(); ui.render(); + const trailing = ui.find((node) => node.type === "CxFilters").props.trailing; + const controls = trailing.children.filter((node: Element) => node?.type === "button"); + assert.equal(controls.findIndex((node: Element) => node.props["aria-label"] === "匹配仓库") + 1, + controls.findIndex((node: Element) => node.props["aria-label"] === "新建分组")); + ui.find((node) => node.type === "button" && node.props["aria-label"] === "匹配仓库").props.onClick(); ui.render(); + const drawer = ui.find((node) => node.type === "SkillSourceMatchDrawer"); + assert.deepEqual(Array.from(drawer.props.localSkillIds), ["first", "second"]); + ui.hooks.mutation = async (command) => { + assert.equal(command, "apply_skill_repository_matches"); + ui.server.skills = [{ ...original, repoKey: "owner/repo@main" }, failed]; + return { ...structuredClone(ui.server), status: "failed", message: "one failed", applied: [ + { localId: "first", fromRepoKey: "local", toRepoKey: "owner/repo@main" }, + ] }; + }; + await drawer.props.onApply([]); + ui.render(); + assert.deepEqual(ui.savedState().assignmentsByIdentity, { + [skillIdentityKey(skill("first", "owner/repo@main"))]: "custom:Manual", + [skillIdentityKey(failed)]: "", + }); + assert.equal(ui.notices.at(-1)?.status, "failed"); + ui.unmount(); +}); + +test("different Agent instances in one repository display as one row", async () => { + const ui = createHarness(); + ui.server.skills = [ + { ...skill("alpha", "owner/repo@main"), agents: ["codex"], + locations: [{ manifestPath: "C:/agents/alpha/SKILL.md", rootKind: "shared", managed: false, enabled: true }] }, + { ...skill("alpha", "owner/repo@main"), agents: ["claude"], + locations: [{ manifestPath: "C:/claude/alpha/SKILL.md", rootKind: "claude", managed: false, enabled: true }] }, + ]; + ui.render(); await tick(); ui.render(); ui.selectTab("已安装"); + const items = ui.group("repo:owner/repo").props.group.items; + assert.equal(items.length, 1); + assert.equal(items[0].locations.length, 2); + assert.equal(items[0].displayInstances.length, 2); + ui.unmount(); +}); + test("new assignments for duplicate IDs without legacy settings affect only one repository", async () => { const ui = createHarness(JSON.stringify({ names: ["Manual"], assignments: {} })); ui.server.skills = [skill("same", "a/repo@main"), skill("same", "z/repo@main")]; diff --git a/apps/codex-plus-manager/src/skill-groups.test.ts b/apps/codex-plus-manager/src/skill-groups.test.ts index 26fd3e3ee4..a6ba44df28 100644 --- a/apps/codex-plus-manager/src/skill-groups.test.ts +++ b/apps/codex-plus-manager/src/skill-groups.test.ts @@ -1,10 +1,24 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { customSkillGroupKey, defaultSkillGroupKey, groupSkills, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName } from "./skill-groups.ts"; +import { aggregateSkillGroupItems, customSkillGroupKey, defaultSkillGroupKey, groupSkills, migrateSkillGroupAssignments, migrateSkillInstanceAssignments, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillGroupIdentityKey, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName } from "./skill-groups.ts"; const skill = (id: string, repoKey = "", bundled = false) => ({ id, repoKey, bundled }); const labels = { builtin: "Built-in", user: "User", source: (item: ReturnType) => item.repoKey === "local" ? "Local import" : item.repoKey.split("@", 1)[0] }; +test("same repository variants combine across Agents but keep each source path", () => { + const first = { ...skill("alpha", "owner/repo@main"), installed: true, enabled: true, agents: ["codex"], + repoPath: ".agents/skills/alpha", locations: [{ rootKind: "shared" }] }; + const second = { ...skill("alpha", "owner/repo@main"), installed: true, enabled: false, agents: ["claude"], + repoPath: ".claude/skills/alpha", locations: [{ rootKind: "claude" }] }; + const merged = aggregateSkillGroupItems([first, second]); + assert.equal(merged.length, 1); + assert.deepEqual(merged[0].displayInstances?.map((item) => item.repoPath), + [".agents/skills/alpha", ".claude/skills/alpha"]); + assert.deepEqual(merged[0].agents, ["codex", "claude"]); + assert.equal(aggregateSkillGroupItems([first, { ...second, repoKey: "other/repo@main" }]).length, 2); + assert.equal(aggregateSkillGroupItems([first, { ...second, locations: [{ rootKind: "codex" }] }]).length, 2); +}); + test("empty custom groups and pinned groups remain visible without skills", () => { const groups = groupSkills([], [], ["Empty"], {}, [], labels); assert.deepEqual(groups.map((group) => group.key), ["builtin", "user", "custom:Empty"]); @@ -54,6 +68,23 @@ test("configured and discovered sources of the same repository create only one g assert.deepEqual(groups.find((group) => group.key === "repo:owner/repo")?.items, [item]); }); +test("a display-only source groups identical installations without changing their identity or assignments", () => { + const inferred = { ...skill("alpha"), instanceKey: "alpha:C:/claude/alpha", + conflict: "different installs", displayGroupRepoKey: "owner/repo@main" }; + const confirmed = { ...skill("alpha", "owner/repo@main"), instanceKey: "alpha:C:/codex/alpha", + conflict: "different installs" }; + const other = { ...skill("alpha"), instanceKey: "alpha:C:/other/alpha", conflict: "different installs" }; + const items = [inferred, confirmed, other]; + const groups = groupSkills(items, items, ["Manual"], {}, [], labels); + assert.equal(groups.find((group) => group.key === "repo:owner/repo")?.label, "owner/repo"); + assert.deepEqual(groups.find((group) => group.key === "repo:owner/repo")?.items, [inferred, confirmed]); + assert.deepEqual(groups.find((group) => group.key === "user")?.items, [other]); + assert.equal(inferred.repoKey, ""); + const assignments = { [skillIdentityKey(inferred)]: "custom:Manual" }; + const assigned = groupSkills(items, items, ["Manual"], {}, [], labels, true, {}, assignments); + assert.deepEqual(assigned.find((group) => group.key === "custom:Manual")?.items, [inferred]); +}); + test("import channels default to the user group and both generations of custom assignments are preserved", () => { const items = [skill("builtin", "", true), skill("user"), skill("local", "local"), skill("cc", "cc-switch"), skill("repository", "owner/repo@main:skills"), skill("old"), skill("new"), skill("assigned-builtin", "", true)]; @@ -138,6 +169,81 @@ test("identity keys cannot collide with other tuples, legacy IDs, or inherited p assert.equal(skillGroupAssignment(skill("toString", "local"), ["Legacy"], {}, {}), ""); }); +test("conflicting same-name locations have independent identities and do not inherit ambiguous old assignments", () => { + const first = { ...skill("shared"), instanceKey: "shared:C:/agents/shared", conflict: "不同内容" }; + const second = { ...skill("shared"), instanceKey: "shared:C:/codex/shared", conflict: "不同内容" }; + const old = { shared: "Manual" }; + const oldIdentity = { [skillIdentityKey(skill("shared"))]: "custom:Manual" }; + assert.notEqual(skillIdentityKey(first), skillIdentityKey(second)); + assert.equal(skillGroupAssignment(first, ["Manual"], old, oldIdentity), ""); + assert.equal(skillGroupAssignment(second, ["Manual"], old, oldIdentity), ""); + const assigned = { [skillIdentityKey(first)]: "custom:Manual", [skillIdentityKey(second)]: "" }; + assert.equal(skillGroupAssignment(first, ["Manual"], old, assigned), "custom:Manual"); + assert.equal(skillGroupAssignment(second, ["Manual"], old, assigned), ""); + assert.equal(skillIdentityKey({ ...skill("shared"), instanceKey: "shared" }), skillIdentityKey(skill("shared"))); +}); + +test("instance assignments survive conflict, source changes, and conflict resolution", () => { + const original = { ...skill("shared", "owner/repo@main"), instanceKey: "shared:C:/agents/shared" }; + const conflict = { ...original, repoKey: "", conflict: "不同内容" }; + const other = { ...conflict, instanceKey: "shared:C:/codex/shared" }; + const after = { ...original, repoKey: "other/repo@main" }; + assert.equal(skillIdentityKey(original), skillIdentityKey(conflict)); + assert.equal(skillIdentityKey(conflict), skillIdentityKey(after)); + assert.notEqual(skillIdentityKey(conflict), skillIdentityKey(other)); + + const oldIdentity = { [skillIdentityKey(skill("shared", "owner/repo@main"))]: "custom:Manual" }; + const migrated = migrateSkillInstanceAssignments(oldIdentity, {}, [original], ["Manual"]); + assert.equal(skillGroupAssignment(conflict, ["Manual"], {}, migrated), "custom:Manual"); + assert.equal(skillGroupAssignment(other, ["Manual"], {}, migrated), ""); + assert.equal(skillGroupAssignment(after, ["Manual"], {}, migrated), "custom:Manual"); + assert.equal(oldIdentity[skillIdentityKey(original)], undefined); + assert.equal(migrateSkillInstanceAssignments(migrated, {}, [original], ["Manual"]), migrated); +}); + +test("instance migration retains legacy defaults, prior conflict choices, and existing instance choices", () => { + const original = { ...skill("shared", "local"), instanceKey: "shared:C:/agents/shared" }; + const other = { ...skill("other", "local"), instanceKey: "other:C:/agents/other" }; + const conflict = { ...original, repoKey: "", conflict: "不同内容" }; + const legacyTuple = JSON.stringify(["", "shared", original.instanceKey]); + const existing = { + [legacyTuple]: "custom:Prior", + [skillIdentityKey(other)]: "", + [JSON.stringify(["local", "other"])]: "custom:Manual", + }; + const migrated = migrateSkillInstanceAssignments(existing, { shared: "Manual", other: "Manual" }, [conflict, other], ["Manual", "Prior"]); + assert.equal(migrated[skillIdentityKey(conflict)], "custom:Prior"); + assert.equal(migrated[skillIdentityKey(other)], ""); + assert.equal(migrated[legacyTuple], "custom:Prior"); + const fromLegacy = migrateSkillInstanceAssignments({}, { shared: "Manual" }, [original], ["Manual"]); + assert.equal(fromLegacy[skillIdentityKey(original)], "custom:Manual"); + assert.deepEqual(migrateSkillInstanceAssignments({}, { shared: "Manual" }, [conflict], ["Manual"]), {}); +}); + +test("equal independent aggregate keeps unambiguous legacy groups without assigning a physical instance", () => { + const aggregate = { ...skill("shared"), instanceKey: "shared:C:/agents/shared", + conflict: "多个独立安装", conflictKind: "equal-independent" }; + const first = { ...aggregate, conflict: "不同内容", conflictKind: "independent" }; + const second = { ...first, instanceKey: "shared:C:/claude/shared" }; + for (const [old, oldByIdentity] of [ + [{ shared: "Manual" }, {}], + [{}, { [JSON.stringify(["cc-switch", "shared"])]: "custom:Manual" }], + [{}, { [JSON.stringify(["one/repo@main", "shared"])]: "custom:Manual", + [JSON.stringify(["two/repo@main", "shared"])]: "custom:Manual" }], + ] as Array<[Record, Record]>) { + assert.equal(skillGroupAssignment(aggregate, ["Manual"], old, oldByIdentity), "custom:Manual"); + const migrated = migrateSkillInstanceAssignments(oldByIdentity, old, [aggregate], ["Manual"]); + assert.equal(migrated[skillGroupIdentityKey(aggregate)], "custom:Manual"); + assert.equal(migrated[skillIdentityKey(first)], undefined); + assert.equal(skillGroupAssignment(first, ["Manual"], old, migrated), ""); + assert.equal(skillGroupAssignment(second, ["Manual"], old, migrated), ""); + } + const conflicting = { [JSON.stringify(["one/repo@main", "shared"])]: "custom:Manual", + [JSON.stringify(["two/repo@main", "shared"])]: "custom:Other" }; + assert.equal(skillGroupAssignment(aggregate, ["Manual", "Other"], { shared: "Manual" }, conflicting), ""); + assert.equal(migrateSkillInstanceAssignments(conflicting, { shared: "Manual" }, [aggregate], ["Manual", "Other"]), conflicting); +}); + test("changed backend identity does not silently inherit another tuple's override", () => { const original = skill("shared", "owner/repo@main"); const changed = skill("shared", "owner/repo@other"); @@ -148,6 +254,34 @@ test("changed backend identity does not silently inherit another tuple's overrid assert.deepEqual(overrides, { [skillIdentityKey(original)]: "custom:Other" }); }); +test("explicit source associations migrate identity assignments, including source defaults", () => { + const first = skill("first", "local"); + const second = skill("second", "cc-switch"); + const other = skill("other", "local"); + const assignments = { [skillIdentityKey(first)]: "custom:Manual", [skillIdentityKey(second)]: "", [skillIdentityKey(other)]: "custom:Other" }; + const result = migrateSkillGroupAssignments(assignments, [ + { id: "first", fromRepoKey: "local", toRepoKey: "owner/repo@main" }, + { id: "second", fromRepoKey: "cc-switch", toRepoKey: "owner/repo@main" }, + ]); + assert.deepEqual(result, { + [skillIdentityKey(skill("first", "owner/repo@main"))]: "custom:Manual", + [skillIdentityKey(skill("second", "owner/repo@main"))]: "", + [skillIdentityKey(other)]: "custom:Other", + }); + assert.equal(assignments[skillIdentityKey(first)], "custom:Manual"); +}); + +test("source association does not overwrite an existing destination assignment", () => { + const from = skill("same", "local"); + const to = skill("same", "owner/repo@main"); + assert.deepEqual(migrateSkillGroupAssignments({ + [skillIdentityKey(from)]: "custom:Old", + [skillIdentityKey(to)]: "custom:Existing", + }, [{ id: "same", fromRepoKey: "local", toRepoKey: "owner/repo@main" }]), { + [skillIdentityKey(to)]: "custom:Existing", + }); +}); + test("legacy explicit assignments and source aliases keep their stable identities", () => { const items = [skill("first", "one/repo@main"), skill("second", "other/repo@main"), skill("third")]; const groups = groupSkills(items, items, ["user"], { first: "repo:other/repo", second: "user", third: "custom:user" }, [], labels, true, diff --git a/apps/codex-plus-manager/src/skill-groups.ts b/apps/codex-plus-manager/src/skill-groups.ts index 1226a05b6d..fb48a965e3 100644 --- a/apps/codex-plus-manager/src/skill-groups.ts +++ b/apps/codex-plus-manager/src/skill-groups.ts @@ -1,7 +1,15 @@ -export type GroupableSkill = { id: string; repoKey: string; bundled: boolean }; +export type GroupableSkill = { id: string; repoKey: string; bundled: boolean; instanceKey?: string; conflict?: string | null; conflictKind?: string | null; displayGroupRepoKey?: string | null }; export type GroupableRepository = { owner: string; name: string }; export type SkillGroup = { key: string; label: string; kind: "builtin" | "user" | "repo" | "custom"; items: T[] }; export type SkillGroupSortDirection = "asc" | "desc"; +type DisplayableSkill = GroupableSkill & { + installed: boolean; + enabled: boolean; + agents?: string[]; + repoPath?: string; + locations?: Array<{ rootKind: string }>; + safeDisableAgents?: string[]; +}; type SkillGroupStorage = Record & { names: string[]; assignments: Record; @@ -15,7 +23,127 @@ export const BUILTIN_SKILL_GROUP = "builtin"; export const USER_SKILL_GROUP = "user"; export const customSkillGroupKey = (name: string) => `custom:${name}`; export const repositorySkillGroupKey = (name: string) => `repo:${name}`; -export const skillIdentityKey = (skill: Pick) => JSON.stringify([skill.repoKey, skill.id]); +export const skillIdentityKey = (skill: Pick) => + JSON.stringify(skill.instanceKey && skill.instanceKey !== skill.id + ? ["instance", skill.id, skill.instanceKey] : [skill.repoKey, skill.id]); +export const skillGroupIdentityKey = (skill: GroupableSkill) => skill.conflictKind === "equal-independent" + ? JSON.stringify(["aggregate", skill.id]) : skillIdentityKey(skill); + +/** 同组内不同 Agent 的安装共用一行,仍保留每个物理安装的身份。 */ +export function aggregateSkillGroupItems(skills: T[]): Array { + const candidates = new Map(); + for (const skill of skills) { + if (!skill.installed || skill.bundled) continue; + candidates.set(skill.id, [...(candidates.get(skill.id) || []), skill]); + } + const merged = new Map(); + for (const [id, instances] of candidates) { + if (instances.length < 2 || instances.some((item) => !item.locations?.length)) continue; + const owners = new Set(); + let overlaps = false; + for (const item of instances) { + const agents = new Set(item.locations!.map((location) => location.rootKind === "shared" ? "codex" : location.rootKind)); + if ([...agents].some((agent) => owners.has(agent))) overlaps = true; + for (const agent of agents) owners.add(agent); + } + const sources = new Set(instances.filter((item) => skillRepositoryName(item.repoKey)) + .map((item) => item.repoKey)); + if (overlaps || sources.size > 1) continue; + const agents = [...new Set(instances.flatMap((item) => item.agents ?? (item.enabled ? ["codex"] : [])))]; + merged.set(id, { + ...instances[0], repoKey: "", repoPath: "", originalDir: null, updateOwner: "", updateAvailable: false, + agents, enabled: agents.includes("codex"), locations: instances.flatMap((item) => item.locations || []), + safeDisableAgents: [...new Set(instances.flatMap((item) => item.safeDisableAgents || []))], + displayInstances: instances, + } as T & { displayInstances: T[] }); + } + const emitted = new Set(); + return skills.flatMap((skill) => { + const item = skill.installed && !skill.bundled ? merged.get(skill.id) : undefined; + if (!item) return [skill]; + if (emitted.has(skill.id)) return []; + emitted.add(skill.id); + return [item]; + }); +} + +function aggregateLegacyAssignment( + skill: GroupableSkill, + assignmentsByIdentity: Record, +): string | null | undefined { + const existing = skillIdentityKey(skill); + if (Object.prototype.hasOwnProperty.call(assignmentsByIdentity, existing)) return assignmentsByIdentity[existing]; + const values = Object.entries(assignmentsByIdentity).filter(([key]) => { + try { + const tuple: unknown = JSON.parse(key); + return Array.isArray(tuple) && tuple.length === 2 && tuple[1] === skill.id && tuple[0] !== "aggregate"; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + return false; + } + }).map(([, value]) => value); + if (!values.length) return undefined; + return values.every((value) => value === values[0]) ? values[0] : null; +} + +export function migrateSkillInstanceAssignments( + assignmentsByIdentity: Record, + assignments: Record, + skills: GroupableSkill[], + customNames: string[], +): Record { + let migrated = assignmentsByIdentity; + for (const skill of skills) { + if (skill.bundled || !skill.instanceKey || skill.instanceKey === skill.id) continue; + const key = skillGroupIdentityKey(skill); + if (Object.prototype.hasOwnProperty.call(migrated, key)) continue; + if (skill.conflictKind === "equal-independent") { + const value = aggregateLegacyAssignment(skill, migrated); + if (typeof value === "string") { + if (migrated === assignmentsByIdentity) migrated = { ...assignmentsByIdentity }; + migrated[key] = value; + } else if (value === undefined && Object.prototype.hasOwnProperty.call(assignments, skill.id)) { + const legacy = assignments[skill.id]; + const name = legacySkillCustomGroupName(legacy, customNames); + if (migrated === assignmentsByIdentity) migrated = { ...assignmentsByIdentity }; + migrated[key] = name === null ? legacy : customSkillGroupKey(name); + } + continue; + } + const previousKeys = [ + JSON.stringify([skill.repoKey, skill.id, skill.instanceKey]), + JSON.stringify(["", skill.id, skill.instanceKey]), + ...(!skill.conflict ? [JSON.stringify([skill.repoKey, skill.id])] : []), + ]; + const previousKey = previousKeys.find((previous) => Object.prototype.hasOwnProperty.call(migrated, previous)); + let value: string; + if (previousKey !== undefined) value = migrated[previousKey]; + else if (!skill.conflict && Object.prototype.hasOwnProperty.call(assignments, skill.id)) { + const legacy = assignments[skill.id]; + const name = legacySkillCustomGroupName(legacy, customNames); + value = name === null ? legacy : customSkillGroupKey(name); + } else continue; + if (migrated === assignmentsByIdentity) migrated = { ...assignmentsByIdentity }; + migrated[key] = value; + } + return migrated; +} + +export function migrateSkillGroupAssignments( + assignments: Record, + changes: Array<{ id: string; fromRepoKey: string; toRepoKey: string }>, +): Record { + const migrated = { ...assignments }; + for (const change of changes) { + const from = skillIdentityKey({ id: change.id, repoKey: change.fromRepoKey }); + const to = skillIdentityKey({ id: change.id, repoKey: change.toRepoKey }); + if (from === to || !Object.prototype.hasOwnProperty.call(migrated, from)) continue; + // 目标身份可能已有用户设置,保留它,避免覆盖另一项的分组。 + if (!Object.prototype.hasOwnProperty.call(migrated, to)) migrated[to] = migrated[from]; + delete migrated[from]; + } + return migrated; +} /** 导入渠道不是仓库;只有明确的 owner/repo@branch 来源才创建仓库分组。 */ export function skillRepositoryName(repoKey: string): string | null { @@ -25,7 +153,7 @@ export function skillRepositoryName(repoKey: string): string | null { export function defaultSkillGroupKey(skill: GroupableSkill): string { if (skill.bundled) return BUILTIN_SKILL_GROUP; - const repository = skillRepositoryName(skill.repoKey); + const repository = skillRepositoryName(skill.repoKey) || skillRepositoryName(skill.displayGroupRepoKey || ""); return repository ? repositorySkillGroupKey(repository) : USER_SKILL_GROUP; } @@ -41,9 +169,17 @@ export function skillGroupAssignment( assignments: Record, assignmentsByIdentity: Record, ): string { - const key = skillIdentityKey(skill); + const key = skillGroupIdentityKey(skill); // 空字符串表示主动选择按来源,不能再回退到旧的 ID 分配。 if (Object.prototype.hasOwnProperty.call(assignmentsByIdentity, key)) return assignmentsByIdentity[key]; + if (skill.conflictKind === "equal-independent") { + const legacy = aggregateLegacyAssignment(skill, assignmentsByIdentity); + if (legacy !== undefined) return legacy ?? ""; + } + // 旧设置不能证明属于哪一份冲突内容。 + if (skill.conflict && skill.conflictKind !== "equal-independent") return ""; + const previousKey = JSON.stringify([skill.repoKey, skill.id]); + if (Object.prototype.hasOwnProperty.call(assignmentsByIdentity, previousKey)) return assignmentsByIdentity[previousKey]; const legacy = Object.prototype.hasOwnProperty.call(assignments, skill.id) ? assignments[skill.id] : ""; const customName = legacySkillCustomGroupName(legacy, customNames); return customName === null ? legacy : customSkillGroupKey(customName); @@ -82,7 +218,9 @@ export function groupSkills( knownRepositories: GroupableRepository[] = [], ): SkillGroup[] { const groups = new Map>(); - const sourceLabels = new Map(known.map((skill) => [defaultSkillGroupKey(skill), labels.source(skill)])); + const sourceLabels = new Map(known.map((skill) => [defaultSkillGroupKey(skill), + skillRepositoryName(skill.repoKey) ? labels.source(skill) + : skillRepositoryName(skill.displayGroupRepoKey || "") || labels.source(skill)])); const add = (key: string, label: string, kind: SkillGroup["kind"]) => { if (!groups.has(key)) groups.set(key, { key, label, kind, items: [] }); }; @@ -91,10 +229,10 @@ export function groupSkills( add(USER_SKILL_GROUP, labels.user, "user"); for (const name of customNames) add(customSkillGroupKey(name), name, "custom"); for (const skill of known) { - const name = !skill.bundled && skillRepositoryName(skill.repoKey); + const name = !skill.bundled && (skillRepositoryName(skill.repoKey) || skillRepositoryName(skill.displayGroupRepoKey || "")); if (name) { const key = repositorySkillGroupKey(name); - add(key, aliases[key] || labels.source(skill) || name, "repo"); + add(key, aliases[key] || (skillRepositoryName(skill.repoKey) ? labels.source(skill) : name) || name, "repo"); } } // 与后端仓库配置的 owner/name 规则一致,导入渠道本身不创建仓库组。 diff --git a/apps/codex-plus-manager/src/skill-location-association.test.ts b/apps/codex-plus-manager/src/skill-location-association.test.ts new file mode 100644 index 0000000000..872e10d81e --- /dev/null +++ b/apps/codex-plus-manager/src/skill-location-association.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +type Element = { type: any; props: Record; children: any[] }; + +test("each Agent installation associates its own repository path", () => { + 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); + const nodes = file.statements.filter((node) => ts.isFunctionDeclaration(node) + && (node.name?.text === "repoFromKey" || node.name?.text === "SkillDetailDrawer")); + const code = ts.transpileModule(nodes.map((node) => node.getText(file)).join("\n"), { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const selected: Array<[string, Record]> = []; + const context: Record = { + exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + fragment: "fragment", t: (value: string) => value, repoUrl: () => "", sourceLabel: () => "本地", + ManagementDrawer: "ManagementDrawer", Button: "Button", Edit3: "Edit3", ExternalLink: "ExternalLink", ArrowRight: "ArrowRight", + }; + vm.createContext(context); + vm.runInContext(code, context); + const locations = [ + { manifestPath: "C:/agents/alpha/SKILL.md", rootKind: "shared", managed: false, enabled: true, + selectable: true, originalPath: "C:/agents/alpha", repoKey: "owner/repo@main", repoPath: ".agents/skills/alpha" }, + { manifestPath: "C:/claude/alpha/SKILL.md", rootKind: "claude", managed: false, enabled: true, + selectable: true, originalPath: "C:/claude/alpha", repoKey: "owner/repo@main", repoPath: ".claude/skills/alpha" }, + ]; + const element = context.SkillDetailDrawer({ + skill: { id: "alpha", name: "alpha", description: "", repoKey: "", installed: true, bundled: false, + locations, displayInstances: locations.map((location) => ({ repoKey: location.repoKey, repoPath: location.repoPath, locations: [location] })) }, + busy: false, onClose: () => {}, onOpenUrl: () => {}, onOpenRepo: () => {}, + onMatch: (path: string, details: Record) => selected.push([path, details]), + }); + const collect = (node: any, result: Element[] = []): Element[] => { + if (!node || typeof node !== "object") return result; + if (Array.isArray(node)) { node.forEach((child) => collect(child, result)); return result; } + result.push(node); + node.children?.forEach((child: any) => collect(child, result)); + return result; + }; + const buttons = collect(element).filter((item) => item.type === "Button" && item.children.includes("关联此安装位置")); + assert.equal(buttons.length, 2); + buttons.forEach((button) => button.props.onClick()); + assert.deepEqual(selected.map(([path, detail]) => [path, detail.currentRepoPath]), [ + ["C:/agents/alpha", ".agents/skills/alpha"], + ["C:/claude/alpha", ".claude/skills/alpha"], + ]); +}); diff --git a/apps/codex-plus-manager/src/skill-source-match-ui.test.ts b/apps/codex-plus-manager/src/skill-source-match-ui.test.ts new file mode 100644 index 0000000000..82dcbb22e2 --- /dev/null +++ b/apps/codex-plus-manager/src/skill-source-match-ui.test.ts @@ -0,0 +1,362 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; +import { discardSkillSourceScan, getSkillSourceScan, skillSourceRepoKey as keyOfRepo } from "./skill-source-scan.ts"; + +type Element = { type: any; props: Record; children: any[] }; + +const source = readFileSync(new URL("./SkillSourceMatchDrawer.tsx", import.meta.url), "utf8"); +const file = ts.createSourceFile("SkillSourceMatchDrawer.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const nodes = file.statements.filter((node) => + ts.isFunctionDeclaration(node) && node.name?.text === "SkillSourceMatchDrawer" + || ts.isVariableStatement(node) && node.declarationList.declarations.some((item) => + ts.isIdentifier(item.name) && ["keyOfRepo", "keyOfCandidate", "evidenceLabel"].includes(item.name.text))); +const code = ts.transpileModule(nodes.map((node) => node.getText(file)).join("\n"), { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test("batch discovery exposes local evidence and submits only preview-bound choices", async () => { + const slots: any[] = []; + const effects: Array<() => void> = []; + const calls: Array<{ command: string; args: any }> = []; + const selections: any[] = []; + let cursor = 0; + let elements: Element[] = []; + let closed = false; + const context: Record = { + exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + fragment: "fragment", + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = typeof initial === "function" ? initial() : initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useEffect: (run: () => void, deps: any[]) => { + const index = cursor++; + const previous = slots[index] as any[] | undefined; + if (!previous || deps.some((value, position) => !Object.is(value, previous[position]))) effects.push(run); + slots[index] = deps; + }, + useMemo: (run: () => any) => run(), + useRef: (value: any) => { const index = cursor++; return slots[index] ??= { current: value }; }, + t: (value: string) => value, + tf: (value: string, args: any[]) => value.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), + invoke: async (command: string, args: any) => { + calls.push({ command, args }); + if (command === "discover_skill_source_evidence") return { + status: "ok", message: "", evidence: args.id === "alpha" + ? [{ repo: "owner/alpha", kind: "lock", path: "skills-lock.json" }] + : [{ repo: "owner/beta", kind: "script-link", path: "start.ps1" }], + }; + assert.equal(command, "find_skill_repository_matches"); + return { status: "ok", message: "", previewId: "preview-one", warnings: [], manualCandidates: [], + matches: [{ localId: "alpha", localName: "Alpha", repoKey: "owner/alpha@main", + repoPath: "skills/alpha", remoteName: "Alpha", exact: true, localHash: "hidden-local", + snapshot: "hidden-snapshot", remoteHash: "hidden-remote" }] }; + }, + AppSelect: "AppSelect", ManagementDrawer: "ManagementDrawer", Pause: "Pause", Play: "Play", Plus: "Plus", RefreshCw: "RefreshCw", Search: "Search", + discardSkillSourceScan, getSkillSourceScan, keyOfRepo, + }; + vm.createContext(context); + vm.runInContext(code, context); + const collect = (node: any) => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { node.forEach(collect); return; } + elements.push(node); + node.children?.forEach(collect); + if (node.props?.footer) collect(node.props.footer); + }; + const props = { + repos: [{ owner: "owner", name: "alpha", branch: "main", subdir: "", enabled: true }], + localSkillIds: ["alpha", "beta", "alpha"], + onClose: () => { closed = true; }, + onAddRepo: async () => null, + onApply: async (items: any[]) => { selections.push(...items); return { status: "ok", message: "" }; }, + }; + const render = () => { + cursor = 0; elements = []; effects.length = 0; + collect(context.exports.SkillSourceMatchDrawer(props)); + effects.splice(0).forEach((run) => run()); + }; + render(); + await tick(); + await tick(); + render(); + assert.deepEqual(calls.filter((call) => call.command === "discover_skill_source_evidence").map((call) => call.args.id), + ["alpha", "beta"]); + assert.ok(elements.some((node) => node.type === "small" && node.children.includes("alpha · 锁文件"))); + assert.ok(elements.some((node) => node.type === "small" && node.children.includes("beta · 脚本中的链接"))); + + const checkbox = elements.find((node) => node.type === "input" && node.props.type === "checkbox"); + assert.ok(checkbox); + checkbox.props.onChange({ currentTarget: { checked: true } }); + render(); + const confirm = elements.find((node) => node.type === "button" && node.children.includes("关联所选 1 项")); + assert.ok(confirm); + assert.equal(confirm.props.disabled, false); + await confirm.props.onClick(); + await tick(); + assert.deepEqual(JSON.parse(JSON.stringify(selections)), [{ + previewId: "preview-one", localId: "alpha", repoKey: "owner/alpha@main", repoPath: "skills/alpha", + }]); + assert.equal(closed, true); +}); + +test("single-skill matching binds each installation to the selected repository path", async () => { + for (const { localPath, currentRepoKey, currentRepoPath, single } of [ + { localPath: "C:/agents/alpha", currentRepoKey: undefined, currentRepoPath: undefined, single: false }, + { localPath: undefined, currentRepoKey: undefined, currentRepoPath: undefined, single: false }, + { localPath: "C:/claude/alpha", currentRepoKey: "owner/repo@main", currentRepoPath: ".codex/skills/alpha", single: false }, + { localPath: "C:/grok/alpha", currentRepoKey: undefined, currentRepoPath: undefined, single: true }, + ]) { + const slots: any[] = []; + const effects: Array<() => void> = []; + const calls: Array<{ command: string; args: any }> = []; + const selections: any[] = []; + let cursor = 0; + let elements: Element[] = []; + const context: Record = { + exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + fragment: "fragment", + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = typeof initial === "function" ? initial() : initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useEffect: (run: () => void, deps: any[]) => { + const index = cursor++; + const previous = slots[index] as any[] | undefined; + if (!previous || deps.some((value, position) => !Object.is(value, previous[position]))) effects.push(run); + slots[index] = deps; + }, + useMemo: (run: () => any) => run(), + useRef: (value: any) => { const index = cursor++; return slots[index] ??= { current: value }; }, + t: (value: string) => value, + tf: (value: string, args: any[]) => value.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), + invoke: async (command: string, args: any) => { + calls.push({ command, args }); + if (command === "discover_skill_source_evidence") return { status: "ok", message: "", evidence: [] }; + return { status: "ok", message: "", previewId: "instance-preview", warnings: [], + manualCandidates: [{ localId: "alpha", localName: "alpha", repoKey: "owner/aaa@main", + repoPath: "skills/unrelated", remoteName: "unrelated", exact: false, localPaths: ["C:/agents/other"] }], + matches: [ + ...single ? [] : [{ localId: "alpha", localName: "alpha", repoKey: "owner/repo@main", + repoPath: ".agents/skills/alpha", remoteName: "alpha", exact: true, localPaths: ["C:/agents/other"] }], + { localId: "alpha", localName: "alpha", repoKey: "owner/repo@main", + repoPath: ".codex/skills/alpha", remoteName: "alpha", exact: false, localPaths: ["C:/agents/other"] }] }; + }, + AppSelect: "AppSelect", ManagementDrawer: "ManagementDrawer", Pause: "Pause", Play: "Play", Plus: "Plus", RefreshCw: "RefreshCw", Search: "Search", + discardSkillSourceScan, getSkillSourceScan, keyOfRepo, + }; + vm.createContext(context); + vm.runInContext(code, context); + const collect = (node: any) => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { node.forEach(collect); return; } + elements.push(node); + node.children?.forEach(collect); + if (node.props?.footer) collect(node.props.footer); + }; + const props = { + id: "alpha", localPath, agentLabel: "Agent", currentRepoKey, currentRepoPath, + repos: [{ owner: "owner", name: "repo", branch: "main", subdir: "", enabled: true }], + localSkillIds: [], onClose: () => {}, onAddRepo: async () => null, + onApply: async (items: any[]) => { selections.push(...items); return { status: "ok", message: "" }; }, + }; + const render = () => { + cursor = 0; elements = []; effects.length = 0; + collect(context.exports.SkillSourceMatchDrawer(props)); + effects.splice(0).forEach((run) => run()); + }; + render(); await tick(); await tick(); render(); + for (const command of ["discover_skill_source_evidence", "find_skill_repository_matches"]) { + const request = calls.find((call) => call.command === command); + assert.equal(request?.args.id, props.id); + assert.equal(request?.args.localPath, props.localPath || null); + } + const checkbox = elements.find((node) => node.type === "input" && node.props.type === "checkbox"); + assert.ok(checkbox); + checkbox.props.onChange({ currentTarget: { checked: true } }); render(); + const select = elements.find((node) => node.type === "AppSelect"); + assert.ok(select); + assert.deepEqual(Array.from(select.props.options, (option: any) => option.label), [ + ...single ? [] : ["owner/repo@main / .agents/skills/alpha"], + "owner/repo@main / .codex/skills/alpha", + "owner/aaa@main / skills/unrelated", + ]); + const wanted = JSON.stringify(["owner/repo@main", ".codex/skills/alpha"]); + assert.equal(select.props.value, single || currentRepoKey ? wanted : ""); + if (localPath) { + assert.ok(elements.some((node) => node.type === "dd" && node.children.includes(`Agent · ${localPath}`))); + assert.ok(elements.some((node) => node.type === "small" && node.children.includes(localPath))); + assert.ok(!elements.some((node) => node.type === "small" && node.children.includes("C:/agents/other"))); + } else { + assert.ok(elements.some((node) => node.type === "small" && node.children.includes("C:/agents/other"))); + } + let confirm = elements.find((node) => node.type === "button" && node.children.includes("关联所选 1 项")); + assert.ok(confirm); + if (!single && !currentRepoKey) { + assert.equal(confirm.props.disabled, true); + select.props.onChange(wanted); + render(); + confirm = elements.find((node) => node.type === "button" && node.children.includes("关联所选 1 项")); + assert.ok(confirm); + } + assert.equal(confirm.props.disabled, false); + await confirm.props.onClick(); await tick(); + assert.equal(selections[0].localPath, props.localPath); + assert.equal(selections[0].previewId, "instance-preview"); + assert.equal(selections[0].localId, props.id); + assert.equal(selections[0].repoKey, "owner/repo@main"); + assert.equal(selections[0].repoPath, ".codex/skills/alpha"); + } +}); + +test("background scanning can be paused for a manual association and restarted after an expired preview", async () => { + let slots: any[] = []; + let effects: Array<() => void> = []; + let cleanups: Array<() => void> = []; + let cursor = 0; + let elements: Element[] = []; + const started: string[] = []; + const pending: Array<(value: unknown) => void> = []; + const selections: any[][] = []; + let closed = false; + const repos = ["one", "two"].map((name) => ({ owner: "owner", name, branch: "main", subdir: "", enabled: true })); + const context: Record = { + exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + fragment: "fragment", + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = typeof initial === "function" ? initial() : initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useEffect: (run: () => void | (() => void), deps: any[]) => { + const index = cursor++; + const previous = slots[index] as any[] | undefined; + if (!previous || deps.some((value, position) => !Object.is(value, previous[position]))) { + effects.push(() => { const cleanup = run(); if (cleanup) cleanups.push(cleanup); }); + } + slots[index] = deps; + }, + useMemo: (run: () => any) => run(), + useRef: (value: any) => { const index = cursor++; return slots[index] ??= { current: value }; }, + t: (value: string) => value, + tf: (value: string, args: any[]) => value.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), + invoke: async (command: string, args: any) => { + if (command === "discover_skill_source_evidence") return { status: "ok", message: "", evidence: [] }; + assert.equal(command, "find_skill_repository_matches"); + started.push(args.repoKey); + return new Promise((resolve) => pending.push(resolve)); + }, + AppSelect: "AppSelect", ManagementDrawer: "ManagementDrawer", Pause: "Pause", Play: "Play", Plus: "Plus", RefreshCw: "RefreshCw", Search: "Search", + discardSkillSourceScan, getSkillSourceScan, keyOfRepo, + }; + vm.createContext(context); + vm.runInContext(code, context); + const collect = (node: any) => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { node.forEach(collect); return; } + elements.push(node); + node.children?.forEach(collect); + if (node.props?.footer) collect(node.props.footer); + }; + const props = { + id: "manual-background-fixture", localPath: "C:/fixture/manual-background", + repos, localSkillIds: [], onClose: () => { closed = true; }, onAddRepo: async () => null, + onApply: async (items: any[]) => { + selections.push(JSON.parse(JSON.stringify(items))); + return selections.length === 1 + ? { status: "failed", message: "来源匹配预览已失效,请重新匹配" } + : { status: "ok", message: "" }; + }, + }; + const render = () => { + cursor = 0; elements = []; effects = []; + collect(context.exports.SkillSourceMatchDrawer(props)); + effects.splice(0).forEach((run) => run()); + }; + const unmount = () => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + slots = []; + }; + const button = (label: string) => { + const found = elements.find((node) => node.type === "button" && node.children.includes(label)); + assert.ok(found, `missing button: ${label}`); + return found; + }; + const candidate = (previewId: string) => ({ + status: "ok", message: "", previewId, warnings: [], matches: [], + manualCandidates: [{ + localId: props.id, localName: props.id, repoKey: "owner/one@main", + repoPath: "skills/chosen", remoteName: "chosen", exact: false, + }], + }); + const selectManualCandidate = () => { + const checkbox = elements.find((node) => node.type === "input" && node.props.type === "checkbox"); + assert.ok(checkbox); + assert.equal(checkbox.props.disabled, false); + checkbox.props.onChange({ currentTarget: { checked: true } }); + render(); + const select = elements.find((node) => node.type === "AppSelect"); + assert.ok(select); + assert.equal(select.props.options[0].hint, "手动选择,内容不同"); + select.props.onChange(JSON.stringify(["owner/one@main", "skills/chosen"])); + render(); + }; + + render(); + assert.deepEqual(started, ["owner/one@main"]); + button("关闭").props.onClick(); + assert.equal(closed, true); + unmount(); + pending[0](candidate("old-preview")); + await tick(); + assert.deepEqual(started, ["owner/one@main", "owner/two@main"]); + + closed = false; + render(); + assert.ok(elements.some((node) => node.props.role === "status" + && node.children.some((child: unknown) => typeof child === "string" && child.includes("owner/two@main")))); + button("暂停扫描").props.onClick(); + render(); + assert.ok(button("继续扫描")); + selectManualCandidate(); + const confirm = button("关联所选 1 项"); + assert.equal(confirm.props.disabled, false); + confirm.props.onClick(); + await tick(); + render(); + assert.ok(elements.some((node) => node.props.role === "alert" && node.children.includes("来源匹配预览已失效,请重新匹配"))); + assert.equal(closed, false); + assert.equal(selections[0][0].previewId, "old-preview"); + assert.equal(selections[0][0].manual, true); + assert.equal(selections[0][0].localPath, props.localPath); + + const retry = elements.find((node) => node.type === "button" && node.props["aria-label"] === "重新匹配"); + assert.ok(retry); + retry.props.onClick(); + render(); + assert.ok(!elements.some((node) => node.props.role === "alert" + && node.children.includes("来源匹配预览已失效,请重新匹配"))); + pending[1](candidate("discarded-preview")); + await tick(); + assert.deepEqual(started, ["owner/one@main", "owner/two@main", "owner/one@main"]); + pending[2](candidate("new-preview")); + await tick(); + render(); + button("暂停扫描").props.onClick(); + render(); + selectManualCandidate(); + button("关联所选 1 项").props.onClick(); + await tick(); + assert.equal(closed, true); + assert.equal(selections[1][0].previewId, "new-preview"); + assert.equal(selections[1][0].manual, true); + unmount(); + pending[3](candidate("finished-after-close")); + await tick(); +}); diff --git a/apps/codex-plus-manager/src/skill-source-scan.test.ts b/apps/codex-plus-manager/src/skill-source-scan.test.ts new file mode 100644 index 0000000000..eca1fcfe06 --- /dev/null +++ b/apps/codex-plus-manager/src/skill-source-scan.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SkillSourceScan, discardSkillSourceScan, getSkillSourceScan } from "./skill-source-scan.ts"; + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const repo = (name: string) => ({ owner: "owner", name, branch: "main", subdir: "", enabled: true }); +const result = (name: string) => ({ + status: "ok", message: "", previewId: `preview-${name}`, warnings: [], manualCandidates: [], + matches: [{ localId: name, localName: name, repoKey: `owner/${name}@main`, repoPath: name, remoteName: name, exact: true }], +}); + +test("pause retains completed results, waits for the current request, and resumes the remaining repositories", async () => { + const started: string[] = []; + const pending = new Map void>(); + const scan = new SkillSourceScan(async (command, args) => { + if (command === "discover_skill_source_evidence") return { status: "ok", evidence: [] }; + const key = String(args.repoKey); + started.push(key); + return new Promise((resolve) => pending.set(key, resolve)); + }); + scan.ensure([repo("one"), repo("two"), repo("three")], ["one"]); + assert.deepEqual(started, ["owner/one@main"]); + pending.get("owner/one@main")!(result("one")); + await tick(); + assert.deepEqual(started, ["owner/one@main", "owner/two@main"]); + scan.pause(); + assert.equal(scan.getSnapshot().status, "paused"); + assert.equal(scan.getSnapshot().currentRepo, "owner/two@main"); + assert.deepEqual(scan.getSnapshot().candidates.map((item) => item.localId), ["one"]); + pending.get("owner/two@main")!(result("two")); + await tick(); + assert.deepEqual(started, ["owner/one@main", "owner/two@main"]); + assert.equal(scan.getSnapshot().progress, 2); + assert.deepEqual(scan.getSnapshot().candidates.map((item) => item.localId), ["one", "two"]); + scan.resume(); + assert.deepEqual(started, ["owner/one@main", "owner/two@main", "owner/three@main"]); + pending.get("owner/three@main")!(result("three")); + await tick(); + assert.equal(scan.getSnapshot().status, "complete"); + assert.equal(scan.getSnapshot().progress, 3); +}); + +test("closing the view leaves the same scan running and reopening sees its results", async () => { + let resolveScan!: (value: unknown) => void; + const request = async () => new Promise((resolve) => { resolveScan = resolve; }); + const scan = getSkillSourceScan(request, "background-fixture", "C:/fixture"); + let notices = 0; + const unsubscribe = scan.subscribe(() => notices++); + scan.ensure([repo("one")], []); + assert.equal(scan.getSnapshot().status, "scanning"); + unsubscribe(); + const before = notices; + resolveScan(result("one")); + await tick(); + assert.equal(notices, before); + const reopened = getSkillSourceScan(request, "background-fixture", "C:/fixture"); + assert.equal(reopened, scan); + assert.equal(reopened.getSnapshot().status, "complete"); + assert.equal(reopened.getSnapshot().candidates[0].previewId, "preview-one"); + discardSkillSourceScan(scan, "background-fixture", "C:/fixture"); +}); + +test("scan reports per-repository failures and continues, including repositories added while paused", async () => { + const started: string[] = []; + let resolveFirst!: (value: unknown) => void; + const scan = new SkillSourceScan(async (command, args) => { + if (command === "discover_skill_source_evidence") throw new Error("evidence denied"); + const key = String(args.repoKey); + started.push(key); + if (key === "owner/one@main") return new Promise((resolve) => { resolveFirst = resolve; }); + if (key === "owner/two@main") return { status: "failed", message: "offline", warnings: [], matches: [] }; + throw new Error("network down"); + }); + scan.ensure([repo("one"), repo("two")], ["alpha"]); + scan.pause(); + scan.addRepo(repo("three")); + resolveFirst(result("one")); + await tick(); + assert.deepEqual(started, ["owner/one@main"]); + assert.deepEqual(scan.getSnapshot().evidenceErrors, ["alpha:Error: evidence denied"]); + scan.resume(); + await tick(); + assert.equal(scan.getSnapshot().status, "complete"); + assert.equal(scan.getSnapshot().progress, 3); + assert.deepEqual(scan.getSnapshot().warnings, ["owner/two@main:offline", "owner/three@main:Error: network down"]); +}); + +test("restarting ignores an old pending response and scans the new request once it settles", async () => { + const pending: Array<(value: unknown) => void> = []; + const scan = new SkillSourceScan(async () => new Promise((resolve) => pending.push(resolve))); + scan.ensure([repo("one")], []); + scan.restart([repo("two")], []); + assert.equal(pending.length, 1); + pending[0](result("one")); + await tick(); + assert.equal(pending.length, 2); + assert.deepEqual(scan.getSnapshot().candidates, []); + pending[1](result("two")); + await tick(); + assert.deepEqual(scan.getSnapshot().candidates.map((item) => item.localId), ["two"]); + assert.equal(scan.getSnapshot().status, "complete"); +}); + +test("a partial association removes applied candidates even when the active request returns later", async () => { + let resolveScan!: (value: unknown) => void; + const scan = new SkillSourceScan(async () => new Promise((resolve) => { resolveScan = resolve; })); + scan.ensure([repo("one")], []); + scan.pause(); + scan.consume(["one"]); + resolveScan(result("one")); + await tick(); + assert.deepEqual(scan.getSnapshot().candidates, []); + assert.equal(scan.getSnapshot().status, "paused"); +}); + +test("pause stops both queues after their current requests and reopening preserves local evidence progress", async () => { + const started: string[] = []; + const pending = new Map void>(); + const request = async (command: string, args: Record) => { + const key = command === "discover_skill_source_evidence" ? `local:${args.id}` : `repo:${args.repoKey}`; + started.push(key); + return new Promise((resolve) => pending.set(key, resolve)); + }; + const scan = getSkillSourceScan(request, "paused-evidence-fixture"); + const unsubscribe = scan.subscribe(() => {}); + scan.ensure([repo("one"), repo("two")], ["alpha", "beta"]); + assert.deepEqual(started, ["local:alpha", "repo:owner/one@main"]); + scan.pause(); + unsubscribe(); + pending.get("local:alpha")!({ status: "ok", message: "", evidence: [ + { repo: "owner/alpha", kind: "lock", path: "skills-lock.json" }, + ] }); + pending.get("repo:owner/one@main")!(result("one")); + await tick(); + assert.deepEqual(started, ["local:alpha", "repo:owner/one@main"]); + + const reopened = getSkillSourceScan(request, "paused-evidence-fixture"); + assert.equal(reopened, scan); + assert.equal(reopened.getSnapshot().status, "paused"); + assert.equal(reopened.getSnapshot().evidenceScanning, false); + assert.deepEqual(reopened.getSnapshot().evidence.map((item) => item.localId), ["alpha"]); + assert.equal(reopened.getSnapshot().progress, 1); + reopened.resume(); + assert.deepEqual(started, ["local:alpha", "repo:owner/one@main", "local:beta", "repo:owner/two@main"]); + pending.get("local:beta")!({ status: "ok", message: "", evidence: [ + { repo: "owner/beta", kind: "receipt", path: "receipt.json" }, + ] }); + pending.get("repo:owner/two@main")!(result("two")); + await tick(); + assert.equal(reopened.getSnapshot().status, "complete"); + assert.deepEqual(reopened.getSnapshot().evidence.map((item) => item.localId), ["alpha", "beta"]); + assert.equal(reopened.getSnapshot().progress, 2); + discardSkillSourceScan(scan, "paused-evidence-fixture"); +}); + +test("local evidence can be paused and resumed even with no repositories", async () => { + const started: string[] = []; + const pending: Array<(value: unknown) => void> = []; + const scan = new SkillSourceScan(async (_command, args) => { + started.push(String(args.id)); + return new Promise((resolve) => pending.push(resolve)); + }); + scan.ensure([], ["alpha", "beta"]); + assert.equal(scan.getSnapshot().status, "scanning"); + scan.pause(); + pending[0]({ status: "ok", message: "", evidence: [] }); + await tick(); + assert.deepEqual(started, ["alpha"]); + assert.equal(scan.getSnapshot().status, "paused"); + assert.equal(scan.getSnapshot().evidenceScanning, false); + scan.resume(); + assert.deepEqual(started, ["alpha", "beta"]); + pending[1]({ status: "ok", message: "", evidence: [] }); + await tick(); + assert.equal(scan.getSnapshot().status, "complete"); +}); + +test("restarting local evidence ignores an old response and starts the new queue", async () => { + const started: string[] = []; + const pending: Array<(value: unknown) => void> = []; + const scan = new SkillSourceScan(async (_command, args) => { + started.push(String(args.id)); + return new Promise((resolve) => pending.push(resolve)); + }); + scan.ensure([], ["old"]); + scan.restart([], ["new"]); + assert.deepEqual(started, ["old"]); + pending[0]({ status: "ok", message: "", evidence: [ + { repo: "owner/old", kind: "lock", path: "old.json" }, + ] }); + await tick(); + assert.deepEqual(started, ["old", "new"]); + assert.deepEqual(scan.getSnapshot().evidence, []); + pending[1]({ status: "ok", message: "", evidence: [ + { repo: "owner/new", kind: "lock", path: "new.json" }, + ] }); + await tick(); + assert.equal(scan.getSnapshot().status, "complete"); + assert.deepEqual(scan.getSnapshot().evidence.map((item) => item.localId), ["new"]); +}); diff --git a/apps/codex-plus-manager/src/skill-source-scan.ts b/apps/codex-plus-manager/src/skill-source-scan.ts new file mode 100644 index 0000000000..1d03c52781 --- /dev/null +++ b/apps/codex-plus-manager/src/skill-source-scan.ts @@ -0,0 +1,222 @@ +import type { SkillRepository } from "./skill-repository"; + +export type SkillSourceMatch = { + previewId: string; localId: string; localName: string; + repoKey: string; repoPath: string; remoteName: string; exact: boolean; + conflict?: string | null; localPaths?: string[]; +}; +export type SkillSourceCandidate = SkillSourceMatch & { manual: boolean }; +export type SkillSourceEvidence = { localId: string; repo: string; kind: string; path: string }; +type ScanResult = { + status: string; message: string; previewId: string; matches: Omit[]; + manualCandidates?: Omit[]; warnings: string[]; +}; +type EvidenceResult = { status: string; message: string; evidence: Omit[] }; +type ScanRequest = (command: string, args: Record) => Promise; +export type SkillSourceScanSnapshot = { + status: "idle" | "scanning" | "paused" | "complete"; + repos: SkillRepository[]; + currentRepo: string; + candidates: SkillSourceCandidate[]; + warnings: string[]; + progress: number; + evidence: SkillSourceEvidence[]; + evidenceErrors: string[]; + evidenceScanning: boolean; +}; + +export const skillSourceRepoKey = (repo: SkillRepository) => + `${repo.owner}/${repo.name}@${repo.branch}${repo.subdir ? `:${repo.subdir}` : ""}`; + +export class SkillSourceScan { + private snapshot: SkillSourceScanSnapshot = { + status: "idle", repos: [], currentRepo: "", candidates: [], warnings: [], progress: 0, + evidence: [], evidenceErrors: [], evidenceScanning: false, + }; + private listeners = new Set<() => void>(); + private generation = 0; + private running = false; + private evidenceRunning = false; + private evidenceIds: string[] = []; + private evidenceProgress = 0; + private consumed = new Set(); + private request: ScanRequest; + private id?: string; + private localPath?: string; + + constructor(request: ScanRequest, id?: string, localPath?: string) { + this.request = request; + this.id = id; + this.localPath = localPath; + } + + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + getSnapshot = () => this.snapshot; + private publish(update: Partial) { + this.snapshot = { ...this.snapshot, ...update }; + for (const listener of this.listeners) listener(); + } + + ensure(repos: SkillRepository[], localIds: string[]) { + if (this.snapshot.status === "idle") { + this.restart(repos, localIds); + return; + } + for (const repo of repos) this.addRepo(repo); + } + + restart(repos: SkillRepository[], localIds: string[]) { + this.generation++; + this.evidenceIds = [...localIds]; + this.evidenceProgress = 0; + this.consumed.clear(); + this.publish({ + status: repos.length || localIds.length ? "scanning" : "complete", repos: [...repos], currentRepo: "", + candidates: [], warnings: [], progress: 0, evidence: [], evidenceErrors: [], + evidenceScanning: localIds.length > 0, + }); + this.pumpEvidence(); + this.pump(); + } + + addRepo(repo: SkillRepository) { + const key = skillSourceRepoKey(repo).toLowerCase(); + if (this.snapshot.repos.some((item) => skillSourceRepoKey(item).toLowerCase() === key)) return; + this.publish({ + repos: [...this.snapshot.repos, repo], + status: this.snapshot.status === "complete" ? "scanning" : this.snapshot.status, + }); + this.pump(); + } + + pause() { + if (this.snapshot.status === "scanning") this.publish({ status: "paused" }); + } + + resume() { + if (this.snapshot.status !== "paused") return; + this.publish({ status: "scanning" }); + this.pumpEvidence(); + this.pump(); + } + + consume(localIds: string[]) { + for (const id of localIds) this.consumed.add(id); + this.publish({ candidates: this.snapshot.candidates.filter((candidate) => !this.consumed.has(candidate.localId)) }); + } + + dispose() { + this.generation++; + this.publish({ status: "idle" }); + } + + private finishIfComplete() { + if (this.snapshot.status === "scanning" + && this.snapshot.progress >= this.snapshot.repos.length + && this.evidenceProgress >= this.evidenceIds.length) { + this.publish({ status: "complete" }); + } + } + + private pumpEvidence() { + if (this.evidenceRunning || this.snapshot.status !== "scanning") return; + if (this.evidenceProgress >= this.evidenceIds.length) { + if (this.snapshot.evidenceScanning) this.publish({ evidenceScanning: false }); + this.finishIfComplete(); + return; + } + this.evidenceRunning = true; + if (!this.snapshot.evidenceScanning) this.publish({ evidenceScanning: true }); + const generation = this.generation; + void (async () => { + while (generation === this.generation && this.snapshot.status === "scanning" + && this.evidenceProgress < this.evidenceIds.length) { + const localId = this.evidenceIds[this.evidenceProgress]; + try { + const result = await this.request("discover_skill_source_evidence", { + id: localId, localPath: this.localPath || null, + }) as EvidenceResult; + if (generation !== this.generation) return; + if (result.status === "ok") this.publish({ + evidence: [...this.snapshot.evidence, ...result.evidence.map((item) => ({ ...item, localId }))], + }); + else this.publish({ evidenceErrors: [...this.snapshot.evidenceErrors, `${localId}:${result.message}`] }); + } catch (error) { + if (generation !== this.generation) return; + this.publish({ evidenceErrors: [...this.snapshot.evidenceErrors, `${localId}:${String(error)}`] }); + } + this.evidenceProgress++; + } + if (generation === this.generation) { + this.publish({ evidenceScanning: false }); + this.finishIfComplete(); + } + })().finally(() => { + this.evidenceRunning = false; + if (this.snapshot.status === "scanning") this.pumpEvidence(); + else if (this.snapshot.evidenceScanning) this.publish({ evidenceScanning: false }); + }); + } + + private pump() { + if (this.running || this.snapshot.status !== "scanning") return; + if (this.snapshot.progress >= this.snapshot.repos.length) { + this.finishIfComplete(); + return; + } + this.running = true; + const generation = this.generation; + void (async () => { + while (generation === this.generation && this.snapshot.status === "scanning" + && this.snapshot.progress < this.snapshot.repos.length) { + const repo = this.snapshot.repos[this.snapshot.progress]; + const repoKey = skillSourceRepoKey(repo); + this.publish({ currentRepo: repoKey }); + try { + const result = await this.request("find_skill_repository_matches", { + repoKey, id: this.id || null, localPath: this.localPath || null, + }) as ScanResult; + if (generation !== this.generation) return; + if (result.status !== "ok") this.publish({ warnings: [...this.snapshot.warnings, `${repoKey}:${result.message}`] }); + else { + const matches = result.matches.map((match) => ({ ...match, previewId: result.previewId, manual: false })); + const manual = this.id ? (result.manualCandidates || []).filter((match) => match.localId === this.id) + .map((match) => ({ ...match, previewId: result.previewId, manual: true })) : []; + this.publish({ + candidates: [...this.snapshot.candidates, ...[...matches, ...manual] + .filter((candidate) => !this.consumed.has(candidate.localId))], + warnings: [...this.snapshot.warnings, ...result.warnings], + }); + } + } catch (error) { + if (generation !== this.generation) return; + this.publish({ warnings: [...this.snapshot.warnings, `${repoKey}:${String(error)}`] }); + } + this.publish({ currentRepo: "", progress: this.snapshot.progress + 1 }); + } + if (generation === this.generation) this.finishIfComplete(); + })().finally(() => { + this.running = false; + if (this.snapshot.status === "scanning") this.pump(); + }); + } +} + +const scans = new Map(); +export function getSkillSourceScan(request: ScanRequest, id?: string, localPath?: string) { + const key = JSON.stringify([id || "", localPath || ""]); + let scan = scans.get(key); + if (!scan) { + scan = new SkillSourceScan(request, id, localPath); + scans.set(key, scan); + } + return scan; +} + +export function discardSkillSourceScan(scan: SkillSourceScan, id?: string, localPath?: string) { + const key = JSON.stringify([id || "", localPath || ""]); + if (scans.get(key) === scan) { + scans.delete(key); + scan.dispose(); + } +} diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 83cc5d5629..7e2896bcab 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -23,6 +23,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; +mod source_match; + +pub use source_match::{SkillRepositoryMatchApplied, SkillRepositoryMatchApply, SkillRepositoryMatchPreview, SkillRepositoryMatchSelection, SkillSourceEvidence}; + mod archive; /// 在锁外检查过的 Agent 副本;提交只核验路径身份与代次,不再重读内容。 @@ -81,7 +85,7 @@ impl SkillRepo { } /// 已安装 skill 的记录。`content_hash` 是安装当时的远端子树哈希,用来比对更新。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstalledSkill { pub id: String, @@ -97,6 +101,14 @@ pub struct InstalledSkill { pub content_hash: String, #[serde(default)] pub installed_at: String, + #[serde(default)] + pub verified_cc_switch: bool, + #[serde(default)] + pub install_channel: String, + #[serde(default)] + pub original_dir: Option, + #[serde(default)] + pub update_owner: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -106,20 +118,49 @@ pub struct SkillsState { pub repos: Vec, #[serde(default)] pub installed: BTreeMap, + #[serde(default)] + instance_targets: BTreeMap, + #[serde(default)] + instance_records: BTreeMap>, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub shared_agent_paths: BTreeMap, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SkillInstanceRecord { + installed: InstalledSkill, + target: SkillInstanceTarget, + #[serde(default)] + managed_copies: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SkillInstanceTarget { + path: PathBuf, + identity: SkillPathIdentity, +} + impl Default for SkillsState { fn default() -> Self { Self { repos: default_skill_repos(), installed: BTreeMap::new(), + instance_targets: BTreeMap::new(), + instance_records: BTreeMap::new(), shared_agent_paths: BTreeMap::new(), } } } +impl SkillsState { + fn instance_record(&self, id: &str, path: &Path) -> Option<&SkillInstanceRecord> { + self.instance_records.get(id)?.values().find(|record| { + record.target.path.canonicalize().ok().as_deref() == Some(path) + && skill_path_identity(&record.target.path).ok() == Some(record.target.identity) + }) + } +} + /// 仓库 key 变化但 repo_path 命名空间不变(例如只切分支)时,把已安装记录迁到新 key。 /// 安装记录保留首次安装的 legacy id(源目录、软链都不用动),只需更新 repo_key, /// 之后合并视图和更新守卫都能按新 key 匹配。 @@ -129,6 +170,13 @@ fn migrate_installed_repo_key(state: &mut SkillsState, old_key: &str, new_key: & record.repo_key = new_key.to_string(); } } + for records in state.instance_records.values_mut() { + for record in records.values_mut() { + if record.installed.repo_key == old_key { + record.installed.repo_key = new_key.to_string(); + } + } + } } /// 首次启动种下的仓库源。第一个是 codex 官方 curated 列表(内置 skill-installer @@ -180,14 +228,19 @@ pub struct RemoteSkill { } /// 给前端的合并视图:远端清单 + 本地安装状态 + 启用状态。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillEntry { pub id: String, + pub instance_key: String, + pub conflict_kind: Option, + pub safe_disable_agents: Vec, + pub safe_toggle_agents: Vec, pub name: String, pub description: String, pub repo_key: String, pub repo_path: String, + pub display_group_repo_key: Option, pub installed: bool, pub enabled: bool, /// codex 自带的 `.system` skill,只读展示,不能安装/卸载。 @@ -199,11 +252,18 @@ pub struct SkillEntry { pub agents: Vec, /// Codex++ 的受控源目录里有它(安装或导入过);只在某个 Agent 自己目录里的 Skill 为 false。 pub managed: bool, + pub managed_source_dir: Option, + pub managed_source_status: Option, + pub managed_source_error: Option, pub locations: Vec, pub conflict: Option, + pub source_verified: bool, + pub install_channel: String, + pub original_dir: Option, + pub update_owner: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillLocation { pub manifest_path: String, @@ -211,6 +271,15 @@ pub struct SkillLocation { pub managed: bool, pub enabled: bool, pub status_error: Option, + pub original_path: Option, + pub association_path: Option, + pub selectable: bool, + pub repo_key: String, + pub repo_path: String, + pub content_hash: String, + pub remote_hash: String, + pub update_available: bool, + pub update_owner: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -993,6 +1062,7 @@ impl SkillsManager { "同名 Skill 安装内容不同,请先选择要启用的版本"); } let (staging, mut installed) = self.stage_directory_import(id, candidate, LOCAL_IMPORT_REPO_KEY)?; + installed.update_owner.clear(); let result = (|| -> anyhow::Result<()> { anyhow::ensure!(skill_path_identity(candidate)? == identity && skill_directory_digest(candidate)? == Some(digest) @@ -1011,6 +1081,26 @@ impl SkillsManager { &self.source_dir } + fn unclaimed_managed_source(&self, id: &str, state: &SkillsState) -> Option { + let source = self.source_dir.join(id); + if !state.installed.get(id).is_some_and(|installed| installed.original_dir.is_none()) + || state.instance_targets.contains_key(id) + || !source.join(SKILL_MANIFEST_FILE).is_file() + || !std::fs::symlink_metadata(&source).is_ok_and(|metadata| metadata.file_type().is_dir()) + { + return None; + } + let canonical = source.canonicalize().ok()?; + let belongs_to_source = |target: &SkillInstanceTarget| target.path == source + || target.path.canonicalize().ok().as_ref() == Some(&canonical); + if state.instance_records.get(id).is_some_and(|records| records.values().any(|record| + belongs_to_source(&record.target) || record.managed_copies.iter().any(&belongs_to_source))) + { + return None; + } + Some(source) + } + /// codex 扫描的 skill 目录。启用一个 skill 就是往这里放一个指向源目录的软链。 pub fn linked_dir(&self) -> PathBuf { self.codex_home.join("skills") @@ -1250,6 +1340,13 @@ impl SkillsManager { let mut entries: BTreeMap<(String, String), SkillEntry> = BTreeMap::new(); for skill in remote { + if state.instance_records.values().flat_map(|records| records.values()).any(|record| { + record.installed.repo_key == skill.repo_key && record.installed.repo_path == skill.repo_path + && state.installed.get(&record.installed.id).is_some_and(|primary| + primary.repo_key != skill.repo_key || primary.repo_path != skill.repo_path) + }) { + continue; + } let installed = state.installed.values().find(|item| item.repo_key == skill.repo_key && item.repo_path == skill.repo_path); let id = installed.map(|item| item.id.clone()).unwrap_or_else(|| skill.id.clone()); @@ -1257,10 +1354,16 @@ impl SkillsManager { (skill.repo_key.clone(), id.clone()), SkillEntry { id: id.clone(), + instance_key: id.clone(), + conflict: None, + conflict_kind: None, + safe_disable_agents: Vec::new(), + safe_toggle_agents: Vec::new(), name: skill.name.clone(), description: skill.description.clone(), repo_key: skill.repo_key.clone(), repo_path: skill.repo_path.clone(), + display_group_repo_key: None, installed: installed.is_some(), enabled: installed.is_some() && is_linked(&linked.join(&id)), bundled: false, @@ -1277,8 +1380,14 @@ impl SkillsManager { .unwrap_or(false), agents: Vec::new(), managed: false, + managed_source_dir: None, + managed_source_status: None, + managed_source_error: None, locations: Vec::new(), - conflict: None, + source_verified: installed.is_some_and(|item| item.verified_cc_switch), + install_channel: installed.map(|item| item.install_channel.clone()).unwrap_or_default(), + original_dir: installed.and_then(|item| item.original_dir.clone()), + update_owner: installed.map(|item| item.update_owner.clone()).unwrap_or_default(), }, ); } @@ -1287,6 +1396,11 @@ impl SkillsManager { for (id, installed) in &state.installed { entries.entry((installed.repo_key.clone(), id.clone())).or_insert_with(|| SkillEntry { id: id.clone(), + instance_key: id.clone(), + conflict: None, + conflict_kind: None, + safe_disable_agents: Vec::new(), + safe_toggle_agents: Vec::new(), name: if installed.name.is_empty() { id.clone() } else { @@ -1295,6 +1409,7 @@ impl SkillsManager { description: installed.description.clone(), repo_key: installed.repo_key.clone(), repo_path: installed.repo_path.clone(), + display_group_repo_key: None, installed: true, enabled: is_linked(&linked.join(id)), bundled: false, @@ -1303,8 +1418,14 @@ impl SkillsManager { update_available: false, agents: Vec::new(), managed: false, + managed_source_dir: None, + managed_source_status: None, + managed_source_error: None, locations: Vec::new(), - conflict: None, + source_verified: installed.verified_cc_switch, + install_channel: installed.install_channel.clone(), + original_dir: installed.original_dir.clone(), + update_owner: installed.update_owner.clone(), }); } @@ -1324,9 +1445,17 @@ impl SkillsManager { 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, + id: id.clone(), + instance_key: id.clone(), + conflict: None, + conflict_kind: None, + safe_disable_agents: Vec::new(), + safe_toggle_agents: Vec::new(), + name, + description, repo_key: String::new(), repo_path: String::new(), + display_group_repo_key: None, installed: true, enabled: false, bundled: false, @@ -1335,8 +1464,14 @@ impl SkillsManager { update_available: false, agents: Vec::new(), managed: false, + managed_source_dir: None, + managed_source_status: None, + managed_source_error: None, locations: Vec::new(), - conflict: None, + source_verified: false, + install_channel: "local".into(), + original_dir: Some(root.join(&id)), + update_owner: String::new(), }); } } @@ -1349,10 +1484,16 @@ impl SkillsManager { let (name, description) = skill.1; entries.insert((String::new(), skill.0.clone()), SkillEntry { id: skill.0.clone(), + instance_key: skill.0.clone(), + conflict: None, + conflict_kind: None, + safe_disable_agents: Vec::new(), + safe_toggle_agents: Vec::new(), name, description, repo_key: String::new(), repo_path: String::new(), + display_group_repo_key: None, installed: true, enabled: false, bundled: false, @@ -1361,8 +1502,14 @@ impl SkillsManager { update_available: false, agents: vec![agent.clone()], managed: false, + managed_source_dir: None, + managed_source_status: None, + managed_source_error: None, locations: Vec::new(), - conflict: None, + source_verified: false, + install_channel: "local".into(), + original_dir: Some(dir.join(&skill.0)), + update_owner: String::new(), }); } } @@ -1374,13 +1521,17 @@ impl SkillsManager { continue; } entry.managed = self.source_dir.join(&entry.id).is_dir(); + let source = self.source_dir.join(&entry.id); + entry.managed_source_dir = source.join(SKILL_MANIFEST_FILE).is_file().then_some(source); + entry.managed_source_status = entry.managed_source_dir.as_ref().map(|_| "stored".into()); + let managed_association_path = self.unclaimed_managed_source(&entry.id, &state); let mut agents = Vec::new(); - let codex = if entry.bundled { + let codex_path = 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)]; + let mut roots = vec![(if entry.bundled { "system" } else { "codex" }, codex_path)]; if !entry.bundled { if let Some(shared) = shared_root { roots.push(("shared", shared.join(&entry.id))); @@ -1395,15 +1546,32 @@ impl SkillsManager { Ok(enabled) => (enabled, None), Err(error) => (false, Some(format!("{error:#}"))), }; + let resolved = path.canonicalize().ok(); let managed = root_kind == "codex" && (is_managed_copy(&path) - || path.canonicalize().ok().zip(self.source_dir.join(&entry.id).canonicalize().ok()) + || resolved.clone().zip(self.source_dir.join(&entry.id).canonicalize().ok()) .is_some_and(|(actual, source)| actual == source)); + let original_path = resolved.filter(|target| root_kind != "system" + && (state.instance_targets.get(&entry.id).is_some_and(|recorded| + recorded.path.canonicalize().ok().as_ref() == Some(target) + && skill_path_identity(&recorded.path).ok() == Some(recorded.identity)) + || !managed && !is_managed_copy(target)) + && !self.source_dir.canonicalize().ok().is_some_and(|source| target.starts_with(source)) + && std::fs::symlink_metadata(target).is_ok_and(|metadata| metadata.file_type().is_dir())); entry.locations.push(SkillLocation { manifest_path: manifest.to_string_lossy().into_owned(), root_kind: root_kind.to_string(), managed, enabled, status_error, + selectable: original_path.is_some(), + association_path: managed.then(|| managed_association_path.clone()).flatten(), + original_path, + repo_key: String::new(), + repo_path: String::new(), + content_hash: String::new(), + remote_hash: String::new(), + update_available: false, + update_owner: String::new(), }); } entry.enabled = entry.locations.iter().any(|location| location.enabled); @@ -1416,45 +1584,388 @@ 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_paths + let resolved = path.canonicalize().ok(); + let shared_target = shared_root.and_then(|shared| shared.join(&entry.id).canonicalize().ok()); + let managed_shared = 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]) - && 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); + && shared_target.is_some() + && (is_managed_copy(&path) || resolved == shared_target)); + let managed = is_managed_copy(&path) || managed_shared || resolved.clone() + .zip(self.source_dir.join(&entry.id).canonicalize().ok()) + .is_some_and(|(actual, source)| actual == source); + let original_path = resolved.clone().filter(|target| !managed && !is_managed_copy(target) + && !self.source_dir.canonicalize().ok().is_some_and(|source| target.starts_with(source)) + && std::fs::symlink_metadata(target).is_ok_and(|metadata| metadata.file_type().is_dir())); entry.locations.push(SkillLocation { manifest_path: manifest.to_string_lossy().into_owned(), root_kind: agent.clone(), managed, enabled: true, status_error: None, + selectable: original_path.is_some(), + association_path: if managed_shared { shared_target } + else { managed.then(|| managed_association_path.clone()).flatten() }, + original_path, + repo_key: String::new(), + repo_path: String::new(), + content_hash: String::new(), + remote_hash: String::new(), + update_available: false, + update_owner: String::new(), }); } } - if let (Some(shared), Some(codex)) = ( - 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)), - ) { - 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:#}")), + } + let original_paths = entry.locations.iter().filter_map(|location| location.original_path.clone()) + .collect::>(); + for location in &mut entry.locations { + if location.managed && location.association_path.is_none() { + let path = Path::new(&location.manifest_path).parent().unwrap().canonicalize().ok(); + location.association_path = state.instance_records.get(&entry.id).into_iter() + .flat_map(|records| records.values()).find_map(|record| { + let original = record.target.path.canonicalize().ok()?; + (original_paths.contains(&original) + && skill_path_identity(&record.target.path).ok() == Some(record.target.identity) + && record.managed_copies.iter().any(|copy| + copy.path.canonicalize().ok() == path + && skill_path_identity(©.path).ok() == Some(copy.identity))) + .then_some(original) + }); + } + let Some(path) = location.original_path.as_deref().or(location.association_path.as_deref()) else { continue }; + let installed = state.instance_record(&entry.id, path).map(|record| &record.installed) + .or_else(|| state.installed.get(&entry.id).filter(|installed| { + installed.original_dir.as_ref().and_then(|original| original.canonicalize().ok()).as_deref() == Some(path) + && state.instance_targets.get(&entry.id).is_none_or(|target| + target.path.canonicalize().ok().as_deref() == Some(path) + && skill_path_identity(&target.path).ok() == Some(target.identity)) + })); + if let Some(installed) = installed { + location.repo_key = installed.repo_key.clone(); + location.repo_path = installed.repo_path.clone(); + location.content_hash = installed.content_hash.clone(); + location.update_owner = installed.update_owner.clone(); + if let Some(candidate) = remote.iter().find(|skill| skill.repo_key == location.repo_key + && skill.repo_path == location.repo_path) + { + location.remote_hash = candidate.content_hash.clone(); + location.update_available = !location.content_hash.is_empty() + && !location.remote_hash.is_empty() + && location.content_hash != location.remote_hash; } } } entry.agents = agents; } - Ok(entries) + let mut visible = Vec::new(); + for mut entry in entries { + if let Some(target) = state.instance_targets.get(&entry.id) + .filter(|_| entry.installed && !entry.bundled) + { + let canonical = target.path.canonicalize().ok(); + let discovered = entry.locations.iter().any(|location| { + canonical.as_ref().is_some_and(|path| location.original_path.as_ref() == Some(path)) + }); + if !discovered || skill_path_identity(&target.path).ok() != Some(target.identity) { + entry.repo_key.clear(); + entry.repo_path.clear(); + entry.content_hash.clear(); + entry.remote_hash.clear(); + entry.update_available = false; + entry.source_verified = false; + entry.install_channel = "local".into(); + entry.original_dir = None; + entry.update_owner.clear(); + } + } + if entry.bundled || entry.locations.is_empty() { + visible.push(entry); + continue; + } + if entry.locations.len() == 1 && entry.managed_source_dir.is_none() + && !state.installed.contains_key(&entry.id) + && !state.instance_records.contains_key(&entry.id) + { + if let Some(path) = &entry.locations[0].original_path { + entry.instance_key = format!("{}:{}", entry.id, path.display()); + entry.original_dir = Some(path.clone()); + } + visible.push(entry); + continue; + } + let mut groups = BTreeMap::>::new(); + let mut hashes = BTreeSet::new(); + let mut path_hashes = BTreeMap::new(); + let mut originals = BTreeSet::new(); + let mut comparison_error = None; + for location in &entry.locations { + let path = Path::new(&location.manifest_path).parent().context("Skill 路径没有父目录")?; + let resolved = path.canonicalize()?; + match skill_directory_digest(&resolved) { + Ok(Some(digest)) => { + hashes.insert(digest); + path_hashes.insert(resolved.clone(), digest); + } + Ok(None) => comparison_error = Some(format!("Skill 目录已不存在:{}", resolved.display())), + Err(error) => comparison_error = Some(format!("{error:#}")), + } + if !location.managed || location.selectable { + originals.insert(resolved.clone()); + } + groups.entry(resolved).or_default().push(location.clone()); + } + if comparison_error.is_none() { + let copies = groups.iter().filter_map(|(path, locations)| { + let original = locations.iter().find_map(|location| location.association_path.as_ref())?; + (original != path && groups.contains_key(original) + && path_hashes.get(path) == path_hashes.get(original)) + .then_some((path.clone(), original.clone())) + }).collect::>(); + for (copy, original) in copies { + let locations = groups.remove(©).unwrap(); + groups.get_mut(&original).unwrap().extend(locations); + } + } + let source = self.source_dir.join(&entry.id); + let mut source_hash = None; + if source.join(SKILL_MANIFEST_FILE).is_file() { + match skill_directory_digest(&source) { + Ok(Some(digest)) => { + source_hash = Some(digest); + entry.managed_source_status = Some(if source.canonicalize() + .is_ok_and(|path| path_hashes.contains_key(&path)) + { + "referenced" + } else if comparison_error.is_some() { + "unknown" + } else if hashes.len() == 1 && hashes.contains(&digest) { + "matching" + } else { + "different" + }.into()); + if comparison_error.is_some() { + entry.managed_source_error = comparison_error.clone(); + } + } + Ok(None) => { + entry.managed_source_status = Some("unknown".into()); + entry.managed_source_error = Some(format!("Skill 受控源已不存在:{}", source.display())); + } + Err(error) => { + entry.managed_source_status = Some("unknown".into()); + entry.managed_source_error = Some(format!("{error:#}")); + } + } + } + let sources: BTreeSet<_> = entry.locations.iter() + .filter(|location| !location.repo_key.is_empty() && !location.repo_path.is_empty()) + .map(|location| (location.repo_key.clone(), location.repo_path.clone())) + .collect(); + let mut group_sources = BTreeMap::<[u8; 32], BTreeSet<(String, String)>>::new(); + if comparison_error.is_none() { + for location in &entry.locations { + if parse_repo_key(&location.repo_key).is_none() || location.repo_path.is_empty() { + continue; + } + if let Some(digest) = location.original_path.as_ref().and_then(|path| path_hashes.get(path)) { + group_sources.entry(*digest).or_default() + .insert((location.repo_key.clone(), location.repo_path.clone())); + } + } + if let Some((digest, installed)) = source_hash.zip(state.installed.get(&entry.id)) + .filter(|(_, installed)| parse_repo_key(&installed.repo_key).is_some() + && !installed.repo_path.is_empty() + && self.unclaimed_managed_source(&entry.id, &state).is_some()) + { + group_sources.entry(digest).or_default() + .insert((installed.repo_key.clone(), installed.repo_path.clone())); + } + } + let associated_originals = originals.len() > 1 && sources.len() == 1 + && entry.locations.iter().filter(|location| !location.managed || location.selectable) + .all(|location| parse_repo_key(&location.repo_key).is_some() + && !location.repo_path.is_empty()); + let mut distinct_owners = BTreeSet::new(); + let separate_agents = originals.len() > 1 && groups.len() > 1 && groups.values().all(|locations| { + locations.iter().map(|location| match location.root_kind.as_str() { + "shared" => "codex", + agent => agent, + }).collect::>().into_iter().all(|agent| distinct_owners.insert(agent)) + }); + if comparison_error.is_none() && hashes.len() <= 1 && sources.len() <= 1 + && !associated_originals && !separate_agents + { + if let Some(path) = originals.iter().next().or_else(|| groups.keys().next()) { + entry.instance_key = format!("{}:{}", entry.id, path.display()); + } + if originals.len() > 1 { + entry.conflict = Some("存在多个独立安装,当前内容相同;来源和更新需分别确认".into()); + entry.conflict_kind = Some("equal-independent".into()); + entry.repo_key.clear(); + entry.repo_path.clear(); + entry.content_hash.clear(); + entry.remote_hash.clear(); + entry.update_available = false; + entry.source_verified = false; + entry.install_channel = "local".into(); + entry.original_dir = None; + entry.update_owner.clear(); + entry.display_group_repo_key = hashes.iter().next().and_then(|digest| + group_sources.get(digest).filter(|sources| sources.len() == 1)) + .and_then(|sources| sources.iter().next().map(|(repo, _)| repo.clone())); + } + visible.push(entry); + continue; + } + let known_original = state.installed.get(&entry.id) + .and_then(|item| item.original_dir.as_ref()) + .and_then(|path| path.canonicalize().ok()); + let independent = if comparison_error.is_some() { + true + } else { + let mut independent_hashes = BTreeSet::new(); + for original in &originals { + independent_hashes.insert(*path_hashes.get(original).context("Skill 目录已不存在")?); + } + if !originals.is_empty() && !originals.iter().all(|path| Some(path) == known_original.as_ref()) + { + independent_hashes.extend(path_hashes.iter() + .filter(|(path, _)| !originals.contains(*path)) + .map(|(_, digest)| *digest)); + } + originals.len() > 1 || independent_hashes.len() > 1 + }; + let mut instances = Vec::new(); + let mut managed_copy_changed = false; + for (path, locations) in groups { + let mut instance = entry.clone(); + instance.instance_key = format!("{}:{}", instance.id, path.display()); + instance.conflict = Some(comparison_error.as_ref().map_or_else( + || "存在同名但内容或来源不同的 Skill,请分别处理".into(), + |error| format!("无法安全比较 Skill 内容:{error}"), + )); + instance.conflict_kind = Some(if independent { "independent" } else { "managed-modified" }.into()); + let (name, description) = read_skill_manifest(&path.join(SKILL_MANIFEST_FILE), &instance.id); + instance.name = name; + instance.description = description; + instance.locations = locations; + instance.agents = instance.locations.iter().filter_map(|location| match location.root_kind.as_str() { + "codex" | "shared" if location.enabled => Some("codex".to_string()), + "codex" | "shared" => None, + agent => Some(agent.to_string()), + }).collect::>().into_iter().collect(); + instance.enabled = instance.agents.iter().any(|agent| agent == "codex"); + let managed_instance = instance.locations.iter().all(|location| location.managed) + || source.canonicalize().ok().as_ref() == Some(&path); + if managed_instance && (source_hash != path_hashes.get(&path).copied() + || instance.locations.iter().filter_map(|location| location.association_path.as_ref()) + .any(|original| path_hashes.get(original) + .is_some_and(|digest| Some(digest) != path_hashes.get(&path)))) + { + managed_copy_changed = true; + } + instance.managed = managed_instance || instance.locations.iter().any(|location| location.managed); + if !independent { + instance.safe_disable_agents = instance.locations.iter() + .filter(|location| location.managed && location.enabled + && (location.root_kind != "codex" || self.shared_skills_dir() + .is_none_or(|root| !root.join(&instance.id).join(SKILL_MANIFEST_FILE).is_file()))) + .filter_map(|location| match location.root_kind.as_str() { + "shared" | "system" => None, + agent => Some(agent.to_string()), + }) + .collect(); + if self.shared_skills_dir() + .is_none_or(|root| !root.join(&instance.id).join(SKILL_MANIFEST_FILE).is_file()) + && instance.locations.iter().any(|location| { + location.root_kind == "codex" && location.managed + && location.status_error.is_none() + && std::fs::symlink_metadata(self.linked_dir().join(&instance.id)) + .is_ok_and(|metadata| metadata.file_type().is_dir() + && !metadata_is_link(&metadata)) + }) + && self.ensure_skill_name_not_aliased(&instance.id, &state).is_ok() + { + instance.safe_toggle_agents.push("codex".into()); + } + } + let record = state.instance_record(&entry.id, &path).or_else(|| instance.locations.iter() + .filter_map(|location| location.association_path.as_deref()) + .find_map(|original| state.instance_record(&entry.id, original))); + // 旧安装记录没有原目录和实例索引;仅受控源及其副本可继承该记录。 + let legacy_managed = record.is_none() + && state.installed.get(&entry.id).is_some_and(|installed| installed.original_dir.is_none()) + && !state.instance_targets.contains_key(&entry.id) + && source.join(SKILL_MANIFEST_FILE).is_file() + && managed_instance; + let associated = record.is_some() || state.instance_targets.get(&entry.id).is_some_and(|target| { + target.path.canonicalize().ok().as_ref() == Some(&path) + && skill_path_identity(&target.path).ok() == Some(target.identity) + }) || legacy_managed; + if let Some(record) = record { + instance.repo_key = record.installed.repo_key.clone(); + instance.repo_path = record.installed.repo_path.clone(); + instance.content_hash = record.installed.content_hash.clone(); + instance.install_channel = record.installed.install_channel.clone(); + instance.update_owner = record.installed.update_owner.clone(); + instance.source_verified = record.installed.verified_cc_switch; + instance.remote_hash = remote.iter().find(|skill| skill.repo_key == instance.repo_key + && skill.repo_path == instance.repo_path) + .map(|skill| skill.content_hash.clone()).unwrap_or_default(); + instance.update_available = !instance.content_hash.is_empty() && !instance.remote_hash.is_empty() + && instance.content_hash != instance.remote_hash; + } + if !associated { + instance.repo_key.clear(); + instance.repo_path.clear(); + instance.content_hash.clear(); + instance.remote_hash.clear(); + instance.update_available = false; + instance.source_verified = false; + instance.install_channel = "local".into(); + instance.update_owner.clear(); + instance.display_group_repo_key = path_hashes.get(&path).and_then(|digest| + group_sources.get(digest).filter(|sources| sources.len() == 1)) + .and_then(|sources| sources.iter().next().map(|(repo, _)| repo.clone())); + } else if legacy_managed && instance.update_owner.is_empty() && instance.install_channel.is_empty() { + instance.update_owner = "manager".into(); + } + instance.original_dir = (!managed_instance).then_some(path); + instances.push(instance); + } + let mut owners = BTreeSet::new(); + let repositories = instances.iter() + .filter(|instance| parse_repo_key(&instance.repo_key).is_some()) + .map(|instance| instance.repo_key.as_str()) + .collect::>(); + if independent && comparison_error.is_none() && !managed_copy_changed + && instances.len() > 1 + && repositories.len() <= 1 + && instances.iter().all(|instance| { + instance.locations.iter().map(|location| match location.root_kind.as_str() { + "shared" => "codex", + agent => agent, + }).collect::>().into_iter().all(|agent| owners.insert(agent)) + }) + { + for instance in &mut instances { + instance.conflict = None; + instance.conflict_kind = None; + if instance.locations.iter().any(|location| { + location.root_kind == "codex" && location.status_error.is_none() + && std::fs::symlink_metadata(self.linked_dir().join(&instance.id)) + .is_ok_and(|metadata| metadata.file_type().is_dir() + && !metadata_is_link(&metadata)) + }) && self.ensure_skill_name_not_aliased(&instance.id, &state).is_ok() { + instance.safe_toggle_agents.push("codex".into()); + } + } + } + visible.extend(instances); + } + Ok(visible) } /// 本地目录 inventory 不混入仅出现在远端/缓存清单中的未安装条目,也不写 TOML。 @@ -1494,6 +2005,7 @@ impl SkillsManager { let (name, description) = read_skill_manifest(&manifest, id); skills.push(SkillEntry { id: id.to_string(), + instance_key: id.to_string(), name, description, repo_key: String::new(), @@ -1508,6 +2020,7 @@ impl SkillsManager { managed: false, locations: Vec::new(), conflict: None, + ..Default::default() }); } skills @@ -1605,6 +2118,10 @@ impl SkillsManager { id: id.to_string(), name, description, repo_key: repo_key.to_string(), repo_path: String::new(), content_hash: String::new(), installed_at: current_unix_timestamp_string(), + verified_cc_switch: repo_key == "cc-switch", + install_channel: if repo_key == "cc-switch" { "cc-switch" } else { "local" }.into(), + original_dir: Some(external_dir.to_path_buf()), + update_owner: "manager".into(), })) } @@ -1807,6 +2324,7 @@ impl SkillsManager { let (name, description) = read_skill_manifest(&manifest, id); skills.push(SkillEntry { id: id.to_string(), + instance_key: id.to_string(), name, description, repo_key: String::new(), @@ -1821,6 +2339,7 @@ impl SkillsManager { managed: false, locations: Vec::new(), conflict: None, + ..Default::default() }); } skills @@ -1858,6 +2377,24 @@ impl SkillsManager { } _ => {} } + if let Some(record) = installed.as_ref().filter(|_| !install_only) { + let source = self.source_dir.join(id).canonicalize().ok(); + let original = record.original_dir.as_ref().and_then(|path| path.canonicalize().ok()); + let mut roots = vec![self.linked_dir()]; + roots.extend(self.shared_skills_dir().map(Path::to_path_buf)); + roots.extend(self.agent_dirs.iter().map(|(_, root)| root.clone())); + if record.update_owner == "in_place" + || original.as_ref().is_some_and(|target| { + Some(target) != source.as_ref() + && roots.iter().any(|root| { + root.join(id).canonicalize().is_ok_and(|path| &path == target) + }) + }) + || (record.update_owner.is_empty() && !record.install_channel.is_empty()) + { + anyhow::bail!("此 Skill 的来源已关联,但未授权使用此流程更新"); + } + } let scope = self.coordination_scope(); let mut guard = SkillDownloadGuard { installed: installed.clone(), @@ -2016,6 +2553,9 @@ impl SkillsManager { repo_path: skill.repo_path.clone(), content_hash: skill.content_hash.clone(), installed_at: current_unix_timestamp_string(), + install_channel: "github".into(), + update_owner: "manager".into(), + ..Default::default() }, enabled, true, Some(&agent_snapshot), guard.install_only, &mut coordination)?; // 安装记录保留 legacy ID,返回给目录缓存的必须是可用于新安装的稳定 ID。 let mut catalog_skill = skill; @@ -2062,6 +2602,9 @@ impl SkillsManager { repo_path: skill.repo_path.clone(), content_hash: skill.content_hash.clone(), installed_at: current_unix_timestamp_string(), + install_channel: "github".into(), + update_owner: "manager".into(), + ..Default::default() }, enable_after_install, true, Some(&agent_snapshot), false, &mut coordination) })(); result.map_err(|error| retain_staging_context(error, &staging)) @@ -2146,6 +2689,7 @@ impl SkillsManager { repo_path: String::new(), content_hash: String::new(), installed_at: current_unix_timestamp_string(), + ..Default::default() }); self.commit_staged_skill_unlocked( &staging, installed, false, contents_changed, None, false, coordination, @@ -2365,6 +2909,7 @@ impl SkillsManager { repo_path: String::new(), content_hash: String::new(), installed_at: current_unix_timestamp_string(), + ..Default::default() }, true, true, @@ -3718,7 +4263,7 @@ fn skill_directory_digest(path: &Path) -> anyhow::Result> { Ok(Some(hasher.finalize().into())) } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] struct SkillPathIdentity { device: u64, file: u64, @@ -4167,16 +4712,17 @@ mod tests { 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())); + assert_eq!(found.len(), 2); + assert!(found.iter().all(|item| item.conflict.is_none())); + assert!(found.iter().any(|item| item.agents.contains(&"codex".to_string()))); + assert!(found.iter().any(|item| item.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); + let current = manager.try_local_inventory().unwrap(); + let codex = current.iter().find(|item| item.id == "alpha" + && item.locations.iter().any(|location| location.root_kind == "shared")).unwrap(); + assert_eq!(codex.enabled, enabled); assert!(shared.join("alpha/SKILL.md").is_file()); assert!(!manager.linked_dir().join("alpha").exists()); assert!(!manager.source_dir.join("alpha").exists()); @@ -4253,6 +4799,25 @@ mod tests { assert_eq!(std::fs::read_to_string(copy.join("SKILL.md")).unwrap(), "updated"); } + #[test] + fn shared_agent_copy_associates_with_the_shared_original() { + 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---\n"); + 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(); + let entries = manager.try_local_inventory().unwrap(); + let found = entries.iter().filter(|entry| entry.id == "alpha").collect::>(); + assert_eq!(found.len(), 1); + assert!(found[0].conflict.is_none()); + let copy = found[0].locations.iter().find(|location| location.root_kind == "claude").unwrap(); + assert!(copy.managed); + assert_eq!(copy.association_path.as_deref(), Some(shared.join("alpha").canonicalize().unwrap().as_path())); + } + #[test] fn shared_and_codex_conflict_compares_complete_contents_and_preserves_both() { let temp = tempfile::tempdir().unwrap(); @@ -4274,6 +4839,21 @@ mod tests { assert_eq!(std::fs::read_to_string(codex.join("script.js")).unwrap(), "two"); } + #[test] + fn identical_installations_for_different_agents_are_not_conflicts() { + 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---\nsame"); + write_skill(&temp.path().join("claude-skills/alpha"), "---\nname: alpha\n---\nsame"); + let entries = manager.try_local_inventory().unwrap(); + let found = entries.iter().filter(|item| item.id == "alpha").collect::>(); + assert_eq!(found.len(), 2); + assert!(found.iter().all(|item| item.conflict.is_none())); + assert!(found.iter().any(|item| item.agents.contains(&"codex".to_string()))); + assert!(found.iter().any(|item| item.agents.contains(&"claude".to_string()))); + } + #[test] fn native_rules_use_last_match_and_preserve_other_configuration() { let temp = tempfile::tempdir().unwrap(); @@ -4439,7 +5019,8 @@ mod tests { 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(), + installed_at: String::new(), verified_cc_switch: false, install_channel: String::new(), + original_dir: None, update_owner: String::new(), }; let mut coordination = skills_coordination(); assert!(manager.commit_staged_skill_unlocked( @@ -5916,6 +6497,7 @@ mod tests { repo_path: "alpha".into(), content_hash: "hash".into(), installed_at: "1".into(), + ..Default::default() }; let mut coordination = skills_coordination(); @@ -5943,6 +6525,7 @@ mod tests { repo_path: "alpha".into(), content_hash: "hash".into(), installed_at: "1".into(), + ..Default::default() }; let mut coordination = skills_coordination(); @@ -7798,6 +8381,7 @@ mod tests { repo_path: "notes~old".into(), content_hash: "old".into(), installed_at: String::new(), + ..Default::default() }, ); manager.save_state_unlocked(&state).unwrap(); diff --git a/crates/codex-plus-core/src/skills/source_match.rs b/crates/codex-plus-core/src/skills/source_match.rs new file mode 100644 index 0000000000..d9d1897729 --- /dev/null +++ b/crates/codex-plus-core/src/skills/source_match.rs @@ -0,0 +1,1580 @@ +//! 将本地 Skill 与仓库路径关联;关联只更新记录,不接管技能目录。 +use super::*; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRepositoryMatch { + pub local_id: String, + pub local_name: String, + pub local_hash: String, + pub snapshot: String, + pub repo_key: String, + pub repo_path: String, + pub remote_name: String, + pub remote_hash: String, + pub exact: bool, + pub conflict: Option, + pub local_paths: Vec, + pub local_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillSourceEvidence { + pub repo: String, + pub kind: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRepositoryMatchPreview { + pub preview_id: String, + pub matches: Vec, + pub manual_candidates: Vec, + pub examined: usize, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRepositoryMatchSelection { + pub preview_id: String, + pub local_id: String, + pub repo_key: String, + pub repo_path: String, + #[serde(default)] + pub local_path: Option, + #[serde(default)] + pub manual: bool, +} + +#[derive(Clone)] +struct RepositoryMatchSnapshot { + state_path: PathBuf, + scope: CoordinationScope, + matches: Vec, + manual_candidates: Vec, +} + +fn repository_match_previews() -> MutexGuard<'static, std::collections::VecDeque<(String, RepositoryMatchSnapshot)>> { + static PREVIEWS: OnceLock>> = OnceLock::new(); + PREVIEWS.get_or_init(Mutex::default).lock() + .expect("Skill 来源匹配预览锁中毒,拒绝继续修改") +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRepositoryMatchApplied { + pub local_id: String, + pub from_repo_key: String, + pub to_repo_key: String, + pub local_path: Option, + pub instance_key: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRepositoryMatchApply { + pub applied: Vec, + pub errors: Vec, +} + +struct MatchSnapshot { + value: String, + local_hash: String, + name: String, + description: String, + installed: Option, + scope: CoordinationScope, + skill_generation: u64, + repo_generation: u64, + original_dir: Option, + instance_target: Option, + instance_records: Option>, + selected_identity: Option, + selected_digest: Option<[u8; 32]>, + selected_managed_source: bool, + local_paths: Vec, + conflict: Option, +} + +fn comparable_name(value: &str) -> String { + value.trim().to_lowercase().chars().filter(|ch| ch.is_alphanumeric()).collect() +} + +fn github_repositories(text: &str) -> BTreeSet { + let mut found = BTreeSet::new(); + for (offset, _) in text.match_indices("github.com") { + let prefix = text[..offset].chars().next_back(); + if !matches!(prefix, Some('/') | Some('@')) { + continue; + } + let rest = &text[offset + "github.com".len()..]; + let Some(rest) = rest.strip_prefix('/').or_else(|| rest.strip_prefix(':')) else { + continue; + }; + let mut parts = rest.split('/'); + let Some(owner) = parts.next() else { continue }; + let Some(repo) = parts.next() else { continue }; + let repo = repo.trim_end_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' && ch != '.') + .trim_end_matches(".git"); + let valid = |part: &str| !part.is_empty() + && part.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')); + if valid(owner) && valid(repo) && owner != "." && owner != ".." && repo != "." && repo != ".." { + found.insert(format!("{owner}/{repo}")); + } + } + found +} + +fn json_repository_values(value: &Value, out: &mut BTreeSet) { + match value { + Value::String(value) => { + out.extend(github_repositories(value)); + let trimmed = value.trim().trim_start_matches("github:"); + if !trimmed.contains("://") && !trimmed.contains('@') && trimmed.matches('/').count() == 1 { + out.extend(github_repositories(&format!("https://github.com/{trimmed}"))); + } + } + Value::Array(values) => values.iter().for_each(|value| json_repository_values(value, out)), + Value::Object(values) => values.values().for_each(|value| json_repository_values(value, out)), + _ => {} + } +} + +fn plausible_match(id: &str, name: &str, remote: &RemoteSkill, local_hash: &str) -> (bool, bool) { + let exact = !remote.content_hash.is_empty() && remote.content_hash == local_hash; + let remote_dir = if remote.repo_path == "." { + remote.repo_key.split('/').nth(1).unwrap_or("").split('@').next().unwrap_or("") + } else { + remote.repo_path.rsplit('/').next().unwrap_or("") + }; + let names = [comparable_name(id), comparable_name(name)]; + let targets = [comparable_name(remote_dir), comparable_name(&remote.name)]; + (exact || names.iter().any(|name| !name.is_empty() && targets.contains(name)), exact) +} + +/// 复用远端清单的哈希格式:相对路径与 Git blob ID 的排序列表。 +pub(super) fn local_repository_hash(root: &Path) -> anyhow::Result { + fn visit(root: &Path, directory: &Path, files: &mut Vec<(String, String)>) -> anyhow::Result<()> { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if directory == root && entry.file_name() == MANAGED_COPY_MARKER { + continue; + } + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path)?; + if metadata_is_link(&metadata) { + anyhow::bail!("Skill 包含无法比较的符号链接:{}", path.display()); + } + if metadata.is_dir() { + visit(root, &path, files)?; + } else if metadata.is_file() { + let relative = path.strip_prefix(root)?.components() + .map(|part| part.as_os_str().to_str().context("Skill 文件名无法比较")) + .collect::>>()?.join("/"); + let mut file = std::fs::File::open(&path)?; + let mut blob = sha1::Sha1::new(); + blob.update(format!("blob {}\0", metadata.len()).as_bytes()); + let mut bytes_read = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + bytes_read += count as u64; + blob.update(&buffer[..count]); + } + let after = std::fs::metadata(&path)?; + anyhow::ensure!( + bytes_read == metadata.len() && after.len() == metadata.len() + && after.modified().ok() == metadata.modified().ok(), + "读取期间 Skill 文件发生变化:{}", path.display() + ); + files.push((relative, format!("{:x}", blob.finalize()))); + } else { + anyhow::bail!("Skill 包含无法比较的特殊文件:{}", path.display()); + } + } + Ok(()) + } + + let mut files = Vec::new(); + visit(root, root, &mut files)?; + files.sort(); + let mut hash = Sha256::new(); + for (path, blob) in files { + hash.update(path.as_bytes()); + hash.update([0]); + hash.update(blob.as_bytes()); + hash.update([0]); + } + Ok(format!("{:x}", hash.finalize())) +} + +impl SkillsManager { + /// 线索仅供用户核对,不表示来源已验证,也不会修改安装状态。 + pub fn discover_skill_source_evidence(&self, id: &str) -> anyhow::Result> { + self.discover_skill_source_evidence_inner(id, None) + } + + pub fn discover_skill_source_evidence_for_instance( + &self, + id: &str, + local_path: &Path, + ) -> anyhow::Result> { + self.discover_skill_source_evidence_inner(id, Some(local_path)) + } + + fn discover_skill_source_evidence_inner( + &self, + id: &str, + local_path: Option<&Path>, + ) -> anyhow::Result> { + validate_skill_id(id)?; + let mut evidence = BTreeSet::<(String, String, PathBuf)>::new(); + let mut targets = BTreeSet::new(); + let managed_root = self.source_dir.canonicalize().ok(); + for root in self.repository_match_roots() { + let path = root.join(id); + if path.join(SKILL_MANIFEST_FILE).is_file() { + let target = path.canonicalize()?; + if local_path.is_none_or(|selected| (selected == path || selected == target) + && (root == self.source_dir + || !managed_root.as_ref().is_some_and(|managed| target.starts_with(managed)))) + { + targets.insert(target); + } + } + } + if let Some(selected) = local_path { + anyhow::ensure!(!targets.is_empty() && std::fs::symlink_metadata(selected)?.file_type().is_dir() + && !is_managed_copy(selected), "所选路径不是已发现的实体 Skill 原件"); + } + for target in targets { + let mut add_text = |path: &Path, kind: &str| -> anyhow::Result<()> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error).with_context(|| format!("读取 {} 失败", path.display())), + }; + if let Ok(text) = std::str::from_utf8(&bytes) { + for repo in github_repositories(text) { + evidence.insert((repo, kind.to_string(), path.to_path_buf())); + } + } + Ok(()) + }; + add_text(&target.join(SKILL_MANIFEST_FILE), "manifest-link")?; + let scripts = target.join("scripts"); + match std::fs::read_dir(&scripts) { + Ok(entries) => for entry in entries { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_file() + && path.extension().and_then(|ext| ext.to_str()).map_or(true, |ext| + matches!(ext.to_ascii_lowercase().as_str(), "sh" | "cmd" | "bat" | "ps1")) + && entry.metadata()?.len() <= 1024 * 1024 + { + add_text(&path, "script-link")?; + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + drop(add_text); + for (path, kind) in [ + (target.join("package.json"), "package"), + (target.join(".skill-receipt.json"), "receipt"), + (target.join(".install-receipt.json"), "receipt"), + ] { + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + let value: Value = serde_json::from_slice(&bytes) + .with_context(|| format!("解析 {} 失败", path.display()))?; + let mut repos = BTreeSet::new(); + if kind == "package" { + for key in ["repository", "homepage"] { + if let Some(value) = value.get(key) { json_repository_values(value, &mut repos); } + } + } else { + json_repository_values(&value, &mut repos); + } + for repo in repos { evidence.insert((repo, kind.into(), path.clone())); } + } + for directory in target.ancestors().take(4) { + for name in ["skills-lock.json", ".skills-lock.json", ".skill-lock.json"] { + let path = directory.join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + let value: Value = serde_json::from_slice(&bytes) + .with_context(|| format!("解析 {} 失败", path.display()))?; + if let Some(record) = value.get("skills").and_then(|items| items.get(id)) { + let mut repos = BTreeSet::new(); + json_repository_values(record, &mut repos); + for repo in repos { evidence.insert((repo, "lock".into(), path.clone())); } + } + } + } + let mut command = std::process::Command::new("git"); + command.args(["-C"]).arg(&target).args(["rev-parse", "--show-toplevel"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(crate::windows_create_no_window()); + } + let git = command.output(); + match git { + Ok(output) if output.status.success() => { + let root = String::from_utf8(output.stdout)?; + if Path::new(root.trim_end_matches(['\r', '\n'])).canonicalize()? == target { + let mut command = std::process::Command::new("git"); + command.args(["-C"]).arg(&target).args(["remote", "get-url", "origin"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(crate::windows_create_no_window()); + } + let output = command.output()?; + if output.status.success() { + for repo in github_repositories(&String::from_utf8_lossy(&output.stdout)) { + evidence.insert((repo, "git-remote".into(), target.clone())); + } + } + } + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(evidence.into_iter().map(|(repo, kind, path)| SkillSourceEvidence { repo, kind, path }).collect()) + } + + fn repository_match_roots(&self) -> Vec { + let mut roots = vec![self.source_dir.clone(), self.linked_dir()]; + if let Some(root) = self.shared_skills_dir() { + roots.push(root.to_path_buf()); + } + roots.extend(self.agent_dirs.iter().map(|(_, dir)| dir.clone())); + roots + } + + fn repository_match_ids(&self) -> anyhow::Result> { + let state = self.load_state_checked_unlocked()?; + let mut ids: BTreeSet<_> = state.installed.keys().cloned().collect(); + for root in self.repository_match_roots() { + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error).with_context(|| format!("读取 {} 失败", root.display())), + }; + for entry in entries { + let entry = entry?; + let id = entry.file_name().into_string() + .map_err(|_| anyhow::anyhow!("Skill 目录名无法识别"))?; + if !id.starts_with('.') && entry.path().join(SKILL_MANIFEST_FILE).is_file() { + ids.insert(id); + } + } + } + Ok(ids) + } + + fn repository_match_snapshot(&self, id: &str, repo: &SkillRepo, selected: Option<&Path>) -> anyhow::Result { + validate_skill_id(id)?; + let (scope, skill_generation, repo_generation, installed, instance_target, instance_records, managed_source) = { + let coordination = skills_coordination(); + let state = self.load_state_checked_unlocked()?; + anyhow::ensure!(state.repos.contains(repo), "仓库配置已变化,请重新匹配"); + let scope = self.coordination_scope(); + let skill_generation = coordination.mutation_generation(&scope, id); + let repo_generation = coordination.repo_change_generation(&scope, &repo.key()); + (scope, skill_generation, repo_generation, state.installed.get(id).cloned(), state.instance_targets.get(id).cloned(), + state.instance_records.get(id).cloned(), self.unclaimed_managed_source(id, &state)) + }; + + let mut snapshot = Sha256::new(); + snapshot.update(serde_json::to_vec(&installed)?); + snapshot.update(serde_json::to_vec(&instance_target)?); + snapshot.update(serde_json::to_vec(&instance_records)?); + snapshot.update(serde_json::to_vec(&selected)?); + snapshot.update(serde_json::to_vec(repo)?); + snapshot.update(skill_generation.to_le_bytes()); + snapshot.update(repo_generation.to_le_bytes()); + let mut first: Option<(String, String, String, [u8; 32])> = None; + let mut originals = BTreeMap::::new(); + let mut divergent = false; + let mut selected_identity = None; + let mut selected_digest = None; + let mut selected_managed_source = false; + let managed_root = self.source_dir.canonicalize().ok(); + for root in self.repository_match_roots() { + let path = root.join(id); + snapshot.update(path.to_string_lossy().as_bytes()); + match std::fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + snapshot.update(b"missing"); + } + Err(error) => return Err(error).with_context(|| format!("读取 {} 失败", path.display())), + Ok(_) => { + anyhow::ensure!(path.join(SKILL_MANIFEST_FILE).is_file(), + "同名路径不是有效 Skill:{}", path.display()); + let identity = skill_path_identity(&path)?; + let target = path.canonicalize()?; + let digest = skill_directory_digest(&target)?.context("Skill 内容已不存在")?; + snapshot.update(identity.device.to_le_bytes()); + snapshot.update(identity.file.to_le_bytes()); + snapshot.update(target.to_string_lossy().as_bytes()); + snapshot.update(digest); + if root != self.source_dir + && !managed_root.as_ref().is_some_and(|managed| target.starts_with(managed)) + && (!target.join(MANAGED_COPY_MARKER).exists() + || instance_target.as_ref().is_some_and(|recorded| + recorded.path.canonicalize().ok().as_ref() == Some(&target) + && skill_path_identity(&recorded.path).ok() == Some(recorded.identity))) + { + originals.entry(target.clone()).or_insert_with(|| path.clone()); + } + if selected.is_some_and(|selected| selected == path || selected == target) + && (root == self.source_dir + || !managed_root.as_ref().is_some_and(|managed| target.starts_with(managed))) + { + let is_managed_source = root == self.source_dir; + if is_managed_source { + anyhow::ensure!(managed_source.as_ref() == Some(&path), + "受控源属于其他安装位置,请选择该安装位置关联"); + } + let selected_path = selected.unwrap(); + let recorded_original = instance_target.as_ref().is_some_and(|recorded| + recorded.path.canonicalize().ok().as_ref() == Some(&target) + && skill_path_identity(&recorded.path).ok() == Some(recorded.identity)); + anyhow::ensure!(std::fs::symlink_metadata(selected_path)?.file_type().is_dir() + && (!is_managed_copy(selected_path) || recorded_original), + "所选路径不是可单独管理的实体原件"); + let (name, description) = read_skill_manifest(&selected_path.join(SKILL_MANIFEST_FILE), id); + selected_identity = Some(skill_path_identity(selected_path)?); + selected_digest = Some(digest); + selected_managed_source = is_managed_source; + let local_hash = local_repository_hash(selected_path)?; + anyhow::ensure!(skill_directory_digest(selected_path)? == Some(digest), + "读取期间 Skill 内容已变化,请重新匹配"); + first = Some((name, description, local_hash, digest)); + } else if selected.is_some() { + continue; + } else if let Some((_, _, _, previous)) = &first { + divergent |= *previous != digest; + } else { + let (name, description) = read_skill_manifest(&target.join(SKILL_MANIFEST_FILE), id); + let local_hash = local_repository_hash(&target)?; + anyhow::ensure!(skill_directory_digest(&target)? == Some(digest), + "读取期间 Skill 内容已变化,请重新匹配"); + first = Some((name, description, local_hash, digest)); + } + } + } + snapshot.update([0]); + } + let (name, description, local_hash, _) = first.context("本地 Skill 已不存在")?; + let recorded = installed.as_ref().and_then(|item| item.original_dir.as_ref()) + .and_then(|path| path.canonicalize().ok()) + .filter(|path| originals.contains_key(path)); + let original_dir = if selected_managed_source { + None + } else { + selected.map(Path::to_path_buf).or_else(|| recorded.and_then(|path| originals.get(&path).cloned()) + .or_else(|| (originals.len() == 1).then(|| originals.values().next().cloned()).flatten())) + }; + let conflict = if selected.is_some() { + None + } else if divergent { + Some("同名 Skill 副本内容不同,请先确定原件".to_string()) + } else if originals.len() > 1 { + Some("存在多个独立的本地原件,请先确定原件".to_string()) + } else { + None + }; + let coordination = skills_coordination(); + let state = self.load_state_checked_unlocked()?; + anyhow::ensure!( + coordination_scope_is_current(&scope, &self.coordination_scope()) + && coordination.mutation_generation(&scope, id) == skill_generation + && coordination.repo_change_generation(&scope, &repo.key()) == repo_generation + && state.repos.contains(repo) + && state.installed.get(id) == installed.as_ref() + && state.instance_targets.get(id) == instance_target.as_ref() + && state.instance_records.get(id) == instance_records.as_ref(), + "Skill 或仓库在匹配期间发生变化,请重试" + ); + Ok(MatchSnapshot { + value: format!("{:x}", snapshot.finalize()), + local_hash, + name, + description, + installed, + scope, + skill_generation, + repo_generation, + original_dir, + instance_target, + instance_records, + selected_identity, + selected_digest, + selected_managed_source, + local_paths: originals.into_values().collect(), + conflict, + }) + } + + /// 批量匹配缺少来源的技能;指定 ID 时也提供其他路径供手动关联。 + pub fn preview_repository_matches( + &self, + repo: &SkillRepo, + remote: &[RemoteSkill], + only_id: Option<&str>, + ) -> anyhow::Result { + self.preview_repository_matches_inner(repo, remote, only_id, None) + } + + pub fn preview_repository_matches_for_instance( + &self, + repo: &SkillRepo, + remote: &[RemoteSkill], + id: &str, + local_path: &Path, + ) -> anyhow::Result { + self.preview_repository_matches_inner(repo, remote, Some(id), Some(local_path)) + } + + fn preview_repository_matches_inner( + &self, + repo: &SkillRepo, + remote: &[RemoteSkill], + only_id: Option<&str>, + local_path: Option<&Path>, + ) -> anyhow::Result { + if let Some(id) = only_id { + validate_skill_id(id)?; + } + { + let _coordination = skills_coordination(); + let state = self.load_state_checked_unlocked()?; + anyhow::ensure!(state.repos.contains(repo), "仓库配置已变化,请重新匹配"); + } + let ids = match only_id { + Some(id) => BTreeSet::from([id.to_string()]), + None => self.repository_match_ids()?, + }; + let mut preview = SkillRepositoryMatchPreview::default(); + for id in ids { + let snapshot = match self.repository_match_snapshot(&id, repo, local_path) { + Ok(snapshot) => snapshot, + Err(error) => { + preview.warnings.push(format!("{id}:{error:#}")); + continue; + } + }; + if only_id.is_none() && snapshot.local_paths.len() <= 1 && snapshot.installed.as_ref().is_some_and(|item| + !item.repo_path.is_empty() && parse_repo_key(&item.repo_key).is_some()) + { + continue; + } + preview.examined += 1; + for candidate in remote.iter().filter(|item| item.repo_key == repo.key()) { + let (matches, exact) = plausible_match(&id, &snapshot.name, candidate, &snapshot.local_hash); + if matches || only_id.is_some() { + let found = SkillRepositoryMatch { + local_id: id.clone(), + local_name: snapshot.name.clone(), + local_hash: snapshot.local_hash.clone(), + snapshot: snapshot.value.clone(), + repo_key: candidate.repo_key.clone(), + repo_path: candidate.repo_path.clone(), + remote_name: candidate.name.clone(), + remote_hash: candidate.content_hash.clone(), + exact, + conflict: snapshot.conflict.clone(), + local_paths: snapshot.local_paths.clone(), + local_path: local_path.map(Path::to_path_buf), + }; + if matches { + preview.matches.push(found); + } else { + preview.manual_candidates.push(found); + } + } + } + } + preview.matches.sort_by(|a, b| a.local_id.cmp(&b.local_id) + .then(b.exact.cmp(&a.exact)).then(a.repo_path.cmp(&b.repo_path))); + preview.manual_candidates.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + preview.preview_id = uuid::Uuid::new_v4().to_string(); + let mut previews = repository_match_previews(); + while previews.len() >= 256 { + previews.pop_front(); + } + previews.push_back((preview.preview_id.clone(), RepositoryMatchSnapshot { + state_path: self.state_path.clone(), + scope: self.coordination_scope(), + matches: preview.matches.clone(), + manual_candidates: preview.manual_candidates.clone(), + })); + Ok(preview) + } + + /// `remote` 由调用方在确认时重新拉取;每项独立提交并返回实际成功项。 + pub fn apply_repository_matches( + &self, + selections: &[SkillRepositoryMatchSelection], + remote: &[RemoteSkill], + ) -> anyhow::Result { + let mut ids = BTreeSet::new(); + let mut origins = BTreeSet::new(); + for selection in selections { + validate_skill_id(&selection.local_id)?; + anyhow::ensure!(ids.insert(selection.local_id.to_lowercase()), "同一个 Skill 只能选择一个来源"); + anyhow::ensure!(origins.insert((selection.repo_key.clone(), selection.repo_path.clone())), + "同一个仓库路径不能关联多个 Skill"); + anyhow::ensure!(!selection.manual || selections.len() == 1, "手动选择只能单项确认"); + } + let mut outcome = SkillRepositoryMatchApply::default(); + for selection in selections { + let result = (|| -> anyhow::Result { + let preview = repository_match_previews().iter() + .find(|(id, _)| id == &selection.preview_id) + .map(|(_, preview)| preview.clone()) + .context("来源匹配预览已失效,请重新匹配")?; + anyhow::ensure!(preview.state_path == self.state_path + && coordination_scope_is_current(&preview.scope, &self.coordination_scope()), + "Skill 管理范围已变化,请重新匹配"); + let candidates = if selection.manual { &preview.manual_candidates } else { &preview.matches }; + let confirmed = candidates.iter().find(|item| + item.local_id == selection.local_id && item.repo_key == selection.repo_key + && item.repo_path == selection.repo_path && item.local_path == selection.local_path) + .context("所选来源不在已确认的预览中,请重新匹配")?; + let repo = self.list_repos().into_iter() + .find(|repo| repo.key() == selection.repo_key).context("仓库已删除")?; + let candidate = remote.iter().find(|item| + item.repo_key == selection.repo_key && item.repo_path == selection.repo_path + && item.content_hash == confirmed.remote_hash && !item.content_hash.is_empty()) + .context("仓库内容已变化或来源不可用,请重新匹配")?; + let snapshot = self.repository_match_snapshot(&selection.local_id, &repo, selection.local_path.as_deref())?; + anyhow::ensure!(snapshot.conflict.is_none(), "{}", snapshot.conflict.as_deref().unwrap_or("")); + let (matches, _) = plausible_match(&selection.local_id, &snapshot.name, candidate, &snapshot.local_hash); + anyhow::ensure!(snapshot.value == confirmed.snapshot && snapshot.local_hash == confirmed.local_hash + && (matches || selection.manual), "Skill 已变化或来源不匹配,请重新匹配"); + let mut coordination = skills_coordination(); + let mut state = self.load_state_checked_unlocked()?; + if let Some(path) = &selection.local_path { + self.ensure_skill_name_not_aliased(&selection.local_id, &state)?; + anyhow::ensure!(Some(skill_path_identity(path)?) == snapshot.selected_identity + && skill_directory_digest(path)? == snapshot.selected_digest, + "所选 Skill 已改变,请重新匹配"); + } else { + self.ensure_skill_name_not_aliased(&selection.local_id, &state)?; + } + anyhow::ensure!( + coordination_scope_is_current(&snapshot.scope, &self.coordination_scope()) + && coordination.mutation_generation(&snapshot.scope, &selection.local_id) == snapshot.skill_generation + && coordination.repo_change_generation(&snapshot.scope, &repo.key()) == snapshot.repo_generation + && state.repos.contains(&repo) + && state.installed.get(&selection.local_id) == snapshot.installed.as_ref() + && state.instance_targets.get(&selection.local_id) == snapshot.instance_target.as_ref() + && state.instance_records.get(&selection.local_id) == snapshot.instance_records.as_ref(), + "Skill 或仓库在确认期间发生变化,请重新匹配" + ); + anyhow::ensure!(!state.installed.values().any(|item| + item.id != selection.local_id && item.repo_key == selection.repo_key + && item.repo_path == selection.repo_path), "仓库路径已关联其他 Skill"); + let previous = if snapshot.selected_managed_source { + snapshot.installed.clone() + } else if let Some(path) = &selection.local_path { + let canonical = path.canonicalize()?; + state.instance_record(&selection.local_id, &canonical).map(|record| record.installed.clone()) + .or_else(|| snapshot.instance_target.as_ref().filter(|target| { + target.path.canonicalize().ok().as_ref() == Some(&canonical) + && skill_path_identity(&target.path).ok() == Some(target.identity) + }).and(snapshot.installed.clone())) + } else { + snapshot.installed.clone() + }; + let from_repo_key = previous.as_ref().map(|item| item.repo_key.clone()).unwrap_or_default(); + let same_original = previous.as_ref().is_some_and(|item| { + match (&item.original_dir, &snapshot.original_dir) { + (Some(old), Some(new)) => old.canonicalize().ok() == new.canonicalize().ok(), + (None, None) => true, + _ => false, + } + }); + let same_source = same_original && previous.as_ref().is_some_and(|item| + item.repo_key == selection.repo_key && item.repo_path == selection.repo_path); + let mut item = previous.unwrap_or_else(|| InstalledSkill { + id: selection.local_id.clone(), + name: snapshot.name.clone(), + description: snapshot.description.clone(), + repo_key: String::new(), + repo_path: String::new(), + content_hash: String::new(), + installed_at: current_unix_timestamp_string(), + verified_cc_switch: false, + install_channel: "local".into(), + original_dir: snapshot.original_dir.clone(), + update_owner: String::new(), + }); + item.name = snapshot.name; + item.description = snapshot.description; + item.repo_key = selection.repo_key.clone(); + item.repo_path = selection.repo_path.clone(); + if !same_source { + item.content_hash = if confirmed.exact { snapshot.local_hash } else { String::new() }; + item.update_owner.clear(); + if item.install_channel.is_empty() { + item.install_channel = "local".into(); + } + } else if item.content_hash.is_empty() && confirmed.exact { + item.content_hash = snapshot.local_hash; + } + if snapshot.original_dir.is_some() && item.update_owner == "manager" { + item.update_owner.clear(); + } + if !same_original { + item.verified_cc_switch = false; + item.install_channel = "local".into(); + } + if let Some(original) = snapshot.original_dir { + if selection.local_path.is_some() || !same_original { + item.original_dir = Some(original); + } + } + if snapshot.selected_managed_source { + state.installed.insert(selection.local_id.clone(), item); + } else if let Some(path) = &selection.local_path { + if let (Some(installed), Some(target)) = (&snapshot.installed, &snapshot.instance_target) { + state.instance_records.entry(selection.local_id.clone()).or_default() + .entry(target.path.clone()).or_insert_with(|| SkillInstanceRecord { + installed: installed.clone(), target: target.clone(), managed_copies: Vec::new(), + }); + } + let canonical = path.canonicalize()?; + let mut managed_copies = state.instance_record(&selection.local_id, &canonical) + .map(|record| record.managed_copies.clone()).unwrap_or_default(); + let recorded_original = snapshot.installed.as_ref().is_some_and(|installed| { + installed.original_dir.as_ref().and_then(|original| original.canonicalize().ok()).as_ref() == Some(&canonical) + }) && snapshot.instance_target.as_ref().is_none_or(|target| { + target.path.canonicalize().ok().as_ref() == Some(&canonical) + && skill_path_identity(&target.path).ok() == Some(target.identity) + }) && snapshot.instance_records.as_ref().is_none_or(|records| { + records.values().filter(|record| { + record.target.path.canonicalize().ok().as_ref() == Some(&canonical) + }).all(|record| skill_path_identity(&record.target.path).ok() == Some(record.target.identity)) + }); + let tracked_by_other = |candidate: &Path| snapshot.instance_records.as_ref() + .into_iter().flat_map(|records| records.values()) + .filter(|record| record.target.path.canonicalize().ok().as_ref() != Some(&canonical)) + .any(|record| record.managed_copies.iter().any(|copy| copy.path == candidate)); + if recorded_original { + let source = self.source_dir.join(&selection.local_id); + if !tracked_by_other(&source) && source.is_dir() && !source.is_symlink() + && skill_directory_digest(&source)? == snapshot.selected_digest + { + let source_identity = skill_path_identity(&source)?; + let existing_source = managed_copies.iter().find(|copy| copy.path == source); + if existing_source.is_none_or(|copy| copy.identity == source_identity) { + if existing_source.is_none() { + managed_copies.push(SkillInstanceTarget { identity: source_identity, path: source }); + } + for root in std::iter::once(self.linked_dir()).chain(self.agent_dirs.iter().map(|(_, root)| root.clone())) { + let copy = root.join(&selection.local_id); + if !tracked_by_other(©) && !managed_copies.iter().any(|item| item.path == copy) + && is_managed_copy(©) && skill_directory_digest(©)? == snapshot.selected_digest + { + managed_copies.push(SkillInstanceTarget { + identity: skill_path_identity(©)?, path: copy, + }); + } + } + } + } + } + let target = SkillInstanceTarget { + path: path.clone(), + identity: skill_path_identity(path)?, + }; + state.instance_records.entry(selection.local_id.clone()).or_default() + .insert(path.clone(), SkillInstanceRecord { installed: item.clone(), target: target.clone(), managed_copies }); + let mirror = snapshot.installed.is_none() + || snapshot.installed.as_ref().is_some_and(|installed| installed.repo_path.is_empty()) + || snapshot.installed.as_ref().and_then(|installed| installed.original_dir.as_ref()) + .and_then(|original| original.canonicalize().ok()) == path.canonicalize().ok() + || snapshot.instance_target.as_ref().is_some_and(|old| old.path == *path); + if mirror { + state.installed.insert(selection.local_id.clone(), item); + state.instance_targets.insert(selection.local_id.clone(), target); + } + } else { + state.installed.insert(selection.local_id.clone(), item); + } + self.save_state_unlocked(&state)?; + coordination.changed_skill(self.coordination_scope(), &selection.local_id); + Ok(SkillRepositoryMatchApplied { + local_id: selection.local_id.clone(), + from_repo_key, + to_repo_key: selection.repo_key.clone(), + local_path: selection.local_path.clone(), + instance_key: selection.local_path.as_ref().map(|path| format!("{}:{}", selection.local_id, path.display())), + }) + })(); + match result { + Ok(applied) => outcome.applied.push(applied), + Err(error) => outcome.errors.push(format!("{}:{error:#}", selection.local_id)), + } + } + Ok(outcome) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, SkillsManager, SkillRepo) { + let temp = tempfile::tempdir().unwrap(); + 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(temp.path().join("shared")) + .with_agent_dirs(vec![("claude".into(), temp.path().join("claude"))]); + let repo = SkillRepo { + owner: "example".into(), name: "repo".into(), branch: "main".into(), + subdir: String::new(), enabled: true, + }; + manager.save_repo(repo.clone(), None).unwrap(); + (temp, manager, repo) + } + + fn write_skill(root: &Path, id: &str, description: &str) { + let path = root.join(id); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("SKILL.md"), format!("---\nname: {id}\ndescription: {description}\n---\n")).unwrap(); + std::fs::write(path.join("details.txt"), "whole tree").unwrap(); + } + + fn candidate(repo: &SkillRepo, path: &str, hash: String) -> RemoteSkill { + RemoteSkill { + id: "remote".into(), name: path.rsplit('/').next().unwrap().into(), + description: String::new(), repo_key: repo.key(), repo_path: path.into(), content_hash: hash, + } + } + + #[test] + fn selected_instance_evidence_does_not_include_other_installs() { + let (temp, manager, _) = fixture(); + let shared = temp.path().join("shared/alpha"); + let claude = temp.path().join("claude/alpha"); + write_skill(&temp.path().join("shared"), "alpha", "shared"); + write_skill(&temp.path().join("claude"), "alpha", "claude"); + std::fs::write(shared.join(SKILL_MANIFEST_FILE), "https://github.com/example/shared").unwrap(); + std::fs::write(claude.join(SKILL_MANIFEST_FILE), "https://github.com/example/claude").unwrap(); + let evidence = manager.discover_skill_source_evidence_for_instance("alpha", &shared).unwrap(); + assert!(evidence.iter().any(|item| item.repo == "example/shared")); + assert!(!evidence.iter().any(|item| item.repo == "example/claude")); + let unrelated = temp.path().join("unrelated"); + std::fs::create_dir(&unrelated).unwrap(); + assert!(manager.discover_skill_source_evidence_for_instance("alpha", &unrelated).is_err()); + } + + #[test] + fn selected_managed_source_can_be_associated_without_claiming_an_independent_install() { + let (temp, manager, repo) = fixture(); + let source = manager.source_dir.join("alpha"); + let independent = temp.path().join("shared/alpha"); + write_skill(&manager.source_dir, "alpha", "managed"); + write_skill(&temp.path().join("shared"), "alpha", "independent"); + write_skill(&temp.path().join("claude"), "alpha", "managed"); + std::fs::write(temp.path().join("claude/alpha").join(MANAGED_COPY_MARKER), "").unwrap(); + std::fs::write(source.join("scripts.txt"), "managed content").unwrap(); + let mut state = manager.load_state(); + state.installed.insert("alpha".into(), InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: LOCAL_IMPORT_REPO_KEY.into(), repo_path: String::new(), content_hash: String::new(), + installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), + original_dir: None, update_owner: String::new(), + }); + manager.save_state_unlocked(&state).unwrap(); + let inventory = manager.try_local_inventory().unwrap(); + assert!(inventory.iter().flat_map(|item| &item.locations) + .any(|location| location.managed && location.association_path.as_ref() == Some(&source))); + let remote = vec![candidate(&repo, "skills/alpha", local_repository_hash(&source).unwrap())]; + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &source).unwrap(); + assert!(preview.matches[0].exact); + assert!(preview.matches[0].conflict.is_none()); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + assert_eq!(state.installed["alpha"].repo_key, repo.key()); + assert_eq!(state.installed["alpha"].repo_path, "skills/alpha"); + assert!(state.installed["alpha"].original_dir.is_none()); + assert!(state.instance_records.get("alpha").is_none()); + assert_eq!(std::fs::read_to_string(source.join("scripts.txt")).unwrap(), "managed content"); + assert!(std::fs::read_to_string(independent.join("SKILL.md")).unwrap().contains("independent")); + } + + #[test] + fn associating_a_legacy_local_source_does_not_authorize_manager_updates() { + let (_temp, manager, repo) = fixture(); + let source = manager.source_dir.join("alpha"); + write_skill(&manager.source_dir, "alpha", "managed"); + let mut state = manager.load_state(); + state.installed.insert("alpha".into(), InstalledSkill { + id: "alpha".into(), name: "alpha".into(), repo_key: "cc-switch".into(), + ..Default::default() + }); + manager.save_state_unlocked(&state).unwrap(); + let remote = vec![candidate(&repo, "skills/alpha", local_repository_hash(&source).unwrap())]; + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &source).unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + assert_eq!(state.installed["alpha"].install_channel, "local"); + assert!(state.installed["alpha"].update_owner.is_empty()); + assert!(manager.capture_download_guard("alpha", &repo.key(), false).is_err()); + } + + #[test] + fn agent_installations_associate_their_selected_repository_paths_independently() { + for multiple_paths in [false, true] { + let (temp, manager, repo) = fixture(); + let source = manager.source_dir.join("alpha"); + let codex = manager.linked_dir().join("alpha"); + let claude = temp.path().join("claude/alpha"); + write_skill(&manager.source_dir, "alpha", "managed"); + copy_dir_all(&source, &codex).unwrap(); + std::fs::write(codex.join(MANAGED_COPY_MARKER), "").unwrap(); + write_skill(&temp.path().join("claude"), "alpha", "independent"); + let mut state = manager.load_state(); + state.installed.insert("alpha".into(), InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: LOCAL_IMPORT_REPO_KEY.into(), repo_path: String::new(), content_hash: String::new(), + installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), + original_dir: None, update_owner: String::new(), + }); + manager.save_state_unlocked(&state).unwrap(); + let first = ".agents/skills/alpha"; + let second = if multiple_paths { ".codex/skills/alpha" } else { first }; + let mut remote = vec![candidate(&repo, first, local_repository_hash(&source).unwrap())]; + if multiple_paths { + remote.push(candidate(&repo, second, local_repository_hash(&claude).unwrap())); + } + for (path, repo_path) in [(&source, first), (&claude, second)] { + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", path).unwrap(); + assert_eq!(preview.matches.len(), remote.len()); + let found = preview.matches.iter().find(|item| item.repo_path == repo_path).unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, found, false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + let state = manager.load_state(); + assert_eq!(state.installed["alpha"].repo_path, first); + assert_eq!(state.instance_record("alpha", &claude.canonicalize().unwrap()).unwrap().installed.repo_path, second); + assert!(state.instance_record("alpha", &claude.canonicalize().unwrap()).unwrap().installed.update_owner.is_empty()); + let entries = manager.try_local_inventory().unwrap(); + let instances = entries.iter().filter(|item| item.id == "alpha").collect::>(); + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|item| item.conflict.is_none())); + assert_eq!(std::fs::read_to_string(source.join("SKILL.md")).unwrap(), + std::fs::read_to_string(codex.join("SKILL.md")).unwrap()); + assert!(std::fs::read_to_string(claude.join("SKILL.md")).unwrap().contains("independent")); + if multiple_paths { + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &claude).unwrap(); + let found = preview.matches.iter().find(|item| item.repo_path == first).unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, found, false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + assert_eq!(state.installed["alpha"].repo_path, first); + assert_eq!(state.instance_record("alpha", &claude.canonicalize().unwrap()).unwrap().installed.repo_path, first); + } + } + } + + #[test] + fn tracked_agent_copy_uses_its_originals_association_without_hiding_changes() { + for scenario in ["matching", "modified", "replaced"] { + let (temp, manager, repo) = fixture(); + let original = temp.path().join("shared/alpha"); + let source = manager.source_dir.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + let independent = temp.path().join("claude/alpha"); + write_skill(&temp.path().join("shared"), "alpha", "shared"); + copy_dir_all(&original, &source).unwrap(); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + write_skill(&temp.path().join("claude"), "alpha", "independent"); + let record = |path: &Path, repo_path: &str| { + let installed = InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: repo.key(), repo_path: repo_path.into(), content_hash: String::new(), + installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), + original_dir: Some(path.to_path_buf()), update_owner: String::new(), + }; + SkillInstanceRecord { + installed, target: SkillInstanceTarget { path: path.to_path_buf(), identity: skill_path_identity(path).unwrap() }, + managed_copies: Vec::new(), + } + }; + let mut shared = record(&original, ".agents/skills/alpha"); + shared.managed_copies = [&source, ©].into_iter().map(|path| SkillInstanceTarget { + path: path.clone(), identity: skill_path_identity(path).unwrap(), + }).collect(); + let other = record(&independent, ".claude/skills/alpha"); + let mut state = manager.load_state(); + state.installed.insert("alpha".into(), shared.installed.clone()); + state.instance_targets.insert("alpha".into(), shared.target.clone()); + state.instance_records.insert("alpha".into(), BTreeMap::from([ + (original.clone(), shared), (independent.clone(), other), + ])); + manager.save_state_unlocked(&state).unwrap(); + if scenario == "modified" { + std::fs::write(copy.join("details.txt"), "changed").unwrap(); + } else if scenario == "replaced" { + std::fs::rename(©, manager.linked_dir().join(".old-alpha")).unwrap(); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + } + + let entries = manager.try_local_inventory().unwrap(); + let instances = entries.iter().filter(|item| item.id == "alpha").collect::>(); + let codex = instances.iter().flat_map(|item| &item.locations) + .find(|location| location.root_kind == "codex").unwrap(); + if scenario == "matching" { + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|item| item.conflict.is_none())); + assert_eq!(codex.association_path.as_ref(), Some(&original.canonicalize().unwrap())); + assert_eq!(codex.repo_path, ".agents/skills/alpha"); + assert!(instances.iter().any(|item| item.locations.iter().any(|location| location.root_kind == "shared") + && item.locations.iter().any(|location| location.root_kind == "codex"))); + } else { + assert!(instances.iter().any(|item| item.conflict.is_some())); + if scenario == "replaced" { + assert!(codex.association_path.is_none()); + } + } + } + } + + #[test] + fn managed_source_association_rejects_claimed_sources_and_changed_files() { + let (temp, manager, repo) = fixture(); + let source = manager.source_dir.join("alpha"); + let original = temp.path().join("shared/alpha"); + write_skill(&manager.source_dir, "alpha", "managed"); + write_skill(&temp.path().join("shared"), "alpha", "original"); + let mut state = manager.load_state(); + let installed = InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: LOCAL_IMPORT_REPO_KEY.into(), repo_path: String::new(), content_hash: String::new(), + installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), + original_dir: None, update_owner: String::new(), + }; + state.installed.insert("alpha".into(), installed.clone()); + state.instance_records.entry("alpha".into()).or_default().insert(original.clone(), SkillInstanceRecord { + installed, target: SkillInstanceTarget { path: original.clone(), identity: skill_path_identity(&original).unwrap() }, + managed_copies: vec![SkillInstanceTarget { path: source.clone(), identity: skill_path_identity(&source).unwrap() }], + }); + manager.save_state_unlocked(&state).unwrap(); + let inventory = manager.try_local_inventory().unwrap(); + assert!(inventory.iter().flat_map(|item| &item.locations) + .all(|location| location.association_path.is_none())); + let remote = vec![candidate(&repo, "skills/alpha", local_repository_hash(&source).unwrap())]; + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &source).unwrap(); + assert!(preview.matches.is_empty()); + assert!(preview.warnings.iter().any(|warning| warning.contains("其他安装位置"))); + + state.instance_records.clear(); + manager.save_state_unlocked(&state).unwrap(); + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &source).unwrap(); + std::fs::write(source.join("details.txt"), "changed").unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.applied.is_empty()); + assert!(!result.errors.is_empty()); + assert_eq!(manager.load_state().installed["alpha"].repo_key, LOCAL_IMPORT_REPO_KEY); + } + + fn selection(preview: &SkillRepositoryMatchPreview, found: &SkillRepositoryMatch, manual: bool) -> SkillRepositoryMatchSelection { + SkillRepositoryMatchSelection { + preview_id: preview.preview_id.clone(), local_id: found.local_id.clone(), + repo_key: found.repo_key.clone(), repo_path: found.repo_path.clone(), manual, + local_path: found.local_path.clone(), + } + } + + #[test] + fn shared_skill_matches_whole_tree_and_association_preserves_files() { + let (temp, manager, repo) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "shared"); + let hash = local_repository_hash(&shared.join("alpha")).unwrap(); + let remote = vec![candidate(&repo, "skills/alpha", hash)]; + let preview = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + assert_eq!(preview.matches.len(), 1); + assert!(preview.matches[0].exact); + assert!(!manager.source_dir.join("alpha").exists()); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert_eq!(result.applied.len(), 1); + assert!(result.errors.is_empty()); + assert_eq!(manager.load_state().installed["alpha"].repo_path, "skills/alpha"); + assert!(!manager.source_dir.join("alpha").exists()); + assert_eq!(std::fs::read_to_string(shared.join("alpha/details.txt")).unwrap(), "whole tree"); + } + + #[test] + fn differing_associations_do_not_report_a_repository_update() { + for manual in [false, true] { + for selected_instance in [false, true] { + let (temp, manager, repo) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "local"); + let original = shared.join("alpha"); + let local_hash = local_repository_hash(&original).unwrap(); + let mut remote = vec![candidate( + &repo, + if manual { "skills/unrelated" } else { "skills/alpha" }, + "different-remote-content".into(), + )]; + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap() + }; + let found = if manual { &preview.manual_candidates[0] } else { &preview.matches[0] }; + assert!(!found.exact); + let result = manager.apply_repository_matches(&[selection(&preview, found, manual)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let installed = if selected_instance { + &state.instance_records["alpha"].values().next().unwrap().installed + } else { + &state.installed["alpha"] + }; + assert!(installed.content_hash.is_empty()); + assert!(installed.update_owner.is_empty()); + assert_eq!(local_repository_hash(&original).unwrap(), local_hash); + let entries = manager.merge_entries(&remote); + assert!(entries.iter().any(|entry| entry.id == "alpha" && entry.installed)); + assert!(entries.iter().filter(|entry| entry.id == "alpha").all(|entry| !entry.update_available)); + assert!(entries.iter().flat_map(|entry| &entry.locations).all(|location| !location.update_available)); + + remote[0].content_hash = local_hash.clone(); + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap() + }; + assert!(preview.matches[0].exact); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let installed = if selected_instance { + &state.instance_records["alpha"].values().next().unwrap().installed + } else { + &state.installed["alpha"] + }; + assert_eq!(installed.content_hash, local_hash); + remote[0].content_hash = "later-remote-version".into(); + let entries = manager.merge_entries(&remote); + assert!(entries.iter().filter(|entry| entry.id == "alpha").any(|entry| + entry.update_available || entry.locations.iter().any(|location| location.update_available))); + } + } + } + + #[test] + fn adopted_shared_skill_tracks_matching_managed_copies_without_taking_update_ownership() { + let (temp, manager, repo) = fixture(); + let manager = manager.with_agent_dirs(vec![ + ("claude".into(), temp.path().join("claude")), + ("gemini".into(), temp.path().join("gemini")), + ("other".into(), temp.path().join("other")), + ]); + let original = temp.path().join("shared/alpha"); + write_skill(&temp.path().join("shared"), "alpha", "shared"); + { + let mut coordination = skills_coordination(); + manager.ensure_managed_source_unlocked("alpha", &mut coordination).unwrap(); + } + manager.set_enabled_for("claude", "alpha", true).unwrap(); + let source = manager.source_dir.join("alpha"); + let copy = temp.path().join("gemini/alpha"); + copy_dir_all(&source, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let independent = manager.linked_dir().join("alpha"); + copy_dir_all(&original, &independent).unwrap(); + + let installed = manager.load_state().installed["alpha"].clone(); + assert_eq!(installed.original_dir.as_ref(), Some(&original.canonicalize().unwrap())); + assert!(installed.update_owner.is_empty()); + let remote = vec![candidate(&repo, "skills/alpha", local_repository_hash(&original).unwrap())]; + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let record = state.instance_record("alpha", &original.canonicalize().unwrap()).unwrap(); + assert!(record.installed.update_owner.is_empty()); + assert!(record.managed_copies.iter().any(|item| item.path == source)); + let copy_identity = skill_path_identity(©).unwrap(); + assert!(record.managed_copies.iter().any(|item| item.path == copy && item.identity == copy_identity)); + assert!(!record.managed_copies.iter().any(|item| item.path == independent)); + + // Reassociation must keep a modified tracked copy and add an equal, previously untracked copy. + std::fs::write(copy.join("details.txt"), "modified").unwrap(); + let later = temp.path().join("other/alpha"); + copy_dir_all(&source, &later).unwrap(); + std::fs::write(later.join(MANAGED_COPY_MARKER), "").unwrap(); + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let record = state.instance_record("alpha", &original.canonicalize().unwrap()).unwrap(); + assert!(record.managed_copies.iter().any(|item| item.path == copy && item.identity == copy_identity)); + assert!(record.managed_copies.iter().any(|item| item.path == later)); + assert!(!record.managed_copies.iter().any(|item| item.path == independent)); + + assert!(manager.capture_download_guard("alpha", &repo.key(), false).is_err()); + assert_eq!(std::fs::read_to_string(copy.join("details.txt")).unwrap(), "modified"); + assert_eq!(std::fs::read_to_string(independent.join("details.txt")).unwrap(), "whole tree"); + } + + #[test] + fn selected_instance_does_not_claim_unrelated_or_replaced_managed_copies() { + for scenario in ["unrelated", "replaced", "replaced-record", "other-owner"] { + let (temp, manager, repo) = fixture(); + let manager = manager.with_agent_dirs(vec![ + ("claude".into(), temp.path().join("claude")), + ("gemini".into(), temp.path().join("gemini")), + ]); + let original = temp.path().join("shared/alpha"); + let independent = temp.path().join("claude/alpha"); + let source = manager.source_dir.join("alpha"); + let copy = temp.path().join("gemini/alpha"); + write_skill(&temp.path().join("shared"), "alpha", "same"); + copy_dir_all(&original, &independent).unwrap(); + copy_dir_all(&original, &source).unwrap(); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let mut state = manager.load_state(); + let installed = InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: LOCAL_IMPORT_REPO_KEY.into(), repo_path: String::new(), content_hash: String::new(), + installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), + original_dir: Some(original.clone()), update_owner: String::new(), + }; + state.installed.insert("alpha".into(), installed.clone()); + if scenario == "replaced" { + state.instance_targets.insert("alpha".into(), SkillInstanceTarget { + path: original.clone(), identity: skill_path_identity(&source).unwrap(), + }); + } + if scenario == "replaced-record" { + state.instance_records.entry("alpha".into()).or_default().insert( + original.clone(), SkillInstanceRecord { + installed: installed.clone(), + target: SkillInstanceTarget { + path: original.clone(), identity: skill_path_identity(&source).unwrap(), + }, + managed_copies: Vec::new(), + }, + ); + } + if scenario == "other-owner" { + let mut other = installed; + other.original_dir = Some(independent.clone()); + state.instance_records.entry("alpha".into()).or_default().insert( + independent.clone(), SkillInstanceRecord { + installed: other, + target: SkillInstanceTarget { + path: independent.clone(), identity: skill_path_identity(&independent).unwrap(), + }, + managed_copies: vec![SkillInstanceTarget { + path: source.clone(), identity: skill_path_identity(&source).unwrap(), + }], + }, + ); + } + manager.save_state_unlocked(&state).unwrap(); + let selected = if scenario == "unrelated" { &independent } else { &original }; + let remote = vec![candidate(&repo, "skills/alpha", local_repository_hash(selected).unwrap())]; + let preview = manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", selected).unwrap(); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{scenario}: {:?}", result.errors); + let state = manager.load_state(); + let record = state.instance_record("alpha", &selected.canonicalize().unwrap()).unwrap(); + assert!(record.managed_copies.is_empty(), "{scenario}: {:?}", record.managed_copies); + } + } + + #[test] + fn reassociation_preserves_the_maintenance_baseline_and_install_channel() { + for selected_instance in [false, true] { + let (temp, manager, repo) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "shared"); + let original = shared.join("alpha").canonicalize().unwrap(); + let baseline = local_repository_hash(&original).unwrap(); + let remote = vec![candidate(&repo, "skills/alpha", baseline.clone())]; + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap() + }; + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + + let mut state = manager.load_state(); + let configure = |item: &mut InstalledSkill| { + item.update_owner = "in_place".into(); + item.verified_cc_switch = true; + item.install_channel = "cc-switch".into(); + }; + configure(state.installed.get_mut("alpha").unwrap()); + if selected_instance { + configure(&mut state.instance_records.get_mut("alpha").unwrap().values_mut().next().unwrap().installed); + } + manager.save_state_unlocked(&state).unwrap(); + std::fs::write(original.join("details.txt"), "local edit").unwrap(); + let changed = local_repository_hash(&original).unwrap(); + assert_ne!(changed, baseline); + + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap() + }; + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let installed = if selected_instance { + &state.instance_records["alpha"].values().next().unwrap().installed + } else { + &state.installed["alpha"] + }; + assert_eq!(installed.content_hash, baseline); + assert_eq!(installed.update_owner, "in_place"); + assert!(installed.verified_cc_switch); + assert_eq!(installed.install_channel, "cc-switch"); + + let other = SkillRepo { owner: "other".into(), ..repo.clone() }; + manager.save_repo(other.clone(), None).unwrap(); + let other_remote = vec![candidate(&other, "skills/alpha", changed.clone())]; + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&other, &other_remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&other, &other_remote, Some("alpha")).unwrap() + }; + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &other_remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let installed = if selected_instance { + &state.instance_records["alpha"].values().next().unwrap().installed + } else { + &state.installed["alpha"] + }; + assert_eq!(installed.content_hash, changed); + assert!(installed.update_owner.is_empty()); + assert!(installed.verified_cc_switch); + assert_eq!(installed.install_channel, "cc-switch"); + } + } + + #[test] + fn only_root_managed_marker_is_excluded_from_source_matching() { + let (temp, manager, repo) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "shared"); + let skill = shared.join("alpha"); + let original_hash = local_repository_hash(&skill).unwrap(); + std::fs::write(skill.join(MANAGED_COPY_MARKER), "").unwrap(); + assert_eq!(local_repository_hash(&skill).unwrap(), original_hash); + std::fs::create_dir(skill.join("nested")).unwrap(); + std::fs::write(skill.join("nested").join(MANAGED_COPY_MARKER), "user content").unwrap(); + let remote = vec![candidate(&repo, "skills/alpha", original_hash)]; + let preview = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + assert_eq!(preview.matches.len(), 1); + assert!(!preview.matches[0].exact); + } + + #[test] + fn divergent_copies_are_reported_and_never_associated() { + let (temp, manager, repo) = fixture(); + write_skill(&temp.path().join("shared"), "alpha", "shared"); + write_skill(&temp.path().join("claude"), "alpha", "different"); + let remote = vec![candidate(&repo, "skills/alpha", "remote".into())]; + let preview = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + assert_eq!(preview.matches.len(), 1); + assert!(preview.matches[0].conflict.as_ref().unwrap().contains("副本内容不同")); + assert_eq!(preview.matches[0].local_paths.len(), 2); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.applied.is_empty()); + assert!(!manager.load_state().installed.contains_key("alpha")); + } + + #[test] + fn multiple_equal_originals_are_reported_without_guessing() { + let (temp, manager, repo) = fixture(); + write_skill(&temp.path().join("shared"), "alpha", "same"); + write_skill(&temp.path().join("claude"), "alpha", "same"); + let hash = local_repository_hash(&temp.path().join("shared/alpha")).unwrap(); + let remote = vec![candidate(&repo, "skills/alpha", hash)]; + let preview = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + assert!(preview.matches[0].exact); + assert!(preview.matches[0].conflict.as_ref().unwrap().contains("多个独立")); + assert!(manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote) + .unwrap().applied.is_empty()); + } + + #[test] + fn source_evidence_reports_links_and_lock_without_claiming_ownership() { + let (temp, manager, _) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "source"); + std::fs::write(shared.join("alpha/SKILL.md"), "See https://github.com/example/alpha/blob/main/README.md").unwrap(); + std::fs::create_dir_all(shared.join("alpha/scripts")).unwrap(); + std::fs::write(shared.join("alpha/scripts/start.cmd"), "https://github.com/example/engine/releases").unwrap(); + std::fs::write(shared.join("skills-lock.json"), r#"{"skills":{"alpha":{"source":"example/locked"}}}"#).unwrap(); + let evidence = manager.discover_skill_source_evidence("alpha").unwrap(); + assert!(evidence.iter().any(|item| item.repo == "example/alpha" && item.kind == "manifest-link")); + assert!(evidence.iter().any(|item| item.repo == "example/engine" && item.kind == "script-link")); + assert!(evidence.iter().any(|item| item.repo == "example/locked" && item.kind == "lock")); + assert!(!manager.load_state().installed.contains_key("alpha")); + } + + #[test] + fn source_evidence_ignores_parent_git_remote_but_accepts_skill_repository_root() { + let (temp, manager, _) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "source"); + let original = shared.join("alpha"); + let git = |directory: &Path, arguments: &[&str]| { + let mut command = std::process::Command::new("git"); + command.arg("-C").arg(directory).args(arguments); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(crate::windows_create_no_window()); + } + let output = command.output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + }; + git(&shared, &["init", "-q"]); + git(&shared, &["remote", "add", "origin", "https://github.com/example/dotfiles.git"]); + let evidence = manager.discover_skill_source_evidence("alpha").unwrap(); + assert!(!evidence.iter().any(|item| item.kind == "git-remote")); + + git(&original, &["init", "-q"]); + git(&original, &["remote", "add", "origin", "https://github.com/example/alpha.git"]); + let evidence = manager.discover_skill_source_evidence("alpha").unwrap(); + assert!(evidence.iter().any(|item| item.kind == "git-remote" && item.repo == "example/alpha")); + assert!(!evidence.iter().any(|item| item.repo == "example/dotfiles")); + } + + #[test] + fn changed_local_or_remote_contents_reject_stale_confirmation() { + let (temp, manager, repo) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "shared"); + let hash = local_repository_hash(&shared.join("alpha")).unwrap(); + let remote = vec![candidate(&repo, "skills/alpha", hash)]; + let preview = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + let selected = selection(&preview, &preview.matches[0], false); + std::fs::write(shared.join("alpha/details.txt"), "changed").unwrap(); + let result = manager.apply_repository_matches(&[selected.clone()], &remote).unwrap(); + assert!(result.applied.is_empty()); + assert_eq!(result.errors.len(), 1); + let mut changed_remote = remote; + changed_remote[0].content_hash = "changed".into(); + let result = manager.apply_repository_matches(&[selected], &changed_remote).unwrap(); + assert!(result.applied.is_empty()); + assert_eq!(result.errors.len(), 1); + assert!(!manager.load_state().installed.contains_key("alpha")); + } + + #[test] + fn name_only_and_manual_matches_do_not_claim_content_equality() { + let (temp, manager, repo) = fixture(); + write_skill(&temp.path().join("shared"), "alpha", "shared"); + let remote = vec![candidate(&repo, "skills/alpha", "different".into()), + candidate(&repo, "skills/beta", "other".into())]; + let preview = manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap(); + assert_eq!(preview.matches.len(), 1); + assert!(!preview.matches[0].exact); + assert_eq!(preview.manual_candidates.len(), 1); + let denied = manager.apply_repository_matches(&[selection(&preview, &preview.manual_candidates[0], false)], &remote).unwrap(); + assert!(denied.applied.is_empty()); + let accepted = manager.apply_repository_matches(&[selection(&preview, &preview.manual_candidates[0], true)], &remote).unwrap(); + assert_eq!(accepted.applied.len(), 1); + } + + #[test] + fn confirmation_rejects_missing_expired_and_unpreviewed_selections() { + let (temp, manager, repo) = fixture(); + write_skill(&temp.path().join("shared"), "alpha", "shared"); + let remote = vec![candidate(&repo, "skills/alpha", "different".into()), + candidate(&repo, "skills/beta", "other".into())]; + let preview = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + let selected = selection(&preview, &preview.matches[0], false); + + let mut wrong_id = selected.clone(); + wrong_id.preview_id = uuid::Uuid::new_v4().to_string(); + let result = manager.apply_repository_matches(&[wrong_id], &remote).unwrap(); + assert!(result.applied.is_empty()); + assert!(result.errors[0].contains("预览已失效")); + + let mut changed = selected.clone(); + changed.repo_path = "skills/beta".into(); + changed.manual = true; + let result = manager.apply_repository_matches(&[changed], &remote).unwrap(); + assert!(result.applied.is_empty()); + assert!(result.errors[0].contains("不在已确认的预览中")); + + repository_match_previews().retain(|(id, _)| id != &preview.preview_id); + let result = manager.apply_repository_matches(&[selected], &remote).unwrap(); + assert!(result.applied.is_empty()); + assert!(result.errors[0].contains("预览已失效")); + assert!(!manager.load_state().installed.contains_key("alpha")); + } + + #[test] + fn batch_confirmation_uses_each_repository_preview_and_reports_partial_failure() { + let (temp, manager, repo) = fixture(); + let other = SkillRepo { owner: "other".into(), name: "skills".into(), ..repo.clone() }; + manager.save_repo(other.clone(), None).unwrap(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "first"); + write_skill(&shared, "beta", "second"); + let remote = vec![ + candidate(&repo, "skills/alpha", local_repository_hash(&shared.join("alpha")).unwrap()), + candidate(&other, "skills/beta", local_repository_hash(&shared.join("beta")).unwrap()), + ]; + let first = manager.preview_repository_matches(&repo, &remote, None).unwrap(); + let second = manager.preview_repository_matches(&other, &remote, None).unwrap(); + let selections = [ + selection(&first, first.matches.iter().find(|item| item.local_id == "alpha").unwrap(), false), + selection(&second, second.matches.iter().find(|item| item.local_id == "beta").unwrap(), false), + ]; + std::fs::write(shared.join("beta/details.txt"), "changed after preview").unwrap(); + let result = manager.apply_repository_matches(&selections, &remote).unwrap(); + assert_eq!(result.applied.len(), 1); + assert_eq!(result.applied[0].local_id, "alpha"); + assert_eq!(result.errors.len(), 1); + let state = manager.load_state(); + assert_eq!(state.installed["alpha"].repo_key, repo.key()); + assert!(!state.installed.contains_key("beta")); + } +} 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 129859db57..4c4f2a6492 100644 --- a/crates/codex-plus-core/tests/all/extension_manager_data.rs +++ b/crates/codex-plus-core/tests/all/extension_manager_data.rs @@ -202,6 +202,9 @@ fn skills_view_excludes_paths_descriptions_and_uninstalled_entries() { managed: true, locations: Vec::new(), conflict: None, + original_dir: Some("SECRET_ORIGINAL_PATH".into()), + install_channel: "SECRET_CHANNEL".into(), + ..Default::default() }; let mut remote = entry.clone(); remote.installed = false;