From 15c48e9166ed9af6ab1306409db21c5670295de4 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Fri, 15 May 2026 19:21:08 +0100 Subject: [PATCH 1/3] feat: add plugin marketplace and skills install * Add .claude-plugin/marketplace.json and plugin.json so the skill is installable via `/plugin marketplace add petems/unsloppify` and `/plugin install unsloppify@unsloppify` * Surface `npx skills add petems/unsloppify` as a second install path (works with Claude Code, Codex, Cursor) via the existing skills/unsloppify/ layout * Add tests/test_skill_metadata.py validating SKILL.md frontmatter (allowed-keys set, required name + description), marketplace + plugin manifest schema, name cross-check, and plugin.json/pyproject.toml version sync * Add .github/workflows/skill-validate.yml with three jobs: pytest metadata tests, npx skills add codex-target smoke install, and the canonical `claude plugin validate` (continue-on-error) * Update README install section to surface both install paths * Include .claude-plugin/ in the sdist build Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/marketplace.json | 20 ++++ .claude-plugin/plugin.json | 14 +++ .github/workflows/skill-validate.yml | 77 ++++++++++++++ README.md | 17 ++- pyproject.toml | 1 + tests/test_skill_metadata.py | 154 +++++++++++++++++++++++++++ 6 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .github/workflows/skill-validate.yml create mode 100644 tests/test_skill_metadata.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..1540567 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "unsloppify", + "description": "Strip AI writing patterns from prose. CLI + Claude Code skill.", + "owner": { + "name": "Peter Souter", + "email": "peter.souter@datadoghq.com" + }, + "plugins": [ + { + "name": "unsloppify", + "source": "./", + "description": "Anti-slop prose linter — punctuation, banned phrases, structural cliches.", + "category": "productivity", + "keywords": ["writing", "prose", "linter", "ai-slop", "markdown"], + "homepage": "https://github.com/petems/unsloppify", + "license": "MIT" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..7012b77 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "unsloppify", + "description": "Anti-slop prose linter — Claude skill + Python CLI for stripping AI writing patterns from markdown.", + "version": "0.1.0", + "author": { + "name": "Peter Souter", + "email": "peter.souter@datadoghq.com" + }, + "homepage": "https://github.com/petems/unsloppify", + "repository": "https://github.com/petems/unsloppify", + "license": "MIT", + "keywords": ["writing", "prose", "linter", "ai-slop", "markdown"] +} diff --git a/.github/workflows/skill-validate.yml b/.github/workflows/skill-validate.yml new file mode 100644 index 0000000..0b2addd --- /dev/null +++ b/.github/workflows/skill-validate.yml @@ -0,0 +1,77 @@ +name: Skill validate + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + metadata-tests: + name: Static metadata tests (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - name: Set up Python + run: uv python install 3.13 + - run: uv sync --extra dev + - run: uv run pytest tests/test_skill_metadata.py -v + + skill-install-smoke: + name: npx skills add (codex agent) smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v5 + with: + node-version: "22" + - name: Install skill into isolated $HOME + env: + FAKE_HOME: ${{ runner.temp }}/fakehome + run: | + set -euxo pipefail + mkdir -p "$FAKE_HOME" + # Use codex as the target agent: Claude Code users will install via + # /plugin marketplace add (validated separately), so npx skills add + # is exercised against a non-Claude agent to broaden coverage. + HOME="$FAKE_HOME" npx --yes skills add ./ --all --agent codex --yes + - name: Verify installed skill files + env: + FAKE_HOME: ${{ runner.temp }}/fakehome + run: | + set -euxo pipefail + # The skills CLI documents per-agent destinations; expected codex path + # is ~/.codex/skills//SKILL.md. If the path differs in practice, + # this step will fail loud and we adjust. + find "$FAKE_HOME" -name SKILL.md -print + test -s "$FAKE_HOME/.codex/skills/unsloppify/SKILL.md" + - name: Verify skills list shows unsloppify + env: + FAKE_HOME: ${{ runner.temp }}/fakehome + run: | + set -euxo pipefail + HOME="$FAKE_HOME" npx --yes skills list | tee /tmp/skills-list.txt + grep -q "unsloppify" /tmp/skills-list.txt + + claude-plugin-validate: + name: claude plugin validate (canonical validator) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v5 + with: + node-version: "22" + - name: Run the Claude Code plugin validator + run: | + set -euxo pipefail + # Validates marketplace.json, plugin.json, and skill frontmatter using + # the canonical CLI. Job is marked continue-on-error in case the npm + # package name shifts; the metadata-tests job is the load-bearing + # check. + npx --yes @anthropic-ai/claude-code plugin validate . diff --git a/README.md b/README.md index c1e984e..5be4b9f 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,22 @@ repos: ### Claude Code skill -Drop `skills/unsloppify/` into `~/.claude/skills/`, or install via the plugin marketplace once published. Then ask Claude to "unsloppify this draft" and it will invoke the CLI first for the deterministic catches, then do a judgment pass on structures. +Two ways to install: + +**Plugin marketplace** (recommended for Claude Code): + +```text +/plugin marketplace add petems/unsloppify +/plugin install unsloppify@unsloppify +``` + +**Skills CLI** (works with Claude Code, Codex, Cursor, and other agents): + +```bash +npx skills add petems/unsloppify +``` + +Then ask the agent to "unsloppify this draft" and it will invoke the CLI first for the deterministic catches, then do a judgment pass on structures. ### Claude Code output style diff --git a/pyproject.toml b/pyproject.toml index 30999c6..fd16a1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ include = [ "tests/", "skills/", "output-styles/", + ".claude-plugin/", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", diff --git a/tests/test_skill_metadata.py b/tests/test_skill_metadata.py new file mode 100644 index 0000000..76aa417 --- /dev/null +++ b/tests/test_skill_metadata.py @@ -0,0 +1,154 @@ +"""Validate skill + plugin metadata files. + +These tests guard the install-experience contracts: +- SKILL.md frontmatter must parse and stay within the Anthropic-allowed key set + (otherwise the Claude app refuses to load the skill). +- .claude-plugin/marketplace.json + plugin.json must satisfy the Claude Code + plugin marketplace schema enough to load via `/plugin marketplace add` and + `/plugin install`. +- plugin.json version must stay in sync with pyproject.toml. +""" + +from __future__ import annotations + +import json +import re +import sys +import tomllib +from pathlib import Path +from typing import Any, cast + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILL_MD = REPO_ROOT / "skills" / "unsloppify" / "SKILL.md" +MARKETPLACE_JSON = REPO_ROOT / ".claude-plugin" / "marketplace.json" +PLUGIN_JSON = REPO_ROOT / ".claude-plugin" / "plugin.json" +PYPROJECT_TOML = REPO_ROOT / "pyproject.toml" + +ALLOWED_SKILL_FRONTMATTER_KEYS = { + "name", + "description", + "license", + "allowed-tools", + "metadata", +} + +KEBAB_CASE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +SEMVER_LIKE = re.compile(r"^\d+\.\d+\.\d+(?:[-+].+)?$") + + +def _parse_frontmatter(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + if not text.startswith("---"): + raise AssertionError(f"{path} does not start with YAML frontmatter") + parts = text.split("---", 2) + if len(parts) < 3: + raise AssertionError(f"{path} frontmatter is not closed by a second '---'") + parsed = yaml.safe_load(parts[1]) + assert isinstance(parsed, dict), f"{path} frontmatter is not a mapping" + return cast(dict[str, Any], parsed) + + +@pytest.fixture(scope="module") +def skill_frontmatter() -> dict[str, Any]: + return _parse_frontmatter(SKILL_MD) + + +@pytest.fixture(scope="module") +def marketplace() -> dict[str, Any]: + return cast(dict[str, Any], json.loads(MARKETPLACE_JSON.read_text(encoding="utf-8"))) + + +@pytest.fixture(scope="module") +def plugin() -> dict[str, Any]: + return cast(dict[str, Any], json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))) + + +def test_skill_md_required_fields(skill_frontmatter: dict[str, Any]) -> None: + assert "name" in skill_frontmatter, "SKILL.md must declare a name" + assert "description" in skill_frontmatter, "SKILL.md must declare a description" + name = skill_frontmatter["name"] + assert isinstance(name, str) and KEBAB_CASE.match(name), ( + f"name must be kebab-case, got {name!r}" + ) + description = skill_frontmatter["description"] + assert isinstance(description, str) and description.strip(), ( + "description must be a non-empty string" + ) + + +def test_skill_md_only_allowed_keys(skill_frontmatter: dict[str, Any]) -> None: + extra = set(skill_frontmatter.keys()) - ALLOWED_SKILL_FRONTMATTER_KEYS + assert not extra, ( + f"SKILL.md has frontmatter keys outside the Anthropic-allowed set " + f"{sorted(ALLOWED_SKILL_FRONTMATTER_KEYS)}: {sorted(extra)}. " + "The Claude app rejects skills with unexpected top-level keys " + "(use metadata: for free-form extras)." + ) + + +def test_marketplace_json_required_fields(marketplace: dict[str, Any]) -> None: + assert marketplace.get("name"), "marketplace.json must declare a name" + owner = marketplace.get("owner") + assert isinstance(owner, dict) and owner.get("name"), ( + "marketplace.json must declare owner.name" + ) + plugins = marketplace.get("plugins") + assert isinstance(plugins, list) and plugins, ( + "marketplace.json must list at least one plugin" + ) + for entry in plugins: + assert isinstance(entry, dict) + assert entry.get("name"), "every plugin entry needs a name" + assert "source" in entry, "every plugin entry needs a source" + if isinstance(entry["source"], str): + assert entry["source"].startswith("./"), ( + f"local plugin source must start with './' (got {entry['source']!r})" + ) + + +def test_plugin_json_required_fields(plugin: dict[str, Any]) -> None: + assert plugin.get("name"), "plugin.json must declare a name" + assert plugin.get("description"), "plugin.json must declare a description" + version = plugin.get("version") + assert isinstance(version, str) and SEMVER_LIKE.match(version), ( + f"plugin.json version must be semver-like, got {version!r}" + ) + + +def test_marketplace_plugin_matches_plugin_manifest( + marketplace: dict[str, Any], + plugin: dict[str, Any], +) -> None: + names = [p.get("name") for p in marketplace["plugins"]] + assert plugin["name"] in names, ( + f"plugin.json name {plugin['name']!r} not listed in marketplace.json plugins" + ) + + +def test_plugin_version_matches_pyproject(plugin: dict[str, Any]) -> None: + pyproject = tomllib.loads(PYPROJECT_TOML.read_text(encoding="utf-8")) + py_version = pyproject["project"]["version"] + assert plugin["version"] == py_version, ( + f"plugin.json version ({plugin['version']!r}) must match " + f"pyproject.toml version ({py_version!r}). Bump both together." + ) + + +def test_skill_directory_matches_plugin_skill_name( + skill_frontmatter: dict[str, Any], +) -> None: + # The skill directory name should match the SKILL.md `name` so Claude Code's + # auto-discovery (which looks at /SKILL.md) doesn't surface + # mismatched identifiers. + assert SKILL_MD.parent.name == skill_frontmatter["name"], ( + f"skill directory {SKILL_MD.parent.name!r} must match SKILL.md name " + f"{skill_frontmatter['name']!r}" + ) + + +def test_python_version_is_modern_enough() -> None: + # tomllib requires 3.11+; pyproject pins >=3.11 already. Belt + braces. + assert sys.version_info >= (3, 11), "tests require Python 3.11+ for tomllib" From 1e4cb2abc3bce24a7e21d41ff704b68f856218cb Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Fri, 15 May 2026 19:34:17 +0100 Subject: [PATCH 2/3] fix(ci): correct skills add smoke-test flags and verify path * Replace --all with --global --skill '*' so --agent codex is honored * Verify ~/.agents/skills/unsloppify/SKILL.md (codex is a universal agent) * Use skills list --global to surface globally-installed skill Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/skill-validate.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/skill-validate.yml b/.github/workflows/skill-validate.yml index 0b2addd..06e2e09 100644 --- a/.github/workflows/skill-validate.yml +++ b/.github/workflows/skill-validate.yml @@ -39,23 +39,24 @@ jobs: # Use codex as the target agent: Claude Code users will install via # /plugin marketplace add (validated separately), so npx skills add # is exercised against a non-Claude agent to broaden coverage. - HOME="$FAKE_HOME" npx --yes skills add ./ --all --agent codex --yes + # --global writes into $HOME (so source tree isn't mutated); avoid + # --all because it expands to --agent '*' and overrides --agent codex. + HOME="$FAKE_HOME" npx --yes skills add ./ --global --agent codex --skill '*' --yes - name: Verify installed skill files env: FAKE_HOME: ${{ runner.temp }}/fakehome run: | set -euxo pipefail - # The skills CLI documents per-agent destinations; expected codex path - # is ~/.codex/skills//SKILL.md. If the path differs in practice, - # this step will fail loud and we adjust. + # Codex is a "universal" agent in the skills CLI — global installs + # land in ~/.agents/skills//SKILL.md, not ~/.codex/skills/. find "$FAKE_HOME" -name SKILL.md -print - test -s "$FAKE_HOME/.codex/skills/unsloppify/SKILL.md" + test -s "$FAKE_HOME/.agents/skills/unsloppify/SKILL.md" - name: Verify skills list shows unsloppify env: FAKE_HOME: ${{ runner.temp }}/fakehome run: | set -euxo pipefail - HOME="$FAKE_HOME" npx --yes skills list | tee /tmp/skills-list.txt + HOME="$FAKE_HOME" npx --yes skills list --global | tee /tmp/skills-list.txt grep -q "unsloppify" /tmp/skills-list.txt claude-plugin-validate: From 12078e7c24417bc605d77660fe4743fd0e280b37 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Fri, 15 May 2026 19:38:57 +0100 Subject: [PATCH 3/3] fix: address PR review findings * skill-validate.yml: drop continue-on-error on canonical validator so manifest regressions fail the job instead of silently passing * test_skill_metadata.py: require plugins[].source to be a non-empty string before checking the ./ prefix Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/skill-validate.yml | 6 ++---- tests/test_skill_metadata.py | 12 +++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/skill-validate.yml b/.github/workflows/skill-validate.yml index 06e2e09..d9cd76f 100644 --- a/.github/workflows/skill-validate.yml +++ b/.github/workflows/skill-validate.yml @@ -62,7 +62,6 @@ jobs: claude-plugin-validate: name: claude plugin validate (canonical validator) runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v5 @@ -72,7 +71,6 @@ jobs: run: | set -euxo pipefail # Validates marketplace.json, plugin.json, and skill frontmatter using - # the canonical CLI. Job is marked continue-on-error in case the npm - # package name shifts; the metadata-tests job is the load-bearing - # check. + # the canonical CLI. metadata-tests is the redundant guardrail; this + # job blocks merges so manifest regressions can't slip through. npx --yes @anthropic-ai/claude-code plugin validate . diff --git a/tests/test_skill_metadata.py b/tests/test_skill_metadata.py index 76aa417..cb4606b 100644 --- a/tests/test_skill_metadata.py +++ b/tests/test_skill_metadata.py @@ -102,11 +102,13 @@ def test_marketplace_json_required_fields(marketplace: dict[str, Any]) -> None: for entry in plugins: assert isinstance(entry, dict) assert entry.get("name"), "every plugin entry needs a name" - assert "source" in entry, "every plugin entry needs a source" - if isinstance(entry["source"], str): - assert entry["source"].startswith("./"), ( - f"local plugin source must start with './' (got {entry['source']!r})" - ) + source = entry.get("source") + assert isinstance(source, str) and source, ( + "every plugin entry needs a non-empty string source" + ) + assert source.startswith("./"), ( + f"local plugin source must start with './' (got {source!r})" + ) def test_plugin_json_required_fields(plugin: dict[str, Any]) -> None: