Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@

<img src="https://img.shields.io/pypi/v/skillcheck?style=flat-square" alt="PyPI version"> <img src="https://img.shields.io/pypi/pyversions/skillcheck?style=flat-square" alt="Python"> <img src="https://img.shields.io/github/actions/workflow/status/moonrunnerkc/skillcheck/ci.yml?style=flat-square" alt="CI status"> <img src="https://img.shields.io/github/license/moonrunnerkc/skillcheck?style=flat-square" alt="License">

**v1.2.1 · 687 tests cover all rule modules · production**
**v1.2.2 · 709 tests cover all rule modules · production**

</div>

687 tests cover all rule modules, 0 known false positives.
709 tests cover all rule modules, 0 known false positives.

---

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 6 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions action/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion skills/skillcheck/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down
2 changes: 1 addition & 1 deletion src/skillcheck/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions src/skillcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).",
)
Expand 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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/skillcheck/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = ()
Expand Down Expand Up @@ -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",
Expand All @@ -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"}


Expand Down
3 changes: 3 additions & 0 deletions src/skillcheck/core/symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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] = [
Expand Down
18 changes: 17 additions & 1 deletion src/skillcheck/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -83,6 +86,7 @@
check_claude_only_fields,
check_vscode_dirname,
check_unverified_fields,
check_cursor_description_block_scalar,
]


Expand All @@ -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."""
Expand Down Expand Up @@ -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}'. "
Expand All @@ -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:
Expand All @@ -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
77 changes: 77 additions & 0 deletions src/skillcheck/rules/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<marker>[>|]\+?)\s*(?:#.*)?$",
re.MULTILINE,
)


def check_claude_only_fields(skill: ParsedSkill) -> list[Diagnostic]:
"""Flag fields that only work in Claude Code."""
Expand Down Expand Up @@ -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
Loading
Loading