"""Tests for ClaudeIntegration.""" import json import os from pathlib import Path from unittest.mock import patch import yaml from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import IntegrationBase, SkillsIntegration from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from specify_cli.integrations.manifest import IntegrationManifest class TestClaudeIntegration: def test_registered(self): assert "claude" in INTEGRATION_REGISTRY assert get_integration("claude") is not None def test_is_base_integration(self): assert isinstance(get_integration("claude"), IntegrationBase) def test_config_uses_skills(self): integration = get_integration("claude") assert integration.config["folder"] == ".claude/" assert integration.config["commands_subdir"] == "skills" def test_registrar_config_uses_skill_layout(self): integration = get_integration("claude") assert integration.registrar_config["dir"] == ".claude/skills" assert integration.registrar_config["format"] == "markdown" assert integration.registrar_config["args"] == "$ARGUMENTS" assert integration.registrar_config["extension"] == "/SKILL.md" def test_setup_creates_skill_files(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) created = integration.setup(tmp_path, manifest, script_type="sh") skill_files = [path for path in created if path.name == "SKILL.md"] assert skill_files skills_dir = tmp_path / ".claude" / "skills" assert skills_dir.is_dir() plan_skill = skills_dir / "speckit-plan" / "SKILL.md" assert plan_skill.exists() content = plan_skill.read_text(encoding="utf-8") assert "{SCRIPT}" not in content assert "{ARGS}" not in content assert "__AGENT__" not in content assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." parts = content.split("---", 2) parsed = yaml.safe_load(parts[1]) assert parsed["name"] == "speckit-plan" assert parsed["user-invocable"] is True assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" def test_render_skill_unicode(self): """Test rendering a skill preserves non-ASCII characters.""" integration = get_integration("claude") rendered = integration._render_skill( "constitution", {"description": "Prüfe Konformität der Implementierung"}, "Body", ) assert "Prüfe Konformität" in rendered def test_setup_does_not_write_context_section(self, tmp_path): """The CLI no longer manages the agent context file — that is owned by the opt-in agent-context extension. Setup must not create or touch it.""" integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) integration.setup(tmp_path, manifest, script_type="sh") for path in tmp_path.rglob("*"): if path.is_file(): text = path.read_text(encoding="utf-8", errors="ignore") assert "" not in text def test_teardown_does_not_touch_existing_context_file(self, tmp_path): """A user-authored context file is left intact on teardown.""" integration = get_integration("claude") ctx_path = tmp_path / "CLAUDE.md" original = "# CLAUDE.md\n\nUser content.\n" ctx_path.write_text(original, encoding="utf-8") manifest = IntegrationManifest("claude", tmp_path) integration.setup(tmp_path, manifest, script_type="sh") integration.teardown(tmp_path, manifest) assert ctx_path.read_text(encoding="utf-8") == original def test_integration_flag_creates_skill_files_cli(self, tmp_path): from typer.testing import CliRunner from specify_cli import app project = tmp_path / "claude-promote" project.mkdir() old_cwd = os.getcwd() try: os.chdir(project) runner = CliRunner() result = runner.invoke( app, [ "init", "--here", "--integration", "claude", "--script", "sh", "--ignore-agent-tools", ], catch_exceptions=False, ) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() assert not (project / ".claude" / "commands").exists() init_options = json.loads( (project / ".specify" / "init-options.json").read_text(encoding="utf-8") ) assert init_options["ai"] == "claude" assert init_options["ai_skills"] is True assert init_options["integration"] == "claude" def test_integration_flag_creates_skill_files(self, tmp_path): from typer.testing import CliRunner from specify_cli import app project = tmp_path / "claude-integration" project.mkdir() old_cwd = os.getcwd() try: os.chdir(project) runner = CliRunner() result = runner.invoke( app, [ "init", "--here", "--integration", "claude", "--script", "sh", "--ignore-agent-tools", ], catch_exceptions=False, ) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() def test_interactive_claude_selection_uses_integration_path(self, tmp_path): from typer.testing import CliRunner from specify_cli import app project = tmp_path / "claude-interactive" project.mkdir() old_cwd = os.getcwd() try: os.chdir(project) runner = CliRunner() with ( patch("specify_cli.commands.init._stdin_is_interactive", return_value=True), patch("specify_cli.commands.init.select_with_arrows", return_value="claude"), ): result = runner.invoke( app, [ "init", "--here", "--script", "sh", "--ignore-agent-tools", ], catch_exceptions=False, ) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert (project / ".specify" / "integration.json").exists() assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" assert skill_file.exists() skill_content = skill_file.read_text(encoding="utf-8") assert "user-invocable: true" in skill_content assert "disable-model-invocation: false" in skill_content init_options = json.loads( (project / ".specify" / "init-options.json").read_text(encoding="utf-8") ) assert init_options["ai"] == "claude" assert init_options["ai_skills"] is True assert init_options["integration"] == "claude" def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): """Claude init should succeed even without install_skills.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() target = tmp_path / "fail-proj" result = runner.invoke( app, ["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"], ) assert result.exit_code == 0 assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() def test_claude_hooks_render_skill_invocation(self, tmp_path): from specify_cli.extensions import HookExecutor project = tmp_path / "claude-hooks" project.mkdir() init_options = project / ".specify" / "init-options.json" init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True})) hook_executor = HookExecutor(project) message = hook_executor.format_hook_message( "before_plan", [ { "extension": "test-ext", "command": "speckit.plan", "optional": False, } ], ) assert "Executing: `/speckit-plan`" in message assert "EXECUTE_COMMAND: speckit.plan" in message assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): from specify_cli import save_init_options from specify_cli.presets import PresetManager project = tmp_path / "claude-preset-skill" project.mkdir() save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"}) skills_dir = project / ".claude" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) preset_dir = tmp_path / "claude-skill-command" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.research.md").write_text( "---\n" "description: Research workflow\n" "---\n\n" "preset:claude-skill-command\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "claude-skill-command", "name": "Claude Skill Command", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.research", "file": "commands/speckit.research.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-research" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text(encoding="utf-8") assert "preset:claude-skill-command" in content assert "name: speckit-research" in content assert "user-invocable: true" in content assert "disable-model-invocation: false" in content metadata = manager.registry.get("claude-skill-command") assert "speckit-research" in metadata.get("registered_skills", []) class TestClaudeArgumentHints: """Verify that argument-hint frontmatter is injected for Claude skills.""" def test_converge_has_no_argument_hint(self): """Converge should not advertise unsupported feature-name arguments.""" assert "converge" not in ARGUMENT_HINTS def test_all_skills_have_hints(self, tmp_path): """Every skill with a configured hint must contain an argument-hint line.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] assert len(skill_files) > 0 for f in skill_files: stem = f.parent.name if stem.startswith("speckit-"): stem = stem[len("speckit-"):] content = f.read_text(encoding="utf-8") if stem in ARGUMENT_HINTS: assert "argument-hint:" in content, ( f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" ) else: assert "argument-hint:" not in content, ( f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" ) def test_hints_match_expected_values(self, tmp_path): """Each skill's argument-hint must match the expected text.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] for f in skill_files: # Extract stem: speckit-plan -> plan stem = f.parent.name if stem.startswith("speckit-"): stem = stem[len("speckit-"):] expected_hint = ARGUMENT_HINTS.get(stem) content = f.read_text(encoding="utf-8") if expected_hint is None: assert "argument-hint:" not in content, ( f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" ) else: assert f'argument-hint: "{expected_hint}"' in content, ( f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" ) def test_hint_is_inside_frontmatter(self, tmp_path): """argument-hint must appear between the --- delimiters, not in the body.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] for f in skill_files: content = f.read_text(encoding="utf-8") parts = content.split("---", 2) assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" frontmatter = parts[1] body = parts[2] stem = f.parent.name if stem.startswith("speckit-"): stem = stem[len("speckit-"):] if stem in ARGUMENT_HINTS: assert "argument-hint:" in frontmatter, ( f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" ) assert "argument-hint:" not in body, ( f"{f.parent.name}/SKILL.md: argument-hint leaked into body" ) else: assert "argument-hint:" not in content, ( f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" ) def test_hint_appears_after_description(self, tmp_path): """argument-hint must immediately follow the description line.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] for f in skill_files: content = f.read_text(encoding="utf-8") lines = content.splitlines() stem = f.parent.name if stem.startswith("speckit-"): stem = stem[len("speckit-"):] if stem not in ARGUMENT_HINTS: assert "argument-hint:" not in content, ( f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" ) continue found_description = False for idx, line in enumerate(lines): if line.startswith("description:"): found_description = True assert idx + 1 < len(lines), ( f"{f.parent.name}/SKILL.md: description is last line" ) assert lines[idx + 1].startswith("argument-hint:"), ( f"{f.parent.name}/SKILL.md: argument-hint does not follow description" ) break assert found_description, ( f"{f.parent.name}/SKILL.md: no description: line found in output" ) def test_inject_argument_hint_only_in_frontmatter(self): """inject_argument_hint must not modify description: lines in the body.""" from specify_cli.integrations.claude import ClaudeIntegration content = ( "---\n" "description: My command\n" "---\n" "\n" "description: this is body text\n" ) result = ClaudeIntegration.inject_argument_hint(content, "Test hint") lines = result.splitlines() hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1, ( f"Expected exactly 1 argument-hint line, found {hint_count}" ) def test_inject_argument_hint_skips_if_already_present(self): """inject_argument_hint must not duplicate if argument-hint already exists.""" from specify_cli.integrations.claude import ClaudeIntegration content = ( "---\n" "description: My command\n" 'argument-hint: "Existing hint"\n' "---\n" "\n" "Body text\n" ) result = ClaudeIntegration.inject_argument_hint(content, "New hint") assert result == content, "Content should be unchanged when hint already exists" lines = result.splitlines() hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 class TestClaudeDisableModelInvocation: """Verify disable-model-invocation is false for Claude skills.""" def test_setup_sets_disable_model_invocation_false(self, tmp_path): """Generated SKILL.md files must have disable-model-invocation: false.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] assert len(skill_files) > 0 for f in skill_files: content = f.read_text(encoding="utf-8") parts = content.split("---", 2) parsed = yaml.safe_load(parts[1]) assert parsed["disable-model-invocation"] is False, ( f"{f.parent.name}: expected disable-model-invocation: false" ) def test_disable_model_invocation_not_true(self, tmp_path): """No Claude skill should have disable-model-invocation: true.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") for f in created: if f.name != "SKILL.md": continue content = f.read_text(encoding="utf-8") assert "disable-model-invocation: true" not in content, ( f"{f.parent.name}: must not have disable-model-invocation: true" ) def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path): """Non-Claude skill agents should not get disable-model-invocation.""" from specify_cli.agents import CommandRegistrar fm = CommandRegistrar.build_skill_frontmatter( "codex", "speckit-plan", "desc", "templates/commands/plan.md" ) assert "disable-model-invocation" not in fm assert "user-invocable" not in fm def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path): """SkillsIntegration agents without an override preserve non-hook content.""" # ``agy`` is a plain SkillsIntegration with no post-process override, # so it stands in for the base-class default behavior. agy = get_integration("agy") if agy is None: return # agy not registered in this build content = "---\nname: test\n---\nBody" assert agy.post_process_skill_content(content) == content class TestClaudeForkContext: """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" def test_no_commands_fork_by_default(self): """FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork. ``analyze`` was removed (#3185) because its verbose report defeated the purpose of forking and compounded context overhead across repeated runs. """ assert FORK_CONTEXT_COMMANDS == {} def test_analyze_skill_does_not_fork(self, tmp_path): """speckit-analyze must run in the main session, not a forked subagent (#3185).""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) i.setup(tmp_path, m, script_type="sh") analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" assert analyze_skill.exists() content = analyze_skill.read_text(encoding="utf-8") parts = content.split("---", 2) parsed = yaml.safe_load(parts[1]) assert "context" not in parsed assert "agent" not in parsed def test_no_skills_fork(self, tmp_path): """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] for f in skill_files: stem = f.parent.name if stem.startswith("speckit-"): stem = stem[len("speckit-"):] if stem in FORK_CONTEXT_COMMANDS: continue content = f.read_text(encoding="utf-8") parts = content.split("---", 2) parsed = yaml.safe_load(parts[1]) assert "context" not in parsed, ( f"{f.parent.name}: must not have context frontmatter" ) assert "agent" not in parsed, ( f"{f.parent.name}: must not have agent frontmatter" ) def test_post_process_no_fork_for_skills(self): """With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent.""" i = get_integration("claude") for name in ("speckit-analyze", "speckit-plan"): content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n' result = i.post_process_skill_content(content) parsed = yaml.safe_load(result.split("---", 2)[1]) assert "context" not in parsed assert "agent" not in parsed def test_fork_mechanism_injects_when_configured(self, monkeypatch): """The injection mechanism still works for any command added to FORK_CONTEXT_COMMANDS, even though none ships enabled by default.""" import specify_cli.integrations.claude as claude_mod monkeypatch.setitem( claude_mod.FORK_CONTEXT_COMMANDS, "analyze", {"context": "fork", "agent": "general-purpose"}, ) i = get_integration("claude") content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' result = i.post_process_skill_content(content) parts = result.split("---", 2) parsed = yaml.safe_load(parts[1]) assert parsed.get("context") == "fork" assert parsed.get("agent") == "general-purpose" # Flags must land in the frontmatter, not the body. assert "context: fork" in parts[1] assert "context: fork" not in parts[2] # Re-running must not duplicate the injected keys. twice = i.post_process_skill_content(result) assert result == twice assert twice.count("context: fork") == 1 assert twice.count("agent: general-purpose") == 1 class TestClaudeHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.""" def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): """Skills that have hook sections should get the normalization note.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) i.setup(tmp_path, m, script_type="sh") specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") # specify.md has hook sections assert "replace dots" in content, ( "speckit-specify should have dot-to-hyphen hook note" ) def test_hook_note_not_in_skills_without_hooks(self, tmp_path): """Skills without hook sections should not get the note.""" content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" result = SkillsIntegration._inject_hook_command_note(content) assert "replace dots" not in result def test_hook_note_idempotent(self, tmp_path): """Injecting the note twice should not duplicate it.""" content = ( "---\nname: test\n---\n\n" "- For each executable hook, output the following based on its flag:\n" ) once = SkillsIntegration._inject_hook_command_note(content) twice = SkillsIntegration._inject_hook_command_note(once) assert once == twice, "Hook note injection should be idempotent" def test_hook_note_fills_missing_repeated_instructions(self, tmp_path): """Already-noted hook sections should not suppress later sections.""" from specify_cli.integrations.base import _HOOK_COMMAND_NOTE content = ( "---\nname: test\n---\n\n" f"{_HOOK_COMMAND_NOTE}" "- For each executable hook, output the following based on its flag:\n" "\n" " - For each executable hook, output the following based on its flag:\n" ) result = SkillsIntegration._inject_hook_command_note(content) assert result.count("replace dots (`.`) with hyphens") == 2 def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path): """Unrelated text should not trip the hook-note idempotence guard.""" content = ( "---\nname: test\n---\n\n" "This paragraph says replace dots in a different context.\n" "- For each executable hook, output the following based on its flag:\n" ) result = SkillsIntegration._inject_hook_command_note(content) assert "This paragraph says replace dots in a different context." in result assert result.count("replace dots (`.`) with hyphens") == 1 def test_hook_note_preserves_indentation(self, tmp_path): """The injected note should match the indentation of the target line.""" content = ( "---\nname: test\n---\n\n" " - For each executable hook, output the following\n" ) result = SkillsIntegration._inject_hook_command_note(content) lines = result.splitlines() note_line = [line for line in lines if "replace dots" in line][0] assert note_line.startswith(" "), "Note should preserve indentation" def test_post_process_injects_all_claude_flags(self): """post_process_skill_content should inject all Claude-specific fields.""" i = get_integration("claude") content = ( "---\nname: test\ndescription: test\n---\n\n" "- For each executable hook, output the following\n" ) result = i.post_process_skill_content(content) assert "user-invocable: true" in result assert "disable-model-invocation: false" in result assert "replace dots" in result class TestSpeckitManifestRecordsSkippedFiles: """Regression test for issue #2107. ``install_shared_infra`` must record every shared-infrastructure file under ``.specify/`` in ``speckit.manifest.json``, including files that were *skipped* because they already existed on disk and ``force=False``. Before the fix, the skip branches in the scripts and templates loops appended to ``skipped_files`` without calling ``manifest.record_existing``. So when ``install_shared_infra`` ran with a fresh (or lost) manifest against an already-populated ``.specify/`` tree, every file went down the skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and ``manifest.save()`` wrote an empty ``files`` field — leaving the integration believing nothing was installed. Reproduction (without the fix) using ``install_shared_infra`` directly: install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files (p / ".specify/integrations/speckit.manifest.json").unlink() install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files # ^^ BUG: empty """ def _read_manifest_files(self, project_path: Path) -> dict: manifest_path = ( project_path / ".specify" / "integrations" / "speckit.manifest.json" ) assert manifest_path.exists(), ( f"speckit.manifest.json not written at {manifest_path}" ) data = json.loads(manifest_path.read_text(encoding="utf-8")) # ``IntegrationManifest.save`` serialises a ``files`` dict — assert # the schema explicitly so a regression to a different key (e.g. # the internal ``_files`` attribute name) fails loudly instead of # being masked by a silent fallback. assert isinstance(data, dict), ( f"manifest root is not a dict, got {type(data).__name__}" ) assert "files" in data, ( f"manifest missing 'files' key, got keys: {sorted(data.keys())}" ) files = data["files"] assert isinstance(files, dict), ( f"manifest 'files' is not a dict, got {type(files).__name__}" ) return files def test_install_shared_infra_records_skipped_files(self, tmp_path): """With ``force=False`` and ``.specify/`` already populated, the manifest must still record every file — the skip branches are not allowed to drop files from the manifest.""" from rich.console import Console from specify_cli.shared_infra import install_shared_infra # Resolve the project's own packaged sources by walking up from this # test file to the repo root (which contains ``scripts/`` and # ``templates/`` that ``shared_scripts_source`` looks for). repo_root = Path(__file__).resolve().parents[2] console = Console(quiet=True) # First run — fresh project, manifest gets populated normally. install_shared_infra( tmp_path, "sh", version="0.0.0", core_pack=None, repo_root=repo_root, console=console, force=False, ) first_files = self._read_manifest_files(tmp_path) assert first_files, "first install produced an empty manifest" # Simulate a lost manifest while ``.specify/`` is still on disk # (e.g. the manifest was deleted, corrupted, or the layout was # extracted out-of-band). manifest_path = ( tmp_path / ".specify" / "integrations" / "speckit.manifest.json" ) manifest_path.unlink() # Second run — every file already exists, so every iteration takes # the skip branch. With the fix, those files are still recorded. install_shared_infra( tmp_path, "sh", version="0.0.0", core_pack=None, repo_root=repo_root, console=console, force=False, ) second_files = self._read_manifest_files(tmp_path) assert second_files, ( "speckit.manifest.json files dict is empty after install with " "skipped files (issue #2107) — every file went down the skip " "branch but none were recorded" ) # The recovered manifest must cover everything the first run tracked. missing = set(first_files) - set(second_files) assert not missing, ( f"these files were tracked on the first install but missing after " f"the skipped-files re-install: {sorted(missing)[:5]}" ) def test_install_shared_infra_handles_directory_at_script_destination( self, tmp_path ): """A non-file (directory) at a script's destination must NOT crash ``install_shared_infra`` and must NOT be recorded in the manifest — the path still appears in the user-visible skipped-paths warning. """ from io import StringIO from rich.console import Console from specify_cli.shared_infra import install_shared_infra repo_root = Path(__file__).resolve().parents[2] output = StringIO() console = Console(file=output, force_terminal=False, width=200) # Pre-create the .specify/scripts/bash tree, then plant a directory # where a script file is expected so the skip branch hits a # non-regular-file path. bash_dir = tmp_path / ".specify" / "scripts" / "bash" bash_dir.mkdir(parents=True) (bash_dir / "common.sh").mkdir() # collision: dir where file expected # Must not crash. install_shared_infra( tmp_path, "sh", version="0.0.0", core_pack=None, repo_root=repo_root, console=console, force=False, ) files = self._read_manifest_files(tmp_path) assert ".specify/scripts/bash/common.sh" not in files, ( "directory at script dst must not be recorded in the manifest" ) text = output.getvalue() assert "common.sh" in text, ( "directory-at-script-dst path must surface in the skipped warning" ) def test_install_shared_infra_handles_directory_at_template_destination( self, tmp_path ): """Symmetric coverage for the templates loop: a directory at a template's destination must NOT crash install nor be recorded.""" from io import StringIO from rich.console import Console from specify_cli.shared_infra import install_shared_infra repo_root = Path(__file__).resolve().parents[2] output = StringIO() console = Console(file=output, force_terminal=False, width=200) templates_dir = tmp_path / ".specify" / "templates" templates_dir.mkdir(parents=True) src_templates = repo_root / "templates" real_template = next( ( p.name for p in src_templates.iterdir() if p.is_file() and not p.name.startswith(".") and p.name != "vscode-settings.json" ), None, ) assert real_template, ( "no real template found in repo to collide against" ) (templates_dir / real_template).mkdir() # collision install_shared_infra( tmp_path, "sh", version="0.0.0", core_pack=None, repo_root=repo_root, console=console, force=False, ) files = self._read_manifest_files(tmp_path) template_rel = f".specify/templates/{real_template}" assert template_rel not in files, ( "directory at template dst must not be recorded in manifest" ) text = output.getvalue() assert real_template in text, ( "directory-at-template-dst path must surface in the skipped warning" )