From 5688c47e34fbc8dea4cf8af2e511a091517189dd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 3 May 2026 15:47:40 -0600 Subject: [PATCH 1/5] test: add fixtures and tests for Cursor block-scalar and heading-frontmatter cases (#1) --- tests/fixtures/cursor_desc_folded_keep.md | 12 ++ tests/fixtures/cursor_desc_folded_strip.md | 12 ++ tests/fixtures/cursor_desc_literal.md | 12 ++ tests/fixtures/frontmatter_heading_partial.md | 10 ++ tests/fixtures/frontmatter_name_as_heading.md | 10 ++ tests/test_cli.py | 30 +++++ tests/test_compat.py | 126 +++++++++++++++++- tests/test_frontmatter.py | 64 +++++++++ 8 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/cursor_desc_folded_keep.md create mode 100644 tests/fixtures/cursor_desc_folded_strip.md create mode 100644 tests/fixtures/cursor_desc_literal.md create mode 100644 tests/fixtures/frontmatter_heading_partial.md create mode 100644 tests/fixtures/frontmatter_name_as_heading.md 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() From 5cc17c8b2542ff74481029d0a5df888417cd9172 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 3 May 2026 15:47:46 -0600 Subject: [PATCH 2/5] feat: add compat.cursor-description-block-scalar rule and --strict-cursor flag (#1) --- action.yml | 7 ++- action/entrypoint.py | 2 + src/skillcheck/cli.py | 17 +++++-- src/skillcheck/config_loader.py | 5 ++- src/skillcheck/core/symbolic.py | 3 ++ src/skillcheck/rules/__init__.py | 18 +++++++- src/skillcheck/rules/compat.py | 77 ++++++++++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 6 deletions(-) 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/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 From 07c89d894321613baa142df9783b8e981c4eefd1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 3 May 2026 15:47:52 -0600 Subject: [PATCH 3/5] fix: hint at markdown-heading frontmatter cause in name/description required errors (#1) --- src/skillcheck/rules/frontmatter_common.py | 45 +++++++++++++++++++ .../rules/frontmatter_description.py | 11 ++++- src/skillcheck/rules/frontmatter_name.py | 11 ++++- 3 files changed, 63 insertions(+), 4 deletions(-) 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 [] From 01ee2f686ef634d98825928abf892c0e67af2f41 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 3 May 2026 15:47:57 -0600 Subject: [PATCH 4/5] docs: update README rules table and options for v1.2.2 --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 87ff1aa..b104264 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ PyPI version Python CI status License -**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 | From 2111e6bff406eb855273d1c3e40db58cefc5c4de Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 3 May 2026 15:47:57 -0600 Subject: [PATCH 5/5] chore: bump to 1.2.2 --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- skills/skillcheck/SKILL.md | 2 +- src/skillcheck/__init__.py | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) 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/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",