diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf2ffec..2a641f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.2.2] - 2026-05-03
+
+### Added
+
+- `compat.cursor-description-block-scalar` rule (INFO by default). Flags `description: >`, `description: >+`, `description: |`, and `description: |+` because Cursor's skills UI renders these as empty. The Cursor-safe form is `description: >-` (folded strip). Closes #1.
+- `--strict-cursor` flag promotes the new rule to ERROR and fails the run. Mirrors `--strict-vscode`.
+- `cursor` is now a valid `--target-agent` choice; promotes the rule to WARNING when set without `--strict-cursor`.
+- `strict-cursor` action input (`action.yml`) and `INPUT_STRICT_CURSOR` wiring (`action/entrypoint.py`).
+- TOML config: `strict-cursor = true` is now accepted in `skillcheck.toml`.
+
+### Changed
+
+- `frontmatter.name.required` and `frontmatter.description.required` now append a hint when the missing field appears as a `## name:` or `## description:` markdown heading inside the frontmatter block. Frontmatter keys are YAML, not markdown; the hint nudges authors to drop the `##` prefix. Closes #1.
+
## [1.2.1] - 2026-05-03
### Fixed
diff --git a/README.md b/README.md
index 87ff1aa..b104264 100644
--- a/README.md
+++ b/README.md
@@ -14,11 +14,11 @@
-**v1.2.1 · 687 tests cover all rule modules · production**
+**v1.2.2 · 709 tests cover all rule modules · production**
-687 tests cover all rule modules, 0 known false positives.
+709 tests cover all rule modules, 0 known false positives.
---
@@ -251,8 +251,9 @@ The JSON schema is stable. It will not change in a backward-incompatible way wit
| `--skip-dirname-check` | `false` | Skip directory-name matching (useful for CI temp paths) |
| `--skip-ref-check` | `false` | Skip file reference validation |
| `--min-desc-score N` | | Minimum description quality score (0-100); below this triggers a warning |
-| `--target-agent {claude,vscode,all}` | `all` | Scope compatibility checks to a specific agent |
+| `--target-agent {claude,vscode,cursor,all}` | `all` | Scope compatibility checks to a specific agent |
| `--strict-vscode` | `false` | Promote VS Code compatibility issues to errors |
+| `--strict-cursor` | `false` | Promote Cursor compatibility issues to errors |
| `--warnings-as-errors` | `false` | Escalate warning-only runs to exit code 1 (default for warning-only is 0) |
| `--semantic` | `false` | Enable semantic-adjacent validation; standalone mode runs heuristic graph analysis |
| `--agent-reason` | `false` | Emit a combined critique + graph prompt packet for the calling agent |
@@ -317,6 +318,7 @@ Source tags: `spec` rules derive from the agentskills.io specification or agent-
| `references.depth-exceeded` | warning | spec | Reference deeper than one level from SKILL.md |
| `compat.claude-only` | info | spec | Field only works in Claude Code |
| `compat.vscode-dirname` | info / error | spec | Name does not match parent directory (VS Code); promotes to error with `--strict-vscode` |
+| `compat.cursor-description-block-scalar` | info / warning / error | spec | `description: >` or `description: \|` renders as empty in Cursor; INFO by default, WARNING with `--target-agent cursor`, ERROR with `--strict-cursor`. Use `description: >-` |
| `compat.unverified` | info | advisory | Field behavior unverified in Codex or Cursor |
| `template.detected` | info | advisory | Placeholder file detected; deployment-blocking checks are skipped |
| `graph.capability.orphaned` | warning | heuristic | Capability heading has no declared inputs or outputs |
diff --git a/action.yml b/action.yml
index 16d2adc..1de681e 100644
--- a/action.yml
+++ b/action.yml
@@ -20,13 +20,17 @@ inputs:
required: false
default: ""
target-agent:
- description: "Scope compat checks: claude, vscode, or all (default: all)."
+ description: "Scope compat checks: claude, vscode, cursor, or all (default: all)."
required: false
default: ""
strict-vscode:
description: "Promote VS Code compat issues to errors."
required: false
default: "false"
+ strict-cursor:
+ description: "Promote Cursor compat issues to errors."
+ required: false
+ default: "false"
skip-dirname-check:
description: "Skip directory-name matching check."
required: false
@@ -117,6 +121,7 @@ runs:
env:
INPUT_PATH: ${{ inputs.path }}
INPUT_STRICT_VSCODE: ${{ inputs.strict-vscode }}
+ INPUT_STRICT_CURSOR: ${{ inputs.strict-cursor }}
INPUT_SKIP_DIRNAME_CHECK: ${{ inputs.skip-dirname-check }}
INPUT_SKIP_REF_CHECK: ${{ inputs.skip-ref-check }}
INPUT_MIN_DESC_SCORE: ${{ inputs.min-desc-score }}
diff --git a/action/entrypoint.py b/action/entrypoint.py
index 3b68da9..7438905 100644
--- a/action/entrypoint.py
+++ b/action/entrypoint.py
@@ -26,6 +26,8 @@ def _build_command() -> list[str]:
if os.environ.get("INPUT_STRICT_VSCODE", "false") == "true":
cmd.append("--strict-vscode")
+ if os.environ.get("INPUT_STRICT_CURSOR", "false") == "true":
+ cmd.append("--strict-cursor")
if os.environ.get("INPUT_SKIP_DIRNAME_CHECK", "false") == "true":
cmd.append("--skip-dirname-check")
if os.environ.get("INPUT_SKIP_REF_CHECK", "false") == "true":
diff --git a/pyproject.toml b/pyproject.toml
index ba6a5e8..58a91f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "skillcheck"
-version = "1.2.1"
+version = "1.2.2"
description = "Cross-agent skill quality gate for SKILL.md files conforming to the agentskills.io specification"
readme = "README.md"
license = { text = "MIT" }
diff --git a/skills/skillcheck/SKILL.md b/skills/skillcheck/SKILL.md
index ae7fa93..2eafd0e 100644
--- a/skills/skillcheck/SKILL.md
+++ b/skills/skillcheck/SKILL.md
@@ -1,7 +1,7 @@
---
name: skillcheck
description: Validates and scores SKILL.md files against the agentskills.io specification; use when linting skills for cross-agent compatibility, description quality, or capability graph structure.
-version: "1.2.1"
+version: "1.2.2"
author: brad
---
diff --git a/src/skillcheck/__init__.py b/src/skillcheck/__init__.py
index cef829d..6236922 100644
--- a/src/skillcheck/__init__.py
+++ b/src/skillcheck/__init__.py
@@ -2,7 +2,7 @@
from skillcheck.parser import ParsedSkill, ParseError
from skillcheck.result import Diagnostic, Severity, ValidationResult
-__version__ = "1.2.1"
+__version__ = "1.2.2"
__all__ = [
"validate",
diff --git a/src/skillcheck/cli.py b/src/skillcheck/cli.py
index b4dc836..2b83196 100644
--- a/src/skillcheck/cli.py
+++ b/src/skillcheck/cli.py
@@ -275,6 +275,8 @@ def _format_agent(
skillcheck SKILL.md --min-desc-score 50 require minimum description quality
skillcheck SKILL.md --target-agent vscode scope checks to VS Code
skillcheck SKILL.md --strict-vscode treat VS Code issues as errors
+ skillcheck SKILL.md --target-agent cursor scope checks to Cursor
+ skillcheck SKILL.md --strict-cursor treat Cursor issues as errors
skillcheck SKILL.md --skip-ref-check skip file reference validation
skillcheck SKILL.md --emit-critique-prompt print prompt for agent self-critique
skillcheck SKILL.md --ingest-critique r.json ingest agent response and merge diagnostics
@@ -363,7 +365,7 @@ def _build_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--target-agent",
- choices=["claude", "vscode", "all"],
+ choices=["claude", "vscode", "cursor", "all"],
default="all",
help="Scope compatibility checks to a specific agent (default: all).",
)
@@ -373,6 +375,12 @@ def _build_parser() -> argparse.ArgumentParser:
default=False,
help="Promote VS Code compatibility issues to errors.",
)
+ parser.add_argument(
+ "--strict-cursor",
+ action="store_true",
+ default=False,
+ help="Promote Cursor compatibility issues to errors.",
+ )
parser.add_argument(
"--warnings-as-errors",
action="store_true",
@@ -681,6 +689,8 @@ def _apply_config(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
args.target_agent = loaded_config.target_agent
if loaded_config.strict_vscode is True:
args.strict_vscode = True
+ if loaded_config.strict_cursor is True:
+ args.strict_cursor = True
if loaded_config.skip_dirname_check is True:
args.skip_dirname_check = True
if loaded_config.skip_ref_check is True:
@@ -712,8 +722,8 @@ def main() -> None:
if args.format not in {"text", "json", "md", "agent"}:
parser.error("format must be one of: text, json, md, agent")
- if args.target_agent not in {"claude", "vscode", "all"}:
- parser.error("target-agent must be one of: claude, vscode, all")
+ if args.target_agent not in {"claude", "vscode", "cursor", "all"}:
+ parser.error("target-agent must be one of: claude, vscode, cursor, all")
if args.critique_agent is not None and args.critique_agent not in {"claude", "codex", "cursor"}:
parser.error("critique-agent must be one of: claude, codex, cursor")
if args.graph_agent is not None and args.graph_agent not in {"claude", "codex", "cursor"}:
@@ -942,6 +952,7 @@ def main() -> None:
skip_ref_check=args.skip_ref_check,
min_desc_score=args.min_desc_score,
strict_vscode=args.strict_vscode,
+ strict_cursor=args.strict_cursor,
target_agent=args.target_agent,
)
for p in paths
diff --git a/src/skillcheck/config_loader.py b/src/skillcheck/config_loader.py
index b7b8ec2..f2464f0 100644
--- a/src/skillcheck/config_loader.py
+++ b/src/skillcheck/config_loader.py
@@ -25,6 +25,7 @@ class SkillcheckConfig:
min_desc_score: int | None = None
target_agent: str | None = None
strict_vscode: bool | None = None
+ strict_cursor: bool | None = None
skip_dirname_check: bool | None = None
skip_ref_check: bool | None = None
ignore: tuple[str, ...] = ()
@@ -52,6 +53,8 @@ class ConfigError(Exception):
"target_agent": "target_agent",
"strict-vscode": "strict_vscode",
"strict_vscode": "strict_vscode",
+ "strict-cursor": "strict_cursor",
+ "strict_cursor": "strict_cursor",
"skip-dirname-check": "skip_dirname_check",
"skip_dirname_check": "skip_dirname_check",
"skip-ref-check": "skip_ref_check",
@@ -68,7 +71,7 @@ class ConfigError(Exception):
}
_INT_FIELDS = {"max_lines", "max_tokens", "min_desc_score"}
-_BOOL_FIELDS = {"strict_vscode", "skip_dirname_check", "skip_ref_check", "analyze_graph", "semantic", "history"}
+_BOOL_FIELDS = {"strict_vscode", "strict_cursor", "skip_dirname_check", "skip_ref_check", "analyze_graph", "semantic", "history"}
_STR_FIELDS = {"format", "target_agent", "critique_agent", "graph_agent"}
diff --git a/src/skillcheck/core/symbolic.py b/src/skillcheck/core/symbolic.py
index 5ba9c8a..f339c06 100644
--- a/src/skillcheck/core/symbolic.py
+++ b/src/skillcheck/core/symbolic.py
@@ -17,6 +17,7 @@ def validate(
skip_ref_check: bool = False,
min_desc_score: int | None = None,
strict_vscode: bool = False,
+ strict_cursor: bool = False,
target_agent: str = "all",
) -> ValidationResult:
"""Validate a single SKILL.md file using deterministic symbolic rules.
@@ -30,6 +31,7 @@ def validate(
skip_ref_check: Skip file reference validation.
min_desc_score: Minimum description quality score.
strict_vscode: Promote VS Code compatibility issues to errors.
+ strict_cursor: Promote Cursor compatibility issues to errors.
target_agent: Scope compatibility checks to an agent target.
Returns:
@@ -56,6 +58,7 @@ def validate(
skip_ref_check=skip_ref_check,
min_desc_score=min_desc_score,
strict_vscode=strict_vscode,
+ strict_cursor=strict_cursor,
target_agent=target_agent,
)
diagnostics: list[Diagnostic] = [
diff --git a/src/skillcheck/rules/__init__.py b/src/skillcheck/rules/__init__.py
index 2e2c67e..5813781 100644
--- a/src/skillcheck/rules/__init__.py
+++ b/src/skillcheck/rules/__init__.py
@@ -7,8 +7,11 @@
from skillcheck.result import Diagnostic
from skillcheck.rules.compat import (
check_claude_only_fields,
+ check_cursor_description_block_scalar,
+ check_cursor_description_block_scalar_warning,
check_unverified_fields,
check_vscode_dirname,
+ make_strict_cursor_rule,
make_strict_vscode_rule,
)
from skillcheck.rules.description import (
@@ -83,6 +86,7 @@
check_claude_only_fields,
check_vscode_dirname,
check_unverified_fields,
+ check_cursor_description_block_scalar,
]
@@ -93,6 +97,7 @@ def get_rules(
skip_ref_check: bool = False,
min_desc_score: int | None = None,
strict_vscode: bool = False,
+ strict_cursor: bool = False,
target_agent: str = "all",
) -> list[Callable[[ParsedSkill], list[Diagnostic]]]:
"""Build the full rule list, optionally overriding thresholds and toggling features."""
@@ -123,7 +128,7 @@ def get_rules(
rules.extend(_DISCLOSURE_RULES)
# Cross-agent compatibility (Feature 5)
- _VALID_AGENTS = {"all", "claude", "vscode"}
+ _VALID_AGENTS = {"all", "claude", "vscode", "cursor"}
if target_agent not in _VALID_AGENTS:
raise ValueError(
f"Unknown target_agent '{target_agent}'. "
@@ -137,6 +142,12 @@ def get_rules(
# so the same mismatch is not reported twice.
compat_rules = [r for r in compat_rules if r is not check_vscode_dirname]
compat_rules.append(make_strict_vscode_rule())
+ if strict_cursor:
+ compat_rules = [
+ r for r in compat_rules
+ if r is not check_cursor_description_block_scalar
+ ]
+ compat_rules.append(make_strict_cursor_rule())
rules.extend(compat_rules)
elif target_agent == "vscode":
if strict_vscode:
@@ -145,5 +156,10 @@ def get_rules(
rules.append(check_vscode_dirname)
elif target_agent == "claude":
rules.append(check_claude_only_fields)
+ elif target_agent == "cursor":
+ if strict_cursor:
+ rules.append(make_strict_cursor_rule())
+ else:
+ rules.append(check_cursor_description_block_scalar_warning)
return rules
diff --git a/src/skillcheck/rules/compat.py b/src/skillcheck/rules/compat.py
index 48a94d7..5e6724f 100644
--- a/src/skillcheck/rules/compat.py
+++ b/src/skillcheck/rules/compat.py
@@ -10,13 +10,23 @@
from __future__ import annotations
+import re
from collections.abc import Callable
from skillcheck import config
+from skillcheck.parser import _FRONTMATTER_RE
from skillcheck.parser import ParsedSkill
from skillcheck.result import Diagnostic, Severity
from skillcheck.template_detection import is_template
+# Detects a block scalar marker (>, >+, |, |+) on the description field.
+# >- and |- strip trailing whitespace and render correctly in Cursor's UI,
+# so they are deliberately excluded from the unsafe-marker group.
+_CURSOR_UNSAFE_DESC_BLOCK_SCALAR = re.compile(
+ r"^description\s*:\s*(?P[>|]\+?)\s*(?:#.*)?$",
+ re.MULTILINE,
+)
+
def check_claude_only_fields(skill: ParsedSkill) -> list[Diagnostic]:
"""Flag fields that only work in Claude Code."""
@@ -120,3 +130,70 @@ def check_strict_vscode(skill: ParsedSkill) -> list[Diagnostic]:
check_strict_vscode.__name__ = "check_strict_vscode"
return check_strict_vscode
+
+
+def _detect_cursor_unsafe_block_scalar(skill: ParsedSkill) -> str | None:
+ """Return the offending block-scalar marker on description, else None.
+
+ PyYAML's safe_load discards the scalar style indicator, so we re-scan the
+ raw frontmatter block. The simpler regex path avoids switching the parser
+ to yaml.compose() and reaches into node attributes from every rule.
+ """
+ match = _FRONTMATTER_RE.match(skill.raw_text)
+ if match is None:
+ return None
+ block = match.group(1)
+ marker_match = _CURSOR_UNSAFE_DESC_BLOCK_SCALAR.search(block)
+ if marker_match is None:
+ return None
+ return marker_match.group("marker")
+
+
+def _cursor_block_scalar_message(marker: str) -> str:
+ """Return the diagnostic message for an unsafe Cursor block scalar."""
+ return (
+ f"description uses block scalar '{marker}' which Cursor's skills "
+ f"UI renders as empty (got 'description: {marker}'). Use "
+ f"'description: >-' (folded strip) instead."
+ )
+
+
+def check_cursor_description_block_scalar(skill: ParsedSkill) -> list[Diagnostic]:
+ """Flag Cursor-unsafe block scalars on the description field at INFO."""
+ marker = _detect_cursor_unsafe_block_scalar(skill)
+ if marker is None:
+ return []
+ return [Diagnostic(
+ rule="compat.cursor-description-block-scalar",
+ severity=Severity.INFO,
+ message=_cursor_block_scalar_message(marker),
+ )]
+
+
+def check_cursor_description_block_scalar_warning(skill: ParsedSkill) -> list[Diagnostic]:
+ """WARNING-severity variant for --target-agent cursor."""
+ marker = _detect_cursor_unsafe_block_scalar(skill)
+ if marker is None:
+ return []
+ return [Diagnostic(
+ rule="compat.cursor-description-block-scalar",
+ severity=Severity.WARNING,
+ message=_cursor_block_scalar_message(marker),
+ )]
+
+
+def make_strict_cursor_rule() -> Callable[[ParsedSkill], list[Diagnostic]]:
+ """Return a rule that promotes Cursor block-scalar issues to ERROR."""
+
+ def check_strict_cursor(skill: ParsedSkill) -> list[Diagnostic]:
+ marker = _detect_cursor_unsafe_block_scalar(skill)
+ if marker is None:
+ return []
+ return [Diagnostic(
+ rule="compat.cursor-description-block-scalar",
+ severity=Severity.ERROR,
+ message=_cursor_block_scalar_message(marker),
+ )]
+
+ check_strict_cursor.__name__ = "check_strict_cursor"
+ return check_strict_cursor
diff --git a/src/skillcheck/rules/frontmatter_common.py b/src/skillcheck/rules/frontmatter_common.py
index 5a9480b..8dedae6 100644
--- a/src/skillcheck/rules/frontmatter_common.py
+++ b/src/skillcheck/rules/frontmatter_common.py
@@ -1,5 +1,12 @@
from __future__ import annotations
+import re
+
+# Detects ``## name:`` style markdown headings inside the frontmatter block.
+# The heading variant is what fails: PyYAML drops the leading ``#`` as a
+# comment, so the field never lands in the parsed mapping.
+_FRONTMATTER_HEADING_RE = re.compile(r"^\s*#{1,6}\s+(\w+)\s*:", re.MULTILINE)
+
def _field_line(raw_text: str, field: str) -> int | None:
"""Return the 1-based line number where a frontmatter field appears.
@@ -16,3 +23,41 @@ def _field_line(raw_text: str, field: str) -> int | None:
if line.lstrip().startswith(f"{field}:"):
return i
return None
+
+
+def _frontmatter_block(raw_text: str) -> str | None:
+ """Return the raw YAML body between the leading and closing ``---``.
+
+ Returns None when no frontmatter block is found.
+ """
+ lines = raw_text.splitlines()
+ if not lines or lines[0].strip() != "---":
+ return None
+ collected: list[str] = []
+ for line in lines[1:]:
+ if line.strip() == "---":
+ return "\n".join(collected)
+ collected.append(line)
+ return None
+
+
+def _heading_for_field(raw_text: str, field: str) -> bool:
+ """Return True when ``field`` appears as a ``## name:`` markdown heading.
+
+ The frontmatter block is scanned only; body headings do not trigger.
+ """
+ block = _frontmatter_block(raw_text)
+ if block is None:
+ return False
+ for match in _FRONTMATTER_HEADING_RE.finditer(block):
+ if match.group(1) == field:
+ return True
+ return False
+
+
+def _markdown_heading_hint(field: str) -> str:
+ """Return the appended hint for a ``## field:`` heading inside frontmatter."""
+ return (
+ f" hint: found '## {field}:' as a markdown heading inside frontmatter. "
+ f"Frontmatter keys are YAML, not markdown. Use '{field}: value' (no '##')."
+ )
diff --git a/src/skillcheck/rules/frontmatter_description.py b/src/skillcheck/rules/frontmatter_description.py
index 6a3564c..77803c5 100644
--- a/src/skillcheck/rules/frontmatter_description.py
+++ b/src/skillcheck/rules/frontmatter_description.py
@@ -5,7 +5,11 @@
from skillcheck import config
from skillcheck.parser import ParsedSkill
from skillcheck.result import Diagnostic, Severity
-from skillcheck.rules.frontmatter_common import _field_line
+from skillcheck.rules.frontmatter_common import (
+ _field_line,
+ _heading_for_field,
+ _markdown_heading_hint,
+)
_XML_TAG_RE = re.compile(r"<[a-zA-Z/][^>]*>")
_FIRST_PERSON_RE = re.compile(
@@ -40,10 +44,13 @@ def check_description_type(skill: ParsedSkill) -> list[Diagnostic]:
def check_description_required(skill: ParsedSkill) -> list[Diagnostic]:
if "description" not in skill.frontmatter:
+ message = "Required field 'description' is missing from frontmatter."
+ if _heading_for_field(skill.raw_text, "description"):
+ message += _markdown_heading_hint("description")
return [Diagnostic(
rule="frontmatter.description.required",
severity=Severity.ERROR,
- message="Required field 'description' is missing from frontmatter.",
+ message=message,
)]
return []
diff --git a/src/skillcheck/rules/frontmatter_name.py b/src/skillcheck/rules/frontmatter_name.py
index 85f76b4..2eb416f 100644
--- a/src/skillcheck/rules/frontmatter_name.py
+++ b/src/skillcheck/rules/frontmatter_name.py
@@ -5,7 +5,11 @@
from skillcheck import config
from skillcheck.parser import ParsedSkill
from skillcheck.result import Diagnostic, Severity
-from skillcheck.rules.frontmatter_common import _field_line
+from skillcheck.rules.frontmatter_common import (
+ _field_line,
+ _heading_for_field,
+ _markdown_heading_hint,
+)
from skillcheck.template_detection import is_template
_NAME_VALID_CHARS_RE = re.compile(r"^[a-z0-9-]+$")
@@ -13,10 +17,13 @@
def check_name_required(skill: ParsedSkill) -> list[Diagnostic]:
if skill.frontmatter.get("name") is None:
+ message = "Required field 'name' is missing from frontmatter."
+ if _heading_for_field(skill.raw_text, "name"):
+ message += _markdown_heading_hint("name")
return [Diagnostic(
rule="frontmatter.name.required",
severity=Severity.ERROR,
- message="Required field 'name' is missing from frontmatter.",
+ message=message,
)]
return []
diff --git a/tests/fixtures/cursor_desc_folded_keep.md b/tests/fixtures/cursor_desc_folded_keep.md
new file mode 100644
index 0000000..8b670ff
--- /dev/null
+++ b/tests/fixtures/cursor_desc_folded_keep.md
@@ -0,0 +1,12 @@
+---
+name: cursor-folded-keep
+description: >
+ Generates a description that PyYAML accepts but Cursor's UI parser
+ silently drops, leaving the skill panel empty. Use when reproducing
+ the issue #1 folded-keep case.
+---
+
+# Body
+
+This SKILL.md uses `description: >` (folded keep). PyYAML accepts it,
+but Cursor's skills UI renders the description as empty.
diff --git a/tests/fixtures/cursor_desc_folded_strip.md b/tests/fixtures/cursor_desc_folded_strip.md
new file mode 100644
index 0000000..91b40de
--- /dev/null
+++ b/tests/fixtures/cursor_desc_folded_strip.md
@@ -0,0 +1,12 @@
+---
+name: cursor-folded-strip
+description: >-
+ Validates SKILL.md files for Cursor compatibility. Use when checking
+ that folded-strip (>-) descriptions render correctly in Cursor's UI.
+---
+
+# Body
+
+This SKILL.md uses `description: >-` (folded strip). PyYAML and Cursor
+both render the description correctly. This fixture must not trigger
+the cursor-description-block-scalar rule.
diff --git a/tests/fixtures/cursor_desc_literal.md b/tests/fixtures/cursor_desc_literal.md
new file mode 100644
index 0000000..fc58aeb
--- /dev/null
+++ b/tests/fixtures/cursor_desc_literal.md
@@ -0,0 +1,12 @@
+---
+name: cursor-literal
+description: |
+ Generates a description that PyYAML accepts but Cursor's UI parser
+ silently drops, leaving the skill panel empty. Use when reproducing
+ the issue #1 literal block scalar case.
+---
+
+# Body
+
+This SKILL.md uses `description: |` (literal). PyYAML accepts it,
+but Cursor's skills UI renders the description as empty.
diff --git a/tests/fixtures/frontmatter_heading_partial.md b/tests/fixtures/frontmatter_heading_partial.md
new file mode 100644
index 0000000..571da57
--- /dev/null
+++ b/tests/fixtures/frontmatter_heading_partial.md
@@ -0,0 +1,10 @@
+---
+## name: my-skill
+description: Validates SKILL.md files. Use when checking only the name field is supplied as a markdown heading.
+---
+
+# Body
+
+This SKILL.md uses `## name:` as a markdown heading but provides
+description as a real YAML key. Only the name.required diagnostic should
+carry the markdown-heading hint.
diff --git a/tests/fixtures/frontmatter_name_as_heading.md b/tests/fixtures/frontmatter_name_as_heading.md
new file mode 100644
index 0000000..b0e5609
--- /dev/null
+++ b/tests/fixtures/frontmatter_name_as_heading.md
@@ -0,0 +1,10 @@
+---
+## name: my-skill
+## description: Validates SKILL.md files using markdown headings instead of YAML keys.
+---
+
+# Body
+
+This SKILL.md mistakes YAML frontmatter for markdown and uses `## name:`
+and `## description:` as if they were headings. Both keys end up missing
+from the parsed frontmatter.
diff --git a/tests/test_cli.py b/tests/test_cli.py
index e64d5ec..8f72871 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -270,3 +270,33 @@ def test_quiet_flag_with_json_format():
result = run_fixture(str(FIXTURES_DIR / "valid_basic.md"), "--quiet", "--format", "json")
assert result.returncode == 0
assert result.stdout == ""
+
+
+# ---------------------------------------------------------------------------
+# Issue #1: --strict-cursor exit code and target-agent acceptance
+# ---------------------------------------------------------------------------
+
+def test_strict_cursor_fails_on_folded_keep():
+ result = run_fixture(
+ str(FIXTURES_DIR / "cursor_desc_folded_keep.md"),
+ "--strict-cursor",
+ )
+ assert result.returncode == 1
+ assert "compat.cursor-description-block-scalar" in result.stdout
+
+
+def test_strict_cursor_passes_on_folded_strip():
+ result = run_fixture(
+ str(FIXTURES_DIR / "cursor_desc_folded_strip.md"),
+ "--strict-cursor",
+ )
+ assert result.returncode == 0
+
+
+def test_target_agent_cursor_accepted():
+ result = run_fixture(
+ str(FIXTURES_DIR / "cursor_desc_folded_keep.md"),
+ "--target-agent", "cursor",
+ )
+ assert "invalid choice" not in (result.stderr or "")
+ assert "compat.cursor-description-block-scalar" in result.stdout
diff --git a/tests/test_compat.py b/tests/test_compat.py
index 91ade7f..c7a211d 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -7,8 +7,11 @@
from skillcheck.rules import get_rules
from skillcheck.rules.compat import (
check_claude_only_fields,
+ check_cursor_description_block_scalar,
+ check_cursor_description_block_scalar_warning,
check_unverified_fields,
check_vscode_dirname,
+ make_strict_cursor_rule,
make_strict_vscode_rule,
)
from tests.conftest import FIXTURES_DIR
@@ -160,4 +163,125 @@ def test_strict_vscode_all_emits_single_dirname_diagnostic(tmp_path):
def test_invalid_target_agent_raises():
"""An invalid target_agent should raise ValueError, not silently skip rules."""
with pytest.raises(ValueError, match="Unknown target_agent"):
- get_rules(target_agent="cursor")
+ get_rules(target_agent="vim")
+
+
+# ---------------------------------------------------------------------------
+# compat.cursor-description-block-scalar (issue #1)
+# ---------------------------------------------------------------------------
+
+_RULE_ID = "compat.cursor-description-block-scalar"
+
+
+def test_cursor_block_scalar_flags_folded_keep():
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ diagnostics = check_cursor_description_block_scalar(skill)
+ assert len(diagnostics) == 1
+ d = diagnostics[0]
+ assert d.rule == _RULE_ID
+ assert d.severity == Severity.INFO
+ assert "'>'" in d.message
+ assert ">-" in d.message
+
+
+def test_cursor_block_scalar_flags_literal():
+ skill = parse(FIXTURES_DIR / "cursor_desc_literal.md")
+ diagnostics = check_cursor_description_block_scalar(skill)
+ assert len(diagnostics) == 1
+ d = diagnostics[0]
+ assert d.rule == _RULE_ID
+ assert d.severity == Severity.INFO
+ assert "'|'" in d.message
+ assert ">-" in d.message
+
+
+def test_cursor_block_scalar_passes_folded_strip():
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_strip.md")
+ assert check_cursor_description_block_scalar(skill) == []
+
+
+def test_cursor_block_scalar_passes_literal_strip(tmp_path):
+ f = tmp_path / "SKILL.md"
+ f.write_text(
+ "---\nname: a\ndescription: |-\n Strip variant.\n---\nbody\n"
+ )
+ skill = parse(f)
+ assert check_cursor_description_block_scalar(skill) == []
+
+
+def test_cursor_block_scalar_passes_inline_string(tmp_path):
+ f = tmp_path / "SKILL.md"
+ f.write_text(
+ "---\nname: a\ndescription: A plain inline string.\n---\nbody\n"
+ )
+ skill = parse(f)
+ assert check_cursor_description_block_scalar(skill) == []
+
+
+def test_cursor_block_scalar_passes_folded_keep_explicit(tmp_path):
+ f = tmp_path / "SKILL.md"
+ f.write_text(
+ "---\nname: a\ndescription: >+\n Plus keep variant.\n---\nbody\n"
+ )
+ skill = parse(f)
+ diagnostics = check_cursor_description_block_scalar(skill)
+ assert len(diagnostics) == 1
+ assert "'>+'" in diagnostics[0].message
+
+
+def test_cursor_block_scalar_target_cursor_promotes_to_warning():
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ diagnostics = check_cursor_description_block_scalar_warning(skill)
+ assert len(diagnostics) == 1
+ assert diagnostics[0].severity == Severity.WARNING
+ assert diagnostics[0].rule == _RULE_ID
+
+
+def test_cursor_block_scalar_strict_cursor_promotes_to_error():
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ rule = make_strict_cursor_rule()
+ diagnostics = rule(skill)
+ assert len(diagnostics) == 1
+ assert diagnostics[0].severity == Severity.ERROR
+ assert diagnostics[0].rule == _RULE_ID
+
+
+def test_cursor_block_scalar_default_severity_is_info():
+ """Default severity (no flag) is INFO."""
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ rules = get_rules(target_agent="all")
+ diagnostics = [d for r in rules for d in r(skill) if d.rule == _RULE_ID]
+ assert len(diagnostics) == 1
+ assert diagnostics[0].severity == Severity.INFO
+
+
+def test_cursor_block_scalar_target_cursor_emits_single_warning():
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ rules = get_rules(target_agent="cursor")
+ diagnostics = [d for r in rules for d in r(skill) if d.rule == _RULE_ID]
+ assert len(diagnostics) == 1
+ assert diagnostics[0].severity == Severity.WARNING
+
+
+def test_cursor_block_scalar_strict_cursor_replaces_info():
+ """strict_cursor + target_agent='all' should emit one ERROR, not duplicates."""
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ rules = get_rules(strict_cursor=True, target_agent="all")
+ diagnostics = [d for r in rules for d in r(skill) if d.rule == _RULE_ID]
+ assert len(diagnostics) == 1
+ assert diagnostics[0].severity == Severity.ERROR
+
+
+def test_cursor_block_scalar_strict_cursor_target_cursor():
+ """strict_cursor with target_agent='cursor' should also produce one ERROR."""
+ skill = parse(FIXTURES_DIR / "cursor_desc_folded_keep.md")
+ rules = get_rules(strict_cursor=True, target_agent="cursor")
+ diagnostics = [d for r in rules for d in r(skill) if d.rule == _RULE_ID]
+ assert len(diagnostics) == 1
+ assert diagnostics[0].severity == Severity.ERROR
+
+
+def test_cursor_target_agent_accepts_cursor():
+ """target_agent='cursor' must not raise."""
+ rules = get_rules(target_agent="cursor")
+ assert len(rules) > 0
diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py
index 19845c4..f6711c2 100644
--- a/tests/test_frontmatter.py
+++ b/tests/test_frontmatter.py
@@ -306,3 +306,67 @@ def test_description_rejects_first_person_possessive(tmp_path):
diagnostics = check_description_person_voice(skill)
assert len(diagnostics) == 1
assert diagnostics[0].rule == "frontmatter.description.person-voice"
+
+
+# ---------------------------------------------------------------------------
+# Issue #1: markdown-heading hint on name/description required
+# ---------------------------------------------------------------------------
+
+def test_name_required_hints_at_markdown_heading():
+ skill = parse(FIXTURES_DIR / "frontmatter_name_as_heading.md")
+ diagnostics = check_name_required(skill)
+ assert len(diagnostics) == 1
+ msg = diagnostics[0].message
+ assert "## name:" in msg
+ assert "markdown" in msg.lower()
+ assert "name: value" in msg
+
+
+def test_description_required_hints_at_markdown_heading():
+ skill = parse(FIXTURES_DIR / "frontmatter_name_as_heading.md")
+ diagnostics = check_description_required(skill)
+ assert len(diagnostics) == 1
+ msg = diagnostics[0].message
+ assert "## description:" in msg
+ assert "markdown" in msg.lower()
+ assert "description: value" in msg
+
+
+def test_name_required_hint_absent_when_heading_only_for_other_field():
+ """Hint must reference name only when the heading is for name."""
+ skill = parse(FIXTURES_DIR / "frontmatter_heading_partial.md")
+ diagnostics = check_name_required(skill)
+ assert len(diagnostics) == 1
+ assert "## name:" in diagnostics[0].message
+ # description was supplied as a real key, so its required check passes.
+ assert check_description_required(skill) == []
+
+
+def test_name_required_no_hint_for_plain_missing(tmp_path):
+ f = tmp_path / "SKILL.md"
+ f.write_text("---\ndescription: Plain missing name.\n---\nbody\n")
+ skill = parse(f)
+ diagnostics = check_name_required(skill)
+ assert len(diagnostics) == 1
+ assert "hint" not in diagnostics[0].message.lower()
+
+
+def test_description_required_no_hint_for_plain_missing(tmp_path):
+ f = tmp_path / "SKILL.md"
+ f.write_text("---\nname: my-skill\n---\nbody\n")
+ skill = parse(f)
+ diagnostics = check_description_required(skill)
+ assert len(diagnostics) == 1
+ assert "hint" not in diagnostics[0].message.lower()
+
+
+def test_heading_hint_does_not_match_body_headings(tmp_path):
+ """Body markdown headings must not produce false-positive hints."""
+ f = tmp_path / "SKILL.md"
+ f.write_text(
+ "---\ndescription: Body has a name heading.\n---\n\n## name: section in body\n"
+ )
+ skill = parse(f)
+ diagnostics = check_name_required(skill)
+ assert len(diagnostics) == 1
+ assert "hint" not in diagnostics[0].message.lower()