Skip to content

Commit 8e2e2d2

Browse files
marcelsafinCopilot
andauthored
fix(integrations): escape control characters in SKILL.md frontmatter (#3399)
yaml.safe_dump with default_style='"' replaces the hand-rolled quote helpers in base.py and hermes, so newlines and control characters in template descriptions round-trip instead of producing unparseable YAML. Fixes #3391 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a4d9430 commit 8e2e2d2

3 files changed

Lines changed: 140 additions & 23 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@
5454
}
5555

5656

57+
def yaml_quote(value: str) -> str:
58+
"""Emit *value* as a double-quoted YAML scalar on a single line.
59+
60+
A hand-rolled quote cannot carry raw newlines (YAML folds them to
61+
spaces) or control characters (the reader rejects them), so let the
62+
YAML emitter produce the escapes.
63+
"""
64+
return yaml.safe_dump(
65+
str(value), default_style='"', allow_unicode=True, width=sys.maxsize
66+
).strip()
67+
68+
5769
# ---------------------------------------------------------------------------
5870
# IntegrationOption
5971
# ---------------------------------------------------------------------------
@@ -1480,21 +1492,17 @@ def setup(
14801492
if not description:
14811493
description = f"Spec Kit: {command_name} workflow"
14821494

1483-
# Build SKILL.md with manually formatted frontmatter to match
1484-
# the release packaging script output exactly (double-quoted
1485-
# values, no yaml.safe_dump quoting differences).
1486-
def _quote(v: str) -> str:
1487-
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
1488-
return f'"{escaped}"'
1489-
1495+
# Build SKILL.md with manually formatted frontmatter (stable
1496+
# double-quoted values). yaml_quote escapes newlines and control
1497+
# characters that a plain quoted f-string cannot carry.
14901498
skill_content = (
14911499
f"---\n"
1492-
f"name: {_quote(skill_name)}\n"
1493-
f"description: {_quote(description)}\n"
1494-
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
1500+
f"name: {yaml_quote(skill_name)}\n"
1501+
f"description: {yaml_quote(description)}\n"
1502+
f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
14951503
f"metadata:\n"
1496-
f" author: {_quote('github-spec-kit')}\n"
1497-
f" source: {_quote('templates/commands/' + src_file.name)}\n"
1504+
f" author: {yaml_quote('github-spec-kit')}\n"
1505+
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
14981506
f"---\n"
14991507
f"{processed_body}"
15001508
)

src/specify_cli/integrations/hermes/__init__.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import yaml
2020

21-
from ..base import IntegrationOption, SkillsIntegration
21+
from ..base import IntegrationOption, SkillsIntegration, yaml_quote
2222
from ..manifest import IntegrationManifest
2323

2424

@@ -153,20 +153,18 @@ def setup(
153153
if not description:
154154
description = f"Spec Kit: {command_name} workflow"
155155

156-
# Build SKILL.md with manually formatted frontmatter
157-
def _quote(v: str) -> str:
158-
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
159-
return f'"{escaped}"'
160-
156+
# Build SKILL.md with manually formatted frontmatter. yaml_quote
157+
# escapes newlines and control characters that a plain quoted
158+
# f-string cannot carry.
161159
skill_content = (
162160
f"---\n"
163-
f"name: {_quote(skill_name)}\n"
164-
f"description: {_quote(description)}\n"
161+
f"name: {yaml_quote(skill_name)}\n"
162+
f"description: {yaml_quote(description)}\n"
165163
f"compatibility: "
166-
f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
164+
f"{yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
167165
f"metadata:\n"
168-
f" author: {_quote('github-spec-kit')}\n"
169-
f" source: {_quote('templates/commands/' + src_file.name)}\n"
166+
f" author: {yaml_quote('github-spec-kit')}\n"
167+
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
170168
f"---\n"
171169
f"{processed_body}"
172170
)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Regression tests for SKILL.md frontmatter quoting (#3391).
2+
3+
The skills setup path builds SKILL.md frontmatter by hand with
4+
double-quoted values. A double-quoted YAML scalar cannot carry a raw
5+
newline (the parser folds it to a space) or a control character (the
6+
reader rejects the document), so descriptions taken from template
7+
frontmatter must be escaped by the YAML emitter.
8+
"""
9+
10+
from pathlib import Path
11+
12+
import yaml
13+
14+
from specify_cli.integrations import get_integration
15+
from specify_cli.integrations.base import yaml_quote
16+
from specify_cli.integrations.manifest import IntegrationManifest
17+
18+
MULTILINE = "first line\nsecond line\n"
19+
CONTROL = "ding\aling"
20+
21+
HOSTILE_TEMPLATE = """---
22+
description: |
23+
first line
24+
second line
25+
---
26+
27+
Body of the command.
28+
"""
29+
30+
CONTROL_TEMPLATE = """---
31+
description: "ding\\aling"
32+
---
33+
34+
Body of the command.
35+
"""
36+
37+
38+
def _parse_frontmatter(skill_file: Path) -> dict:
39+
content = skill_file.read_text(encoding="utf-8")
40+
assert content.startswith("---\n")
41+
return yaml.safe_load(content.split("---", 2)[1])
42+
43+
44+
def _fake_templates(tmp_path: Path, body: str) -> Path:
45+
templates = tmp_path / "templates"
46+
templates.mkdir(exist_ok=True)
47+
(templates / "plan.md").write_text(body, encoding="utf-8")
48+
return templates
49+
50+
51+
class TestYamlQuote:
52+
def test_simple_value_keeps_plain_double_quoted_form(self):
53+
assert yaml_quote("speckit-plan") == '"speckit-plan"'
54+
assert yaml_quote('say "hi"') == '"say \\"hi\\""'
55+
assert yaml_quote("back\\slash") == '"back\\\\slash"'
56+
57+
def test_multiline_value_round_trips(self):
58+
quoted = yaml_quote(MULTILINE)
59+
assert "\n" not in quoted
60+
assert yaml.safe_load(quoted) == MULTILINE
61+
62+
def test_control_character_round_trips(self):
63+
quoted = yaml_quote(CONTROL)
64+
assert "\a" not in quoted
65+
assert yaml.safe_load(quoted) == CONTROL
66+
67+
68+
class TestSkillFrontmatterQuoting:
69+
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
70+
integration = get_integration("agy")
71+
monkeypatch.setattr(
72+
integration,
73+
"shared_commands_dir",
74+
lambda: _fake_templates(tmp_path, template),
75+
)
76+
manifest = IntegrationManifest("agy", tmp_path)
77+
created = integration.setup(tmp_path, manifest)
78+
skill_files = [f for f in created if f.name == "SKILL.md"]
79+
assert len(skill_files) == 1
80+
return skill_files[0]
81+
82+
def test_multiline_description_survives(self, tmp_path, monkeypatch):
83+
skill_file = self._generate(tmp_path, monkeypatch, HOSTILE_TEMPLATE)
84+
fm = _parse_frontmatter(skill_file)
85+
assert fm["description"] == MULTILINE
86+
87+
def test_control_character_description_parses(self, tmp_path, monkeypatch):
88+
skill_file = self._generate(tmp_path, monkeypatch, CONTROL_TEMPLATE)
89+
fm = _parse_frontmatter(skill_file)
90+
assert fm["description"] == CONTROL
91+
92+
93+
class TestHermesSkillFrontmatterQuoting:
94+
def test_multiline_description_survives(self, tmp_path, monkeypatch):
95+
home = tmp_path / "home"
96+
home.mkdir(exist_ok=True)
97+
monkeypatch.setattr(Path, "home", lambda: home)
98+
99+
integration = get_integration("hermes")
100+
monkeypatch.setattr(
101+
integration,
102+
"shared_commands_dir",
103+
lambda: _fake_templates(tmp_path, HOSTILE_TEMPLATE),
104+
)
105+
manifest = IntegrationManifest("hermes", tmp_path)
106+
created = integration.setup(tmp_path, manifest)
107+
skill_files = [f for f in created if f.name == "SKILL.md"]
108+
assert len(skill_files) == 1
109+
110+
fm = _parse_frontmatter(skill_files[0])
111+
assert fm["description"] == MULTILINE

0 commit comments

Comments
 (0)