Skip to content

[Bug]: preset add crashes with AttributeError when a composing command's frontmatter is not a YAML mapping #5085

Open

Description

@hnipps

Bug Description

When a preset command uses a composition strategy (append, prepend or wrap), PresetResolver.resolve_content parses the top layer's frontmatter with _parse_fm_yaml, which returns yaml.safe_load(...) or {} (src/specify_cli/presets/_resolver.py:918-921). If the frontmatter is valid YAML but not a mapping, for example a bare line of text, the result is a string. The code then calls top_fm.pop("strategy", None) (_resolver.py:934), which raises AttributeError. Installation calls resolve_content from _register_commands, and preset add handles only PresetError subclasses (src/specify_cli/presets/command_add.py:434-445), so the user gets a raw traceback.

Invalid YAML in the same position is tolerated, so only valid non-mapping YAML triggers the crash.

Steps to Reproduce

From a fresh clone after uv sync, save the following as repro.py and run uv run python -I repro.py:

import traceback
import os, pathlib, tempfile
from typer.testing import CliRunner
from specify_cli import app

base = pathlib.Path(tempfile.mkdtemp())
proj, src = base / "proj", base / "src"
(proj / ".specify" / "templates" / "commands").mkdir(parents=True)
(proj / ".specify" / "templates" / "commands" / "speckit.demo.md").write_text(
    "---\ndescription: core\n---\nCORE BODY\n")
(src / "commands").mkdir(parents=True)
(src / "commands" / "speckit.demo.md").write_text("---\njust a note\n---\nAPPENDED\n")
(src / "preset.yml").write_text(
    'schema_version: "1.0"\n'
    'preset:\n  id: p1\n  name: P\n  version: "1.0.0"\n  description: d\n'
    'requires:\n  speckit_version: ">=0.0.1"\n'
    'provides:\n  templates:\n    - type: command\n      name: speckit.demo\n'
    '      file: commands/speckit.demo.md\n      strategy: append\n')
os.chdir(proj)
r = CliRunner().invoke(app, ["preset", "add", "--dev", str(src)])
print("exit code:", r.exit_code)
print("exception:", repr(r.exception))
traceback.print_exception(r.exc_info[1])

Expected Behavior

Non-mapping frontmatter should be handled like invalid frontmatter: treated as empty or reported as a preset validation error. CommandRegistrar.parse_frontmatter handles the same input by replacing a non-dict result with {} (src/specify_cli/agents.py:158-159), and the resolver already falls back to {} on yaml.YAMLError (_resolver.py:920-921). resolve_content documents its return as "Composed content string, or None if not found" (_resolver.py:755-756).

Actual Behavior

exit code: 1
exception: AttributeError("'str' object has no attribute 'pop'")

Specify CLI Version

1.2.4.dev0 (main at 27d14e2)

AI Agent

Not applicable

Operating System

Linux 6.18 (Ubuntu container)

Python Version

Python 3.12.3

Error Logs

Traceback (most recent call last):
  ...
  File "src/specify_cli/presets/command_add.py", line 193, in preset_add
    manifest = manager.install_from_directory(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "src/specify_cli/presets/_manager.py", line 445, in install_from_directory
    registered_commands = self._register_commands(manifest, dest_dir)
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "src/specify_cli/presets/_manager_commands.py", line 147, in _register_commands
    composed = resolver.resolve_content(cmd["name"], "command")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "src/specify_cli/presets/_resolver.py", line 934, in resolve_content
    top_fm.pop("strategy", None)
    ^^^^^^^^^^
AttributeError: 'str' object has no attribute 'pop'

(The leading frames are from Typer's test runner and are omitted.)

Additional Context

Adding an isinstance(..., dict) check in _parse_fm_yaml, as CommandRegistrar.parse_frontmatter already does, would make the two parsers consistent.

Related: #4552 guards frontmatter that is not a mapping in integrations/base.py. It does not touch presets/_resolver.py.

Also reproduced on macOS (Darwin 25.6, arm64) with Python 3.14.4 and uv run python -I repro.py against 27d14e2; the output matches the above.

AI Disclosure

Found and reproduced with Claude Code (model: Claude Opus 4.8), run autonomously under human supervision. The human reporter re-ran every reproduction above before filing.

Activity

  1. added
    triage-can-waitVerdict: valid and in-scope but deprioritized; held behind the evidence gate
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    triage-can-waitVerdict: valid and in-scope but deprioritized; held behind the evidence gate

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions