Skip to content
Open
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
36 changes: 36 additions & 0 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,42 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
return frontmatter, body


def parse_frontmatter_strict(content: str) -> Tuple[Dict[str, Any], str, Optional[str]]:
"""Parse YAML frontmatter without the lenient key:value fallback.

Unlike parse_frontmatter(), a YAML failure is surfaced instead of being
papered over — callers that validate skills (e.g. the linter) need to know
when the runtime would silently degrade to naive parsing.

Returns:
(frontmatter_dict, remaining_body, yaml_error_or_None)
"""
frontmatter: Dict[str, Any] = {}
body = content

if not content.startswith("---"):
return frontmatter, body, None

end_match = re.search(r"\n---\s*\n", content[3:])
if not end_match:
return frontmatter, body, None

yaml_content = content[3 : end_match.start() + 3]
body = content[end_match.end() + 3 :]

try:
parsed = yaml_load(yaml_content)
except Exception as exc:
return frontmatter, body, str(exc)

if isinstance(parsed, dict):
frontmatter = parsed
elif parsed is not None:
return frontmatter, body, f"frontmatter is {type(parsed).__name__}, expected a mapping"

return frontmatter, body, None


# ── Platform matching ─────────────────────────────────────────────────────


Expand Down
111 changes: 94 additions & 17 deletions hermes_cli/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,80 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None,
c.print()


def do_lint(targets: Optional[List[str]] = None, all_skills: bool = False,
as_json: bool = False, fail_on: str = "error",
console: Optional[Console] = None) -> int:
"""Validate skill structure. Returns an exit code (0/1/2) for CI use."""
from tools.skills_lint import (
collect_installed_skill_names,
find_installed_skill_paths,
format_lint_report,
lint_skill,
)

c = console or _console
targets = targets or []

if all_skills and targets:
c.print("[bold red]Error:[/] --all cannot be combined with explicit targets.\n")
return 2

# Resolve targets to skill directories
skill_dirs: List[Path] = []
if all_skills:
skill_dirs = sorted(set(find_installed_skill_paths().values()),
key=lambda p: str(p))
if not skill_dirs:
c.print("[dim]No installed skills found.[/]\n")
return 0
elif targets:
installed = None
for target in targets:
candidate = Path(target)
if candidate.is_dir() or ("/" in target or "\\" in target):
if not (candidate / "SKILL.md").exists() and candidate.name != "SKILL.md":
c.print(f"[bold red]Error:[/] no SKILL.md found at '{target}'.\n")
return 2
skill_dirs.append(candidate)
continue
if installed is None:
installed = find_installed_skill_paths()
if target in installed:
skill_dirs.append(installed[target])
else:
c.print(f"[bold red]Error:[/] '{target}' is not a path or an installed skill.\n")
return 2
else:
if Path("SKILL.md").exists():
skill_dirs.append(Path("."))
else:
c.print("Usage: hermes skills lint [targets ...] | --all "
"(or run from a directory containing SKILL.md)\n")
return 2

known_names = collect_installed_skill_names()
results = [lint_skill(d, known_skill_names=known_names) for d in skill_dirs]

if as_json:
print(json.dumps([r.to_dict() for r in results], indent=2))
else:
for result in results:
c.print(format_lint_report(result))
c.print()
total_errors = sum(len(r.errors) for r in results)
total_warnings = sum(len(r.warnings) for r in results)
summary = (f"{len(results)} skill(s) linted: "
f"{total_errors} error(s), {total_warnings} warning(s)")
if total_errors:
c.print(f"[bold red]{summary}[/]\n")
elif total_warnings:
c.print(f"[yellow]{summary}[/]\n")
else:
c.print(f"[bold green]{summary}[/]\n")

return 1 if any(r.exit_relevant(fail_on) for r in results) else 0


def do_uninstall(name: str, console: Optional[Console] = None,
skip_confirm: bool = False,
invalidate_cache: bool = True) -> None:
Expand Down Expand Up @@ -1310,25 +1384,19 @@ def do_publish(skill_path: str, target: str = "github", repo: str = "",
c.print(f"[bold red]Error:[/] No SKILL.md found at {path}\n")
return

# Validate the skill
import yaml
skill_md = (path / "SKILL.md").read_text(encoding="utf-8")
fm = {}
if skill_md.startswith("---"):
import re
match = re.search(r'\n---\s*\n', skill_md[3:])
if match:
try:
fm = yaml.safe_load(skill_md[3:match.start() + 3]) or {}
except yaml.YAMLError:
pass
# Lint the skill structure before the security scan — lint errors block,
# warnings are informational.
from tools.skills_lint import format_lint_report, lint_skill

name = fm.get("name", path.name)
description = fm.get("description", "")
if not description:
c.print("[bold red]Error:[/] SKILL.md must have a 'description' in frontmatter.\n")
lint_result = lint_skill(path)
if lint_result.findings:
c.print(format_lint_report(lint_result))
if lint_result.errors:
c.print("[bold red]Cannot publish: fix the lint error(s) above first.[/]\n")
return

name = lint_result.skill_name

# Self-scan before publishing
c.print(f"[bold]Scanning '{name}' before publish...[/]")
result = scan_skill(path, source="self")
Expand Down Expand Up @@ -1578,6 +1646,15 @@ def skills_command(args) -> None:
elif action == "audit":
do_audit(name=getattr(args, "name", None),
deep=getattr(args, "deep", False))
elif action == "lint":
# do_lint returns an exit code for CI; skills_command's caller does not
# propagate return values, so raise SystemExit here (cf. cmd_security).
raise SystemExit(do_lint(
targets=getattr(args, "targets", None),
all_skills=getattr(args, "all_skills", False),
as_json=getattr(args, "json", False),
fail_on=getattr(args, "fail_on", "error"),
))
elif action == "uninstall":
do_uninstall(args.name)
elif action == "reset":
Expand Down Expand Up @@ -1613,7 +1690,7 @@ def skills_command(args) -> None:
return
do_tap(tap_action, repo=repo)
else:
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|lint|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Run 'hermes skills <command> --help' for details.\n")


Expand Down
28 changes: 28 additions & 0 deletions hermes_cli/subcommands/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,34 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
help="Run AST-level analysis on Python files (opt-in diagnostic)",
)

skills_lint = skills_subparsers.add_parser(
"lint",
help="Validate skill structure (frontmatter, fields, requirements)",
description=(
"Validate SKILL.md structure: frontmatter syntax, required fields, "
"field constraints, and requirement declarations. Targets can be "
"paths to skill directories or installed skill names. With no "
"target, lints the current directory if it contains a SKILL.md. "
"Exit codes: 0 clean, 1 findings at/above --fail-on, 2 usage error."
),
)
skills_lint.add_argument(
"targets",
nargs="*",
help="Skill directories or installed skill names (default: ./SKILL.md)",
)
skills_lint.add_argument(
"--all", action="store_true", dest="all_skills",
help="Lint every installed skill",
)
skills_lint.add_argument(
"--json", action="store_true", help="Output results as JSON"
)
skills_lint.add_argument(
"--fail-on", default="error", choices=["error", "warning"],
help="Severity threshold for exit code 1 (default: error)",
)

skills_uninstall = skills_subparsers.add_parser(
"uninstall", help="Remove a hub-installed skill"
)
Expand Down
159 changes: 159 additions & 0 deletions tests/hermes_cli/test_skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,3 +781,162 @@ def test_do_search_json_flag_emits_full_identifiers(capsys):
# Table render must be suppressed — sink should be empty (no "Searching for:" header).
assert "Searching for:" not in sink.getvalue()



# ---------------------------------------------------------------------------
# do_lint
# ---------------------------------------------------------------------------

_CLEAN_SKILL_MD = """---
name: {name}
description: A perfectly fine skill for testing.
version: 1.0.0
---
# Usage
"""

_NO_DESCRIPTION_SKILL_MD = """---
name: {name}
---
# Usage
"""

_LEGACY_PREREQ_SKILL_MD = """---
name: {name}
description: fine
prerequisites:
env_vars: [MY_TOKEN]
---
# Usage
"""


def _write_skill(tmp_path, dirname, template=_CLEAN_SKILL_MD):
skill_dir = tmp_path / dirname
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
template.format(name=dirname), encoding="utf-8"
)
return skill_dir


