From f2cc31a1135418ceca7d64c7ff7e9976b614c552 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Mon, 28 Sep 2026 13:39:53 +0000 Subject: [PATCH 1/2] fix: keep non-ASCII text readable in merged JSON settings files The Copilot .vscode/settings.json merge and the events writer that merges hooks into agent configs (.claude/settings.json, opencode.json, .cursor/hooks.json, ...) called json.dumps without ensure_ascii=False, so every non-ASCII character already in the user's file was rewritten as a \uXXXX escape. Both files are user-owned and hand-edited. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) --- src/specify_cli/events/__init__.py | 2 +- .../integrations/copilot/__init__.py | 2 +- .../integrations/test_integration_copilot.py | 16 +++++++++++++ tests/specify_cli/events/test_events.py | 23 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/events/__init__.py b/src/specify_cli/events/__init__.py index 4cec7997f9..b302f22272 100644 --- a/src/specify_cli/events/__init__.py +++ b/src/specify_cli/events/__init__.py @@ -2552,7 +2552,7 @@ 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") + dst.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") 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..9f77f179c0 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -665,5 +665,5 @@ def _merge_vscode_settings(src: Path, dst: Path) -> None: return dst.write_text( - json.dumps(existing, indent=4) + "\n", encoding="utf-8" + json.dumps(existing, indent=4, ensure_ascii=False) + "\n", encoding="utf-8" ) diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 35d30faf0e..1efa712f40 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -84,6 +84,22 @@ 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_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..27252d9a87 100644 --- a/tests/specify_cli/events/test_events.py +++ b/tests/specify_cli/events/test_events.py @@ -463,6 +463,29 @@ 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? 日本語" + # -- Copilot events JSON writing -------------------------------------------- From 4d6eec2ed9709cf5178356e98881265aa89a63c7 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Mon, 28 Sep 2026 15:47:25 +0000 Subject: [PATCH 2/2] fix: write escaped lone surrogates back as JSON escapes json.loads accepts an escaped lone surrogate such as "\ud800", and with ensure_ascii=False the strict UTF-8 write then raised UnicodeEncodeError on a config the old writer could round-trip. errors="backslashreplace" writes that character back as its \uXXXX escape and leaves the rest of the text readable. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) --- src/specify_cli/events/__init__.py | 7 ++++++- .../integrations/copilot/__init__.py | 5 ++++- .../integrations/test_integration_copilot.py | 15 ++++++++++++++ tests/specify_cli/events/test_events.py | 20 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/events/__init__.py b/src/specify_cli/events/__init__.py index b302f22272..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, ensure_ascii=False) + "\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 9f77f179c0..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, ensure_ascii=False) + "\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 1efa712f40..cb882a71a8 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -100,6 +100,21 @@ def test_setup_merge_keeps_non_ascii_vscode_settings_readable(self, tmp_path): 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 27252d9a87..cb10ad3855 100644 --- a/tests/specify_cli/events/test_events.py +++ b/tests/specify_cli/events/test_events.py @@ -486,6 +486,26 @@ def test_merge_keeps_non_ascii_user_settings_readable(self, tmp_path): 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 --------------------------------------------