From bb3f14322fcd9fb7618d63e281557caa7d4a3e48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:45:17 +0000 Subject: [PATCH 1/4] Fix converge-missing-manifest: track generated skill files Apply the remediation from the bug assessment on issue #4273. Refs #4273 Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) --- src/specify_cli/integrations/base.py | 22 +++++++----- .../test_integration_base_skills.py | 35 +++++++++++++++++++ tests/integrations/test_integration_claude.py | 16 +++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index b3dd0abc34..2572159f07 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -680,15 +680,8 @@ def write_file_and_record( writing. Returns *dest*. """ normalized = content.replace("\r\n", "\n") - from ._file_changes import after_file_change, before_file_change - - before_file_change(dest) - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(normalized.encode("utf-8")) - after_file_change(dest) rel = dest.resolve().relative_to(project_root.resolve()) - manifest.record_existing(rel) - return dest + return manifest.record_file(rel, normalized.encode("utf-8")) def integration_scripts_dir(self) -> Path | None: """Return path to this integration's bundled ``scripts/`` directory. @@ -1951,6 +1944,19 @@ def setup( ) created.append(dst) + created_skill_files = { + path.resolve().relative_to(project_root_resolved).as_posix() + for path in created + if path.name == "SKILL.md" + } + tracked_skill_files = { + path for path in manifest.files if path.endswith("/SKILL.md") + } + if created_skill_files != tracked_skill_files: + raise RuntimeError( + "Skills setup produced a manifest that does not track every " + "generated SKILL.md file" + ) # Install agent runtime events event_files = self.emit_events( diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 015152be01..f365de5e27 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -86,6 +86,41 @@ def test_setup_writes_to_correct_directory(self, tmp_path): f"{f} is not under {expected_dir}" ) + def test_all_skill_files_are_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + + created_skill_files = { + path.relative_to(tmp_path).as_posix() + for path in created + if path.name == "SKILL.md" + } + tracked_skill_files = { + path for path in m.files if path.endswith("/SKILL.md") + } + assert tracked_skill_files == created_skill_files + + def test_reinit_tracks_new_skill_over_existing_install(self, tmp_path): + i = get_integration(self.KEY) + skills_dir = i.skills_dest(tmp_path) + for command in ( + "analyze", "clarify", "constitution", "implement", "plan", + "checklist", "specify", "tasks", "taskstoissues", + ): + skill_file = skills_dir / f"speckit-{command}" / "SKILL.md" + skill_file.parent.mkdir(parents=True, exist_ok=True) + skill_file.write_text("existing skill\n", encoding="utf-8") + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert any(path.parent.name == "speckit-converge" for path in skill_files) + assert len([path for path in m.files if path.endswith("/SKILL.md")]) == len( + skill_files + ) + def test_skill_directory_structure(self, tmp_path): """Each command produces speckit-/SKILL.md.""" i = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index f275737075..2e98ea0cb2 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -79,6 +79,22 @@ def test_setup_creates_skill_files(self, tmp_path): assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" + def test_setup_tracks_every_skill_in_manifest(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + created_skill_files = { + path.relative_to(tmp_path).as_posix() + for path in created + if path.name == "SKILL.md" + } + tracked_skill_files = { + path for path in manifest.files if path.endswith("/SKILL.md") + } + assert tracked_skill_files == created_skill_files + assert ".claude/skills/speckit-converge/SKILL.md" in manifest.files + def test_render_skill_unicode(self): """Test rendering a skill preserves non-ASCII characters.""" integration = get_integration("claude") From 9555b02218267b64efb8ee280b8b9283019c21a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:16:28 +0000 Subject: [PATCH 2/4] Harden skill manifest tracking Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 24 +++++++---- tests/integrations/test_base.py | 41 +++++++++++++++++++ .../test_integration_base_skills.py | 20 --------- tests/integrations/test_integration_claude.py | 28 +++++++++++++ tests/integrations/test_integration_hermes.py | 34 +++++++++++++++ 5 files changed, 120 insertions(+), 27 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 2572159f07..3851ffe5e3 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -680,8 +680,16 @@ def write_file_and_record( writing. Returns *dest*. """ normalized = content.replace("\r\n", "\n") - rel = dest.resolve().relative_to(project_root.resolve()) - return manifest.record_file(rel, normalized.encode("utf-8")) + dest_absolute = dest if dest.is_absolute() else Path.cwd() / dest + project_root_absolute = ( + project_root if project_root.is_absolute() else Path.cwd() / project_root + ) + try: + rel = dest_absolute.relative_to(project_root_absolute) + except ValueError: + rel = dest_absolute.relative_to(project_root.resolve()) + manifest.record_file(rel, normalized.encode("utf-8")) + return dest def integration_scripts_dir(self) -> Path | None: """Return path to this integration's bundled ``scripts/`` directory. @@ -1832,9 +1840,9 @@ def setup( f"project_root ({project_root_resolved})" ) - skills_dir = self.skills_dest(project_root).resolve() + skills_dir = self.skills_dest(project_root) try: - skills_dir.relative_to(project_root_resolved) + skills_dir.resolve().relative_to(project_root_resolved) except ValueError as exc: raise ValueError( f"Skills destination {skills_dir} escapes " @@ -1945,17 +1953,19 @@ def setup( created.append(dst) created_skill_files = { - path.resolve().relative_to(project_root_resolved).as_posix() + path.relative_to(project_root).as_posix() for path in created if path.name == "SKILL.md" } tracked_skill_files = { path for path in manifest.files if path.endswith("/SKILL.md") } - if created_skill_files != tracked_skill_files: + missing_skill_files = created_skill_files - tracked_skill_files + if missing_skill_files: raise RuntimeError( "Skills setup produced a manifest that does not track every " - "generated SKILL.md file" + "generated SKILL.md file: " + + ", ".join(sorted(missing_skill_files)) ) # Install agent runtime events diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 66faeb2b1b..98daf40c21 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -200,6 +200,47 @@ def test_write_file_and_record(self, tmp_path): assert dest.read_text(encoding="utf-8") == "content" assert "sub/f.txt" in m.files + @pytest.mark.parametrize("link_type", ["leaf", "ancestor"]) + def test_write_file_and_record_rejects_project_local_symlink( + self, tmp_path, link_type + ): + target_dir = tmp_path / "user-data" + target_dir.mkdir() + target = target_dir / "SKILL.md" + target.write_text("user content", encoding="utf-8") + dest_dir = tmp_path / ".claude" / "skills" / "speckit-plan" + if link_type == "leaf": + dest_dir.mkdir(parents=True) + dest = dest_dir / "SKILL.md" + dest.symlink_to(target) + else: + dest_dir.parent.mkdir(parents=True) + dest_dir.symlink_to(target_dir, target_is_directory=True) + dest = dest_dir / "SKILL.md" + manifest = IntegrationManifest("claude", tmp_path) + + with pytest.raises(ValueError, match="symlinked"): + IntegrationBase.write_file_and_record( + "replacement", dest, tmp_path, manifest + ) + + assert target.read_text(encoding="utf-8") == "user content" + assert manifest.files == {} + + def test_write_file_and_record_supports_aliased_project_root(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + alias = tmp_path / "project-alias" + alias.symlink_to(project, target_is_directory=True) + manifest = IntegrationManifest("test", alias) + dest = alias / "sub" / "f.txt" + + result = IntegrationBase.write_file_and_record("content", dest, alias, manifest) + + assert result == dest + assert result.read_text(encoding="utf-8") == "content" + assert "sub/f.txt" in manifest.files + def test_setup_copies_shared_templates(self, tmp_path): i = StubIntegration() m = IntegrationManifest("stub", tmp_path) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index f365de5e27..febce5cc22 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -101,26 +101,6 @@ def test_all_skill_files_are_tracked_in_manifest(self, tmp_path): } assert tracked_skill_files == created_skill_files - def test_reinit_tracks_new_skill_over_existing_install(self, tmp_path): - i = get_integration(self.KEY) - skills_dir = i.skills_dest(tmp_path) - for command in ( - "analyze", "clarify", "constitution", "implement", "plan", - "checklist", "specify", "tasks", "taskstoissues", - ): - skill_file = skills_dir / f"speckit-{command}" / "SKILL.md" - skill_file.parent.mkdir(parents=True, exist_ok=True) - skill_file.write_text("existing skill\n", encoding="utf-8") - - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - - skill_files = [path for path in created if path.name == "SKILL.md"] - assert any(path.parent.name == "speckit-converge" for path in skill_files) - assert len([path for path in m.files if path.endswith("/SKILL.md")]) == len( - skill_files - ) - def test_skill_directory_structure(self, tmp_path): """Each command produces speckit-/SKILL.md.""" i = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 2e98ea0cb2..48b3713210 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -95,6 +95,34 @@ def test_setup_tracks_every_skill_in_manifest(self, tmp_path): assert tracked_skill_files == created_skill_files assert ".claude/skills/speckit-converge/SKILL.md" in manifest.files + def test_setup_fails_when_generated_skill_is_not_recorded( + self, tmp_path, monkeypatch + ): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + + def write_without_recording(content, dest, project_root, manifest): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + return dest + + monkeypatch.setattr( + integration, "write_file_and_record", write_without_recording + ) + + with pytest.raises(RuntimeError, match="does not track every generated"): + integration.setup(tmp_path, manifest, script_type="sh") + + def test_setup_allows_additional_manifest_owned_skill(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + extra_skill = ".claude/skills/user-notes/SKILL.md" + manifest.record_file(extra_skill, "user-owned skill\n") + + integration.setup(tmp_path, manifest, script_type="sh") + + assert extra_skill in manifest.files + def test_render_skill_unicode(self): """Test rendering a skill preserves non-ASCII characters.""" integration = get_integration("claude") diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index b2106050b7..a3f7e41adf 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -71,6 +71,40 @@ def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch): """Override: Hermes writes to global, not project-local.""" self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch) + def test_all_skill_files_are_tracked_in_manifest(self, tmp_path, monkeypatch): + """Override: globally installed Hermes skills are not project-owned.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + + assert created + assert all(f.is_relative_to(home) for f in created) + assert not any(path.endswith("/SKILL.md") for path in m.files) + + def test_reinit_tracks_new_skill_over_existing_install(self, tmp_path, monkeypatch): + """Override: Hermes skills stay global and untracked on re-init.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + skills_dir = home / ".hermes" / "skills" + for command in ( + "analyze", "clarify", "constitution", "implement", "plan", + "checklist", "specify", "tasks", "taskstoissues", + ): + skill_file = skills_dir / f"speckit-{command}" / "SKILL.md" + skill_file.parent.mkdir(parents=True, exist_ok=True) + skill_file.write_text("existing skill\n", encoding="utf-8") + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + + assert any(path.parent.name == "speckit-converge" for path in created) + assert not any(path.endswith("/SKILL.md") for path in m.files) + def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch): """The core plan skill must not carry a context-file placeholder — agent context files are owned by the opt-in agent-context extension.""" From 41ef38a4c7cae381a77e59bfb449e663dadf62c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:27:10 +0000 Subject: [PATCH 3/4] Fix integration review findings Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 9 +++--- .../test_integration_base_skills.py | 10 +++---- tests/integrations/test_integration_claude.py | 30 +++++++++++++++---- tests/specify_cli/integrations/_helpers.py | 5 ++++ .../integrations/test_command_upgrade.py | 17 +++++++++-- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 3851ffe5e3..37ce16bdcd 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1952,14 +1952,13 @@ def setup( ) created.append(dst) + resolved_project_root = project_root.resolve() created_skill_files = { - path.relative_to(project_root).as_posix() + path.resolve().relative_to(resolved_project_root).as_posix() for path in created - if path.name == "SKILL.md" - } - tracked_skill_files = { - path for path in manifest.files if path.endswith("/SKILL.md") + if path.name.casefold() == "skill.md" } + tracked_skill_files = set(manifest.files) missing_skill_files = created_skill_files - tracked_skill_files if missing_skill_files: raise RuntimeError( diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index febce5cc22..0351e1c825 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -92,14 +92,12 @@ def test_all_skill_files_are_tracked_in_manifest(self, tmp_path): created = i.setup(tmp_path, m) created_skill_files = { - path.relative_to(tmp_path).as_posix() + path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created - if path.name == "SKILL.md" + if path.name.casefold() == "skill.md" } - tracked_skill_files = { - path for path in m.files if path.endswith("/SKILL.md") - } - assert tracked_skill_files == created_skill_files + tracked_skill_files = set(m.files) + assert created_skill_files <= tracked_skill_files def test_skill_directory_structure(self, tmp_path): """Each command produces speckit-/SKILL.md.""" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 48b3713210..60b8d6149d 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -85,16 +85,34 @@ def test_setup_tracks_every_skill_in_manifest(self, tmp_path): created = integration.setup(tmp_path, manifest, script_type="sh") created_skill_files = { - path.relative_to(tmp_path).as_posix() + path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created - if path.name == "SKILL.md" + if path.name.casefold() == "skill.md" } - tracked_skill_files = { - path for path in manifest.files if path.endswith("/SKILL.md") - } - assert tracked_skill_files == created_skill_files + tracked_skill_files = set(manifest.files) + assert created_skill_files <= tracked_skill_files assert ".claude/skills/speckit-converge/SKILL.md" in manifest.files + @pytest.mark.skipif(os.name != "nt", reason="Windows path casing behavior") + def test_setup_tracks_skills_in_existing_mixed_case_paths(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + existing_skill = ( + tmp_path / ".CLAUDE" / "Skills" / "SPECKIT-PLAN" / "skill.md" + ) + existing_skill.parent.mkdir(parents=True) + existing_skill.write_text("existing skill\n", encoding="utf-8") + + created = integration.setup(tmp_path, manifest, script_type="sh") + + created_skill_files = { + path.resolve().relative_to(tmp_path.resolve()).as_posix() + for path in created + if path.name.casefold() == "skill.md" + } + assert created_skill_files <= set(manifest.files) + assert ".CLAUDE/Skills/SPECKIT-PLAN/skill.md" in manifest.files + def test_setup_fails_when_generated_skill_is_not_recorded( self, tmp_path, monkeypatch ): diff --git a/tests/specify_cli/integrations/_helpers.py b/tests/specify_cli/integrations/_helpers.py index 10e8cf8ee0..5a52315da0 100644 --- a/tests/specify_cli/integrations/_helpers.py +++ b/tests/specify_cli/integrations/_helpers.py @@ -63,6 +63,11 @@ def _move_kilocode_install_to_legacy_layout(project): path.replace(".kilo/commands/", ".kilocode/workflows/"): info for path, info in manifest_data.get("files", {}).items() } + if "ownership_modes" in manifest_data: + manifest_data["ownership_modes"] = { + path.replace(".kilo/commands/", ".kilocode/workflows/"): mode + for path, mode in manifest_data["ownership_modes"].items() + } manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") return canonical, legacy diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 42d5ea0a6c..45de0f5e2d 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -240,6 +240,11 @@ def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): for path, info in manifest_data.get("files", {}).items(): patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info manifest_data["files"] = patched_files + if "ownership_modes" in manifest_data: + manifest_data["ownership_modes"] = { + path.replace(".opencode/commands/", ".opencode/command/"): mode + for path, mode in manifest_data["ownership_modes"].items() + } manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") old_commands = sorted(legacy.glob("speckit.*.md")) @@ -312,15 +317,21 @@ def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): ) manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) legacy_manifest_files = {} + legacy_ownership_modes = {} for path, info in manifest_data["files"].items(): skill_path = project / path command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1) legacy_path = commands / f"{command_name}.md" legacy_path.write_bytes(skill_path.read_bytes()) - legacy_manifest_files[ - legacy_path.relative_to(project).as_posix() - ] = info + legacy_relative_path = legacy_path.relative_to(project).as_posix() + legacy_manifest_files[legacy_relative_path] = info + if path in manifest_data.get("ownership_modes", {}): + legacy_ownership_modes[legacy_relative_path] = manifest_data[ + "ownership_modes" + ][path] manifest_data["files"] = legacy_manifest_files + if "ownership_modes" in manifest_data: + manifest_data["ownership_modes"] = legacy_ownership_modes manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") registry_path = project / ".specify" / "extensions" / ".registry" From de2981edfde93ca97e0c2c9f69110f0df15099e6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:53:34 -0500 Subject: [PATCH 4/4] test(integrations): cover skill path safeguards Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e0d9003f-9248-4020-b83d-24897c0406ef --- tests/integrations/test_base.py | 7 ++++++- tests/integrations/test_integration_claude.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 98daf40c21..6b4f79b39d 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -227,13 +227,18 @@ def test_write_file_and_record_rejects_project_local_symlink( assert target.read_text(encoding="utf-8") == "user content" assert manifest.files == {} - def test_write_file_and_record_supports_aliased_project_root(self, tmp_path): + @pytest.mark.parametrize("resolve_dest", [False, True]) + def test_write_file_and_record_supports_aliased_project_root( + self, tmp_path, resolve_dest + ): project = tmp_path / "project" project.mkdir() alias = tmp_path / "project-alias" alias.symlink_to(project, target_is_directory=True) manifest = IntegrationManifest("test", alias) dest = alias / "sub" / "f.txt" + if resolve_dest: + dest = dest.resolve() result = IntegrationBase.write_file_and_record("content", dest, alias, manifest) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 60b8d6149d..69b0cf47dc 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -131,6 +131,24 @@ def write_without_recording(content, dest, project_root, manifest): with pytest.raises(RuntimeError, match="does not track every generated"): integration.setup(tmp_path, manifest, script_type="sh") + def test_setup_rejects_project_local_skills_symlink(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + target = tmp_path / "linked-skills" + target.mkdir() + skills_dir = tmp_path / ".claude" / "skills" + skills_dir.parent.mkdir() + try: + skills_dir.symlink_to(target, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + with pytest.raises(ValueError, match="symlinked"): + integration.setup(tmp_path, manifest, script_type="sh") + + assert list(target.iterdir()) == [] + assert manifest.files == {} + def test_setup_allows_additional_manifest_owned_skill(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path)