diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 529803922e..79fd913dc7 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -826,29 +826,37 @@ def process_template( 6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ - # 1. Extract script command from frontmatter + # 1. Extract script command from frontmatter using structured YAML script_commands: dict[str, str] = {} - script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$") - # Find the scripts: block - in_frontmatter = False - in_scripts = False - for line in content.splitlines(): - if line == "---": - if in_frontmatter: - break - in_frontmatter = True - continue - if not in_frontmatter: - continue - if line == "scripts:": - in_scripts = True - continue - if in_scripts and line and not line[0].isspace(): - break - if in_scripts: - m = script_pattern.match(line) - if m: - script_commands[m.group(1)] = m.group(2).strip() + has_frontmatter = False + frontmatter_dict: dict[str, Any] = {} + body = content + + if content.startswith("---"): + lines = content.splitlines(keepends=True) + close_idx = next( + ( + i + for i in range(1, len(lines)) + if lines[i].rstrip("\r\n") == "---" + ), + None, + ) + if close_idx is not None: + has_frontmatter = True + fm_text = "".join(lines[1:close_idx]) + body = "".join(lines[close_idx + 1 :]) + try: + parsed = yaml.safe_load(fm_text) + if isinstance(parsed, dict): + frontmatter_dict = parsed + raw_scripts = frontmatter_dict.get("scripts") + if isinstance(raw_scripts, dict): + for k, v in raw_scripts.items(): + if isinstance(v, str): + script_commands[str(k)] = v.strip() + except yaml.YAMLError: + pass selected_script_type = ( IntegrationBase.select_script_variant(script_type, script_commands) @@ -867,35 +875,24 @@ def process_template( script_command = IntegrationBase.build_python_invocation( script_command, project_root ) - content = content.replace("{SCRIPT}", script_command) - - # 3. Strip scripts: section from frontmatter - lines = content.splitlines(keepends=True) - output_lines: list[str] = [] - in_frontmatter = False - skip_section = False - dash_count = 0 - for line in lines: - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - if dash_count == 1: - in_frontmatter = True - else: - in_frontmatter = False - skip_section = False - output_lines.append(line) - continue - if in_frontmatter: - if stripped == "scripts:": - skip_section = True - continue - if skip_section: - if line[0:1].isspace(): - continue # skip indented content under scripts - skip_section = False - output_lines.append(line) - content = "".join(output_lines) + body = body.replace("{SCRIPT}", script_command) + + # 3. Strip scripts: section from frontmatter using structured YAML + if has_frontmatter: + frontmatter_dict.pop("scripts", None) + if frontmatter_dict: + rendered_fm = yaml.dump( + frontmatter_dict, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + width=float("inf"), + ) + content = f"---\n{rendered_fm}---\n{body}" + else: + content = f"---\n---\n{body}" + else: + content = body # 4. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 30938dd7b7..e8c9fd7f23 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -765,3 +765,39 @@ def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path): assert sh_file.stat().st_mode & 0o111 # Negative: a non-script file is not made executable. assert not (txt_file.stat().st_mode & 0o111) + + +class TestProcessTemplateStructuredYaml: + def test_scripts_with_yaml_comments_and_quotes(self): + content = ( + "---\n" + "description: \"Test with quotes and comments\"\n" + "scripts:\n" + " # Primary POSIX shell script\n" + ' sh: "scripts/bash/check-prerequisites.sh --json"\n' + " # Python alternative\n" + " py: 'scripts/python/check_prerequisites.py --json'\n" + "---\n" + "Run {SCRIPT} now." + ) + result = IntegrationBase.process_template(content, "agent", "sh") + assert ".specify/scripts/bash/check-prerequisites.sh --json" in result + assert "scripts:" not in result + assert "description:" in result + assert "Test with quotes and comments" in result + + def test_folded_yaml_scalar_preserved(self): + content = ( + "---\n" + "description: >\n" + " A multi-line folded description\n" + " that spans multiple lines.\n" + "scripts:\n" + " sh: scripts/bash/check-prerequisites.sh\n" + "---\n" + "Run {SCRIPT}." + ) + result = IntegrationBase.process_template(content, "agent", "sh") + assert ".specify/scripts/bash/check-prerequisites.sh" in result + assert "scripts:" not in result + assert "A multi-line folded description" in result