Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 811a3aa

Browse files
seiya-kojiclaude
andauthored
fix(skills): preserve non-ASCII characters in skill frontmatter (#2917)
* fix(skills): preserve non-ASCII chars in skill frontmatter Skill SKILL.md frontmatter descriptions containing non-ASCII characters were escaped to \uXXXX / \xXX sequences because yaml.safe_dump() was called without allow_unicode=True. - Add allow_unicode=True to the 7 skill/command frontmatter safe_dump sites (extensions, presets, claude integration) - Add regression tests for the render and extension-install paths Follows the approach of #1936; encoding="utf-8" is already set on the affected write paths, so no encoding change is needed here. Co-Authored-By: Claude Opus 4.8 <[email protected]> * refactor(_utils): add dump_frontmatter helper Centralize skill/command frontmatter YAML serialization into a single _utils.dump_frontmatter helper so no call site can drop allow_unicode or diverge on formatting. Route the 7 existing sites through it and drop a now-unused local yaml import. Switch the extension test fixtures to yaml.safe_dump for parity with the production safe-dump/safe-load codepaths. Co-Authored-By: Claude Opus 4.8 <[email protected]> --------- Co-authored-by: Claude Opus 4.8 <[email protected]>
1 parent de18d21 commit 811a3aa

6 files changed

Lines changed: 92 additions & 14 deletions

File tree

‎src/specify_cli/_utils.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import stat
99
import subprocess
1010
import tempfile
11+
import yaml
1112
from pathlib import Path
1213
from typing import Any
1314
from ._console import console
@@ -16,6 +17,16 @@
1617
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
1718

1819

20+
def dump_frontmatter(data: dict[str, Any]) -> str:
21+
"""Serialize skill/command frontmatter to a YAML string.
22+
23+
Centralizes the dump options used for SKILL.md frontmatter: ``allow_unicode``
24+
preserves Unicode descriptions and ``sort_keys=False`` keeps key order, so no
25+
call site can silently drop either.
26+
"""
27+
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
28+
29+
1930
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
2031
"""Run a shell command and optionally capture output."""
2132
try:

‎src/specify_cli/extensions.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from ._init_options import is_ai_skills_enabled
3030
from ._invocation_style import is_slash_skills_agent
31+
from ._utils import dump_frontmatter
3132
from .catalogs import CatalogEntry as BaseCatalogEntry
3233
from .catalogs import CatalogStackBase
3334

@@ -1073,7 +1074,7 @@ def _register_extension_skills(
10731074
and hasattr(integration, "inject_argument_hint")
10741075
):
10751076
frontmatter_data["argument-hint"] = str(argument_hint)
1076-
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
1077+
frontmatter_text = dump_frontmatter(frontmatter_data)
10771078

10781079
# Derive a human-friendly title from the command name
10791080
short_name = cmd_name

‎src/specify_cli/integrations/claude/__init__.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
from pathlib import Path
66
from typing import Any
77

8-
import yaml
9-
108
from ..base import SkillsIntegration
119
from ..manifest import IntegrationManifest
10+
from ..._utils import dump_frontmatter
1211

1312
# Mapping of command template stem → argument-hint text shown inline
1413
# when a user invokes the slash command in Claude Code.
@@ -103,7 +102,7 @@ def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: s
103102
skill_frontmatter = self._build_skill_fm(
104103
skill_name, description, f"templates/commands/{template_name}.md"
105104
)
106-
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
105+
frontmatter_text = dump_frontmatter(skill_frontmatter)
107106
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
108107

109108
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:

‎src/specify_cli/presets/__init__.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
3131
from .._init_options import is_ai_skills_enabled
3232
from ..integrations.base import IntegrationBase
33+
from .._utils import dump_frontmatter
3334

3435

3536
def _substitute_core_template(
@@ -1068,7 +1069,7 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
10681069
skill_name, desc,
10691070
f"override:{cmd_name}",
10701071
)
1071-
fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip()
1072+
fm_text = dump_frontmatter(fm_data)
10721073
skill_title = self._skill_title_from_command(cmd_name)
10731074
skill_content = (
10741075
f"---\n{fm_text}\n---\n\n"
@@ -1345,7 +1346,7 @@ def _register_skills(
13451346
enhanced_desc,
13461347
f"preset:{manifest.id}",
13471348
)
1348-
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
1349+
frontmatter_text = dump_frontmatter(frontmatter_data)
13491350
skill_content = (
13501351
f"---\n"
13511352
f"{frontmatter_text}\n"
@@ -1441,7 +1442,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
14411442
enhanced_desc,
14421443
f"templates/commands/{short_name}.md",
14431444
)
1444-
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
1445+
frontmatter_text = dump_frontmatter(frontmatter_data)
14451446
skill_title = self._skill_title_from_command(short_name)
14461447
skill_content = (
14471448
f"---\n"
@@ -1478,7 +1479,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
14781479
frontmatter.get("description", f"Extension command: {command_name}"),
14791480
extension_restore["source"],
14801481
)
1481-
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
1482+
frontmatter_text = dump_frontmatter(frontmatter_data)
14821483
skill_content = (
14831484
f"---\n"
14841485
f"{frontmatter_text}\n"
@@ -3276,7 +3277,7 @@ def _parse_fm_yaml(fm_block: str) -> dict:
32763277
if top_fm:
32773278
top_frontmatter_text = (
32783279
"---\n"
3279-
+ yaml.safe_dump(top_fm, sort_keys=False).strip()
3280+
+ dump_frontmatter(top_fm)
32803281
+ "\n---"
32813282
)
32823283
else:

‎tests/integrations/test_integration_claude.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ def test_setup_creates_skill_files(self, tmp_path):
6666
assert parsed["disable-model-invocation"] is False
6767
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
6868

69+
def test_render_skill_unicode(self):
70+
"""Test rendering a skill preserves non-ASCII characters."""
71+
integration = get_integration("claude")
72+
rendered = integration._render_skill(
73+
"constitution",
74+
{"description": "Prüfe Konformität der Implementierung"},
75+
"Body",
76+
)
77+
assert "Prüfe Konformität" in rendered
78+
6979
def test_setup_upserts_context_section(self, tmp_path):
7080
integration = get_integration("claude")
7181
manifest = IntegrationManifest("claude", tmp_path)

‎tests/test_extension_skills.py‎

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
9090
}
9191

9292
with open(ext_dir / "extension.yml", "w") as f:
93-
yaml.dump(manifest_data, f)
93+
yaml.safe_dump(manifest_data, f)
9494

9595
commands_dir = ext_dir / "commands"
9696
commands_dir.mkdir()
@@ -119,6 +119,50 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
119119
return ext_dir
120120

121121

122+
def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path:
123+
"""Create an extension whose command description contains non-ASCII characters."""
124+
ext_dir = temp_dir / ext_id
125+
ext_dir.mkdir()
126+
description = "Prüfe Konformität der Implementierung"
127+
128+
manifest_data = {
129+
"schema_version": "1.0",
130+
"extension": {
131+
"id": ext_id,
132+
"name": "Unicode Extension",
133+
"version": "1.0.0",
134+
"description": description,
135+
},
136+
"requires": {"speckit_version": ">=0.1.0"},
137+
"provides": {
138+
"commands": [
139+
{
140+
"name": f"speckit.{ext_id}.hello",
141+
"file": "commands/hello.md",
142+
"description": description,
143+
},
144+
]
145+
},
146+
}
147+
148+
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
149+
yaml.safe_dump(manifest_data, f, allow_unicode=True)
150+
151+
commands_dir = ext_dir / "commands"
152+
commands_dir.mkdir()
153+
(commands_dir / "hello.md").write_text(
154+
"---\n"
155+
f'description: "{description}"\n'
156+
"---\n"
157+
"\n"
158+
"# Hello\n"
159+
"\n"
160+
"Body.\n",
161+
encoding="utf-8",
162+
)
163+
return ext_dir
164+
165+
122166
def _can_create_symlink(temp_dir: Path) -> bool:
123167
"""Return True when the current platform/user can create file symlinks."""
124168
target = temp_dir / "symlink-target.txt"
@@ -432,6 +476,18 @@ def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_di
432476
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
433477
assert "argument-hint" not in parsed
434478

479+
def test_skill_md_unicode(self, skills_project, temp_dir):
480+
"""SKILL.md generation should preserve non-ASCII characters."""
481+
project_dir, skills_dir = skills_project
482+
ext_dir = _create_unicode_extension_dir(temp_dir)
483+
manager = ExtensionManager(project_dir)
484+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
485+
486+
skill_file = skills_dir / "speckit-uni-ext-hello" / "SKILL.md"
487+
content = skill_file.read_text(encoding="utf-8")
488+
489+
assert "Prüfe Konformität" in content
490+
435491
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
436492
"""No skills should be created when ai_skills is false."""
437493
manager = ExtensionManager(no_skills_project)
@@ -692,7 +748,7 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp
692748
},
693749
}
694750
with open(ext_dir / "extension.yml", "w") as f:
695-
yaml.dump(manifest_data, f)
751+
yaml.safe_dump(manifest_data, f)
696752

