diff --git a/src/specify_cli/events/__init__.py b/src/specify_cli/events/__init__.py index 4cec7997f9..02f1e0ddcf 100644 --- a/src/specify_cli/events/__init__.py +++ b/src/specify_cli/events/__init__.py @@ -2552,7 +2552,12 @@ def _safe_write_json(dst: Path, data: dict) -> None: """Write *data* as JSON to *dst* after validating the destination (#12).""" _ensure_safe_destination(dst) dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + # A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape. + dst.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + errors="backslashreplace", + ) def _ensure_safe_destination(dst: Path) -> None: diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 1a8285d7c4..9adaf5cd9d 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -664,6 +664,9 @@ def _merge_vscode_settings(src: Path, dst: Path) -> None: if not changed: return + # A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape. dst.write_text( - json.dumps(existing, indent=4) + "\n", encoding="utf-8" + json.dumps(existing, indent=4, ensure_ascii=False) + "\n", + encoding="utf-8", + errors="backslashreplace", ) diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 35d30faf0e..cb882a71a8 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -84,6 +84,37 @@ def test_setup_merges_existing_vscode_settings(self, tmp_path): assert settings not in created assert not any("settings.json" in k for k in m.files) + def test_setup_merge_keeps_non_ascii_vscode_settings_readable(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + existing = {"cSpell.words": ["naïve", "Привіт", "日本語"]} + settings = vscode_dir / "settings.json" + settings.write_text(json.dumps(existing, indent=4, ensure_ascii=False), encoding="utf-8") + m = IntegrationManifest("copilot", tmp_path) + + copilot.setup(tmp_path, m, parsed_options={"commands": True}) + + text = settings.read_text(encoding="utf-8") + assert '"naïve"' in text and '"Привіт"' in text and '"日本語"' in text + assert json.loads(text)["cSpell.words"] == existing["cSpell.words"] + + def test_setup_merge_keeps_escaped_lone_surrogate_in_vscode_settings(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings = vscode_dir / "settings.json" + settings.write_text('{"custom.setting": "\\ud800"}', encoding="utf-8") + m = IntegrationManifest("copilot", tmp_path) + + copilot.setup(tmp_path, m, parsed_options={"commands": True}) + + data = json.loads(settings.read_text(encoding="utf-8")) + assert data["custom.setting"] == "\ud800" + assert "chat.promptFilesRecommendations" in data + def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog): from specify_cli.integrations.copilot import CopilotIntegration copilot = CopilotIntegration() diff --git a/tests/specify_cli/events/test_events.py b/tests/specify_cli/events/test_events.py index d1f15bec5f..cb10ad3855 100644 --- a/tests/specify_cli/events/test_events.py +++ b/tests/specify_cli/events/test_events.py @@ -463,6 +463,49 @@ def test_remove_preserves_user_hooks(self, tmp_path): assert len(data["hooks"]["PreToolUse"]) == 1 assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + def test_merge_keeps_non_ascii_user_settings_readable(self, tmp_path): + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps({"env": {"GREETING": "Привіт — ¿qué tal? 日本語"}}, ensure_ascii=False), + encoding="utf-8", + ) + + events = { + "pre_tool_use": [{"command": "speckit.tdd.validate"}], + } + install_integration_events(integration, tmp_path, manifest, events) + + text = config_path.read_text(encoding="utf-8") + assert "Привіт — ¿qué tal? 日本語" in text + assert json.loads(text)["env"]["GREETING"] == "Привіт — ¿qué tal? 日本語" + + def test_merge_keeps_escaped_lone_surrogate_in_user_settings(self, tmp_path): + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text('{"env": {"ODD": "\\ud800"}}', encoding="utf-8") + + events = { + "pre_tool_use": [{"command": "speckit.tdd.validate"}], + } + install_integration_events(integration, tmp_path, manifest, events) + + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["env"]["ODD"] == "\ud800" + assert "PreToolUse" in data["hooks"] + # -- Copilot events JSON writing --------------------------------------------