diff --git a/CHANGELOG.md b/CHANGELOG.md index e7df353..bfe9920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ version tags such as `v0.3.0`. ## Unreleased - Add collection-level Import Review selection that selects or clears every eligible child in one collection without changing blocked, imported, system, conflicted, or unresolved-type children. +- Recognize copied installer skills as one Installed Source Collection when their + valid v3 GitHub provenance uses a shared package-level `pluginName`. ## 0.9.0 diff --git a/crates/skillbox-core/src/installed_sources.rs b/crates/skillbox-core/src/installed_sources.rs index a540e70..4c42bbb 100644 --- a/crates/skillbox-core/src/installed_sources.rs +++ b/crates/skillbox-core/src/installed_sources.rs @@ -252,18 +252,12 @@ fn parse_lockfile_entry(name: &str, value: &Value, root: &Path) -> Option MAX_LOCKFILE_STRING_BYTES { - return None; - } - if !plugin_name.is_empty() - && !plugin_name.eq_ignore_ascii_case(name) - && !plugin_name.eq_ignore_ascii_case(lockfile_skill_name(&skill_path)?) - { - return None; + if let Some(plugin_name) = object.get("pluginName") { + // The installer may use this optional field for a package or collection + // identifier (for example, several skills can belong to one plugin). + // It is bounded metadata only: the lock entry key and safe skillPath + // remain the authority for matching one scanned child skill. + bounded_string(plugin_name)?; } Some(LockfileEntry { root: root.to_path_buf(), diff --git a/crates/skillbox-core/src/tests.rs b/crates/skillbox-core/src/tests.rs index cf9299a..f056282 100644 --- a/crates/skillbox-core/src/tests.rs +++ b/crates/skillbox-core/src/tests.rs @@ -11010,6 +11010,92 @@ fn scan_import_candidates_groups_validated_installed_source_lockfile_entries() { .all(|child| child.locations.len() == 2)); } +#[test] +fn scan_import_candidates_groups_installer_plugin_children_without_using_plugin_name_as_identity() { + let root = temp_dir("candidate-installed-source-hyperframes-plugin"); + let agents_root = root.join(".agents/skills"); + let claude_root = root.join(".claude/skills"); + let codex_root = root.join(".codex/skills"); + let cursor_root = root.join(".cursor/skills"); + let managed_root = root.join("SkillBox"); + let source_url = "https://github.com/heygen-com/hyperframes.git"; + let names = [ + "hyperframes", + "hyperframes-animation", + "hyperframes-cli", + "hyperframes-core", + ]; + + for name in names { + make_skill( + &agents_root.join(name), + name, + "HyperFrames installer-provenance skill", + ); + for runtime_root in [&claude_root, &codex_root, &cursor_root] { + fs::create_dir_all(runtime_root).unwrap(); + symlink_dir(&agents_root.join(name), &runtime_root.join(name)).unwrap(); + } + } + let skills = names + .iter() + .map(|name| { + ( + (*name).to_string(), + serde_json::json!({ + "sourceType": "github", + "sourceUrl": source_url, + "skillPath": format!("skills/{name}/SKILL.md"), + "pluginName": "core-skills", + "skillFolderHash": "stale-lock-hash" + }), + ) + }) + .collect::>(); + fs::create_dir_all(root.join(".agents")).unwrap(); + fs::write( + root.join(".agents/.skill-lock.json"), + serde_json::to_vec(&serde_json::json!({ "version": 3, "skills": skills })).unwrap(), + ) + .unwrap(); + + let scan = scan_import_candidates( + &[agents_root, claude_root, codex_root, cursor_root], + &managed_root, + ) + .unwrap(); + + let collections = scan + .collections + .iter() + .filter(|collection| { + collection.source_kind == ImportCandidateCollectionSourceKind::InstalledSource + && collection.origin_url.as_deref() + == Some("https://github.com/heygen-com/hyperframes") + }) + .collect::>(); + assert_eq!(collections.len(), 1); + let collection = collections[0]; + assert_eq!(collection.children.len(), names.len()); + assert!(collection + .children + .iter() + .all(|child| { names.contains(&child.name.as_str()) && child.locations.len() == 4 })); + assert!(scan + .standalone_groups + .iter() + .all(|group| !names.contains(&group.name.as_str()))); + assert_eq!( + scan.diagnostics.installed_source_lockfile_entries, + names.len() + ); + assert_eq!( + scan.diagnostics.installed_source_lockfile_matches, + names.len() + ); + assert_eq!(scan.diagnostics.installed_source_collections, 1); +} + #[test] fn scan_import_candidates_keeps_single_installed_source_match_standalone() { let root = temp_dir("candidate-installed-source-lockfile-singleton"); diff --git a/docs/architecture.md b/docs/architecture.md index 90484d8..bd41d5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -229,6 +229,9 @@ repository URL、safe `skillPath` 和已扫描 candidate name,再把相同 sou 路径,不执行网络,不伪造 branch/HEAD,也不允许 `apply_import_collection`; 选中的 child 仍走普通 per-skill import。live Git worktree identity 优先, lockfile hash 不会跳过完整目录 snapshot 校验。 +`pluginName` 仅作为有界 installer package metadata:它可以被多个 child 共享,不能作为 +child identity 或 source grouping authority;entry key、safe `skillPath`、扫描到的 +candidate 和 normalized source URL 才是匹配边界。 因此 nested repositories 是独立 collection,worktree/runtime symlink 解析到同一 child 时只保留一个 child identity,而 Git metadata 外的相似 copy 不会因为内容相似 被声明为成员,只会作为 unlinked location 展示。 diff --git a/docs/workflows.md b/docs/workflows.md index 3ce73ea..e6a2eea 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -143,6 +143,9 @@ Review/apply: 与 safe `skillPath`/name 匹配,并且 `sourceType=github`、source URL 可被 canonicalize 为无凭据 GitHub repository identity 时,才会显示为 `Installed source collection`。 +- `pluginName` 是 installer 的 optional、有界 package metadata,不参与 child identity: + 多个 skill 可共享同一个 plugin name。每个 child 仍必须通过 lockfile entry key、safe + `skillPath`、candidate name/root/path 和 normalized GitHub source URL 的匹配。 - 这类 collection 只用于展示来源和聚合 child,不提供 branch、HEAD、fetch、更新或 `collection-apply`。它不使用 lockfile hash 替代完整 snapshot 校验;用户选中的 child 仍逐项走现有 per-skill Import Review/apply,`.agents` 与 `.claude` 的等价