697753
(ext_dir / "commands").mkdir()
698754
(ext_dir / "commands" / "plan.md").write_text(
@@ -747,7 +803,7 @@ def test_missing_command_file_skipped(self, skills_project, temp_dir):
747803
},
748804
}
749805
with open(ext_dir / "extension.yml", "w") as f:
750-
yaml.dump(manifest_data, f)
806+
yaml.safe_dump(manifest_data, f)
751807

752808
(ext_dir / "commands").mkdir()
753809
(ext_dir / "commands" / "exists.md").write_text(
@@ -1303,7 +1359,7 @@ def test_command_without_frontmatter(self, skills_project, temp_dir):
13031359
},
13041360
}
13051361
with open(ext_dir / "extension.yml", "w") as f:
1306-
yaml.dump(manifest_data, f)
1362+
yaml.safe_dump(manifest_data, f)
13071363

13081364
(ext_dir / "commands").mkdir()
13091365
(ext_dir / "commands" / "plain.md").write_text(
@@ -1390,7 +1446,7 @@ def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
13901446
},
13911447
}
13921448
with open(ext_dir / "extension.yml", "w") as f:
1393-
yaml.dump(manifest_data, f)
1449+
yaml.safe_dump(manifest_data, f)
13941450

13951451
(ext_dir / "commands").mkdir()
13961452
# Malformed YAML: invalid key-value syntax

0 commit comments

Comments
 (0)