@pytest.fixture()
def lint_env(monkeypatch):
"""Isolate do_lint from the real installed-skills enumeration."""
import tools.skills_lint as skills_lint

monkeypatch.setattr(
skills_lint, "collect_installed_skill_names", lambda: frozenset()
)
installed = {}
monkeypatch.setattr(
skills_lint, "find_installed_skill_paths", lambda: dict(installed)
)
return installed


def _run_lint(**kwargs):
from hermes_cli.skills_hub import do_lint

sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None, width=200)
code = do_lint(console=console, **kwargs)
return sink.getvalue(), code


def test_do_lint_clean_path_target_exits_zero(tmp_path, lint_env):
skill = _write_skill(tmp_path, "clean-skill")
out, code = _run_lint(targets=[str(skill)])
assert code == 0
assert "OK" in out
assert "0 error(s), 0 warning(s)" in out


def test_do_lint_error_exits_one(tmp_path, lint_env):
skill = _write_skill(tmp_path, "broken-skill", _NO_DESCRIPTION_SKILL_MD)
out, code = _run_lint(targets=[str(skill)])
assert code == 1
assert "HSL015" in out


def test_do_lint_fail_on_warning_flips_exit_code(tmp_path, lint_env):
skill = _write_skill(tmp_path, "legacy-skill", _LEGACY_PREREQ_SKILL_MD)
out, code = _run_lint(targets=[str(skill)])
assert code == 0
assert "HSL032" in out
_, code = _run_lint(targets=[str(skill)], fail_on="warning")
assert code == 1


def test_do_lint_all_with_targets_is_usage_error(tmp_path, lint_env):
out, code = _run_lint(targets=["x"], all_skills=True)
assert code == 2
assert "--all" in out


def test_do_lint_unknown_name_is_usage_error(lint_env):
out, code = _run_lint(targets=["no-such-skill"])
assert code == 2
assert "no-such-skill" in out


def test_do_lint_resolves_installed_name(tmp_path, lint_env):
skill = _write_skill(tmp_path, "my-skill")
lint_env["my-skill"] = skill
out, code = _run_lint(targets=["my-skill"])
assert code == 0
assert "my-skill" in out


def test_do_lint_all_lints_every_installed_skill(tmp_path, lint_env):
lint_env["a-skill"] = _write_skill(tmp_path, "a-skill")
lint_env["b-skill"] = _write_skill(tmp_path, "b-skill", _NO_DESCRIPTION_SKILL_MD)
out, code = _run_lint(all_skills=True)
assert code == 1
assert "2 skill(s) linted" in out
assert "a-skill" in out and "b-skill" in out


def test_do_lint_no_target_no_skill_md_is_usage_error(tmp_path, monkeypatch, lint_env):
monkeypatch.chdir(tmp_path)
out, code = _run_lint()
assert code == 2
assert "Usage" in out


def test_do_lint_no_target_lints_cwd(tmp_path, monkeypatch, lint_env):
skill = _write_skill(tmp_path, "cwd-skill")
monkeypatch.chdir(skill)
out, code = _run_lint()
assert code == 0
assert "cwd-skill" in out


def test_do_lint_json_output(tmp_path, lint_env, capsys):
skill = _write_skill(tmp_path, "legacy-skill", _LEGACY_PREREQ_SKILL_MD)
sink_out, code = _run_lint(targets=[str(skill)], as_json=True)
import json as _json
payload = _json.loads(capsys.readouterr().out)
assert code == 0
assert isinstance(payload, list) and len(payload) == 1
assert payload[0]["skill_name"] == "legacy-skill"
assert payload[0]["findings"][0]["rule_id"] == "HSL032"
# Human report must be suppressed in JSON mode.
assert "HSL032" not in sink_out


def test_do_publish_blocks_on_lint_error(tmp_path, lint_env):
"""Publish must abort on lint errors before the security scan runs."""
from hermes_cli.skills_hub import do_publish

skill = _write_skill(tmp_path, "broken-skill", _NO_DESCRIPTION_SKILL_MD)
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None, width=200)

with patch("tools.skills_guard.scan_skill",
side_effect=AssertionError("scan_skill must not run")):
do_publish(str(skill), target="github", repo="o/r", console=console)

out = sink.getvalue()
assert "HSL015" in out
assert "Cannot publish" in out
Loading