diff --git a/src/apm_cli/bundle/plugin_exporter.py b/src/apm_cli/bundle/plugin_exporter.py index 096840ff..a71e9319 100644 --- a/src/apm_cli/bundle/plugin_exporter.py +++ b/src/apm_cli/bundle/plugin_exporter.py @@ -9,7 +9,7 @@ import os import shutil import tarfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Dict, List, Optional, Set, Tuple import yaml @@ -63,6 +63,16 @@ def _rename_prompt(name: str) -> str: return name +def _normalize_bare_skill_slug(slug: str) -> str: + """Normalize bare-skill slugs derived from dependency virtual paths.""" + normalized = slug.replace("\\", "/").strip("/") + while normalized.startswith("skills/"): + normalized = normalized[len("skills/") :].lstrip("/") + if normalized == "skills": + return "" + return PurePosixPath(normalized).as_posix() if normalized else "" + + # --------------------------------------------------------------------------- # Component collectors # --------------------------------------------------------------------------- @@ -130,7 +140,7 @@ def _collect_bare_skill( return # Derive a slug: prefer virtual_path (e.g. "frontend-design"), else last # segment of repo_url (e.g. "my-skill" from "owner/my-skill") - slug = (getattr(dep, "virtual_path", "") or "").strip("/") + slug = _normalize_bare_skill_slug(getattr(dep, "virtual_path", "") or "") if not slug: slug = dep.repo_url.rsplit("/", 1)[-1] if dep.repo_url else "skill" for f in sorted(install_path.iterdir()): diff --git a/tests/unit/test_plugin_exporter.py b/tests/unit/test_plugin_exporter.py index 0d2372d1..2f7697c3 100644 --- a/tests/unit/test_plugin_exporter.py +++ b/tests/unit/test_plugin_exporter.py @@ -310,6 +310,24 @@ def test_virtual_path_used_as_slug(self, tmp_path): _collect_bare_skill(tmp_path, dep, out) assert any(r.startswith("skills/frontend-design/") for _, r in out) + def test_skills_prefix_stripped_from_virtual_path(self, tmp_path): + """A skills/ virtual path should not produce skills/skills/ nesting.""" + from apm_cli.bundle.plugin_exporter import _collect_bare_skill + + (tmp_path / "SKILL.md").write_text("# Jest") + dep = LockedDependency( + repo_url="github/awesome-copilot", + resolved_commit="abc123", + depth=1, + virtual_path="skills/javascript-typescript-jest", + is_virtual=True, + ) + out: list = [] + _collect_bare_skill(tmp_path, dep, out) + rel_paths = [r for _, r in out] + assert "skills/javascript-typescript-jest/SKILL.md" in rel_paths + assert not any(r.startswith("skills/skills/") for r in rel_paths) + def test_skips_when_no_skill_md(self, tmp_path): """No SKILL.md at root means nothing collected.""" from apm_cli.bundle.plugin_exporter import _collect_bare_skill @@ -634,6 +652,42 @@ def test_dependency_components_included(self, tmp_path): assert (result.bundle_path / "agents" / "dep-agent.agent.md").exists() assert (result.bundle_path / "agents" / "own.agent.md").exists() + def test_virtual_skill_dependency_does_not_duplicate_skills_dir(self, tmp_path): + project = _setup_plugin_project(tmp_path) + + dep = LockedDependency( + repo_url="github/awesome-copilot", + depth=1, + resolved_commit="abc123", + virtual_path="skills/javascript-typescript-jest", + is_virtual=True, + ) + _write_lockfile(project, [dep]) + dep_path = ( + project + / "apm_modules" + / "github" + / "awesome-copilot" + / "skills" + / "javascript-typescript-jest" + ) + dep_path.mkdir(parents=True) + (dep_path / "SKILL.md").write_text("# Jest", encoding="utf-8") + + out = tmp_path / "build" + result = export_plugin_bundle(project, out) + + assert ( + result.bundle_path / "skills" / "javascript-typescript-jest" / "SKILL.md" + ).exists() + assert not ( + result.bundle_path + / "skills" + / "skills" + / "javascript-typescript-jest" + / "SKILL.md" + ).exists() + def test_dev_dependency_excluded(self, tmp_path): project = _setup_plugin_project( tmp_path,