Skip to content
Open
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
35 changes: 25 additions & 10 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,14 +680,15 @@ 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)
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"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the Kilo, OpenCode, and Qoder legacy fixtures to remap ownership metadata with their file paths. Commit: 41ef38a.

return dest

def integration_scripts_dir(self) -> Path | None:
Expand Down Expand Up @@ -1839,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 "
Expand Down Expand Up @@ -1951,6 +1952,20 @@ def setup(
)
created.append(dst)

resolved_project_root = project_root.resolve()
created_skill_files = {
path.resolve().relative_to(resolved_project_root).as_posix()
for path in created
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(
"Skills setup produced a manifest that does not track every "
"generated SKILL.md file: "
+ ", ".join(sorted(missing_skill_files))
)

# Install agent runtime events
event_files = self.emit_events(
Expand Down
46 changes: 46 additions & 0 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,52 @@ 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 == {}

@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)

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)
Expand Down
13 changes: 13 additions & 0 deletions tests/integrations/test_integration_base_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ 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.resolve().relative_to(tmp_path.resolve()).as_posix()
for path in created
if path.name.casefold() == "skill.md"
}
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-<name>/SKILL.md."""
i = get_integration(self.KEY)
Expand Down
80 changes: 80 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,86 @@ 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.resolve().relative_to(tmp_path.resolve()).as_posix()
for path in created
if path.name.casefold() == "skill.md"
}
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
):
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_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)
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")
Expand Down
34 changes: 34 additions & 0 deletions tests/integrations/test_integration_hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 5 additions & 0 deletions tests/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 14 additions & 3 deletions tests/specify_cli/integrations/test_command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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"
Expand Down
Loading