Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 6 additions & 12 deletions crates/skillbox-core/src/installed_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,12 @@ fn parse_lockfile_entry(name: &str, value: &Value, root: &Path) -> Option<Lockfi
let source_url = skillbox_github::normalize_github_repo_url(&source_url).ok()?;
let skill_path = bounded_string(object.get("skillPath")?)?;
validate_lockfile_skill_path(&skill_path, name)?;
let plugin_name = object
.get("pluginName")
.and_then(|value| value.as_str())
.unwrap_or("");
if plugin_name.len() > 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(),
Expand Down
86 changes: 86 additions & 0 deletions crates/skillbox-core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Map<_, _>>();
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::<Vec<_>>();
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");
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 展示。
Expand Down
3 changes: 3 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的等价
Expand Down