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
4 changes: 2 additions & 2 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ def upsert_context_section(
)

if ctx_path.exists():
content = ctx_path.read_text(encoding="utf-8")
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(self.CONTEXT_MARKER_START)
end_idx = content.find(
self.CONTEXT_MARKER_END,
Expand Down Expand Up @@ -547,7 +547,7 @@ def remove_context_section(self, project_root: Path) -> bool:
if not ctx_path.exists():
return False

content = ctx_path.read_text(encoding="utf-8")
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(self.CONTEXT_MARKER_START)
end_idx = content.find(
self.CONTEXT_MARKER_END,
Expand Down
41 changes: 41 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for ClaudeIntegration."""

import codecs
import json
import os
from unittest.mock import patch
Expand Down Expand Up @@ -74,6 +75,46 @@ def test_setup_upserts_context_section(self, tmp_path):
assert "<!-- SPECKIT END -->" in content
assert "read the current plan" in content

def test_upsert_context_section_strips_bom(self, tmp_path):
"""Existing context file with UTF-8 BOM must be cleaned up on upsert."""
integration = get_integration("claude")
ctx_path = tmp_path / integration.context_file

# Write a file that starts with a UTF-8 BOM (as the old PowerShell script did)
bom = codecs.BOM_UTF8
ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n")

integration.upsert_context_section(tmp_path)

result = ctx_path.read_bytes()
assert not result.startswith(bom), "BOM must be stripped after upsert"
content = result.decode("utf-8")
assert "<!-- SPECKIT START -->" in content
assert "Some existing content." in content

def test_remove_context_section_strips_bom(self, tmp_path):
"""remove_context_section must clean BOM from context file on Windows-authored files."""
integration = get_integration("claude")
ctx_path = tmp_path / integration.context_file

marker_content = (
"# CLAUDE.md\n\n"
"<!-- SPECKIT START -->\n"
"For additional context about technologies to be used, project structure,\n"
"shell commands, and other important information, read the current plan\n"
"<!-- SPECKIT END -->\n"
)
ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8"))

result = integration.remove_context_section(tmp_path)

assert result is True
assert ctx_path.exists(), "File should exist (non-empty content remains)"
remaining = ctx_path.read_bytes()
assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove"
assert b"<!-- SPECKIT" not in remaining
assert b"# CLAUDE.md" in remaining

def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
Expand Down
16 changes: 12 additions & 4 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ class TestCommandStep:
"""Test the command step type."""

def test_execute_basic(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus

Expand All @@ -413,7 +414,8 @@ def test_execute_basic(self):
"command": "speckit.specify",
"input": {"args": "{{ inputs.name }}"},
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.status == StepStatus.FAILED
assert result.output["command"] == "speckit.specify"
assert result.output["integration"] == "claude"
Expand Down Expand Up @@ -474,6 +476,7 @@ def test_options_merge(self):

def test_dispatch_not_attempted_without_cli(self):
"""When the CLI tool is not installed, step should fail."""
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus

Expand All @@ -488,7 +491,8 @@ def test_dispatch_not_attempted_without_cli(self):
"command": "speckit.specify",
"input": {"args": "{{ inputs.name }}"},
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.status == StepStatus.FAILED
assert result.output["dispatched"] is False
assert result.error is not None
Expand Down Expand Up @@ -566,6 +570,7 @@ class TestPromptStep:
"""Test the prompt step type."""

def test_execute_basic(self):
from unittest.mock import patch
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus

Expand All @@ -579,7 +584,8 @@ def test_execute_basic(self):
"type": "prompt",
"prompt": "Review {{ inputs.file }} for security issues",
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.status == StepStatus.FAILED
assert result.output["prompt"] == "Review auth.py for security issues"
assert result.output["integration"] == "claude"
Expand Down Expand Up @@ -1311,6 +1317,7 @@ def test_load_not_found(self, project_dir):
engine.load_workflow("nonexistent")

def test_execute_simple_workflow(self, project_dir):
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus

Expand All @@ -1333,7 +1340,8 @@ def test_execute_simple_workflow(self, project_dir):
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition, {"name": "login"})
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
state = engine.execute(definition, {"name": "login"})

assert state.status == RunStatus.FAILED
assert "step-one" in state.step_results
Expand Down
Loading