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
20 changes: 20 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
14 changes: 14 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"]
}
76 changes: 76 additions & 0 deletions .github/workflows/skill-validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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.
# --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
# Codex is a "universal" agent in the skills CLI — global installs
# land in ~/.agents/skills/<name>/SKILL.md, not ~/.codex/skills/.
find "$FAKE_HOME" -name SKILL.md -print
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 --global | 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
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. 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 .
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
| Tier | Examples | Handled by |
|------|----------|------------|
| 1. Punctuation | em dashes, smart quotes, ellipsis chars | CLI, auto-fixed |
| 2. Banned phrases | `delve into`, `tapestry`, `In today's fast-paced world` | CLI, flagged or auto-fixed |

Check notice on line 20 in README.md

View workflow job for this annotation

GitHub Actions / test (3.12)

unsloppify structures.three-item-list

Three-item list (AI default). Try two items, or four. See if three is genuine.

Check notice on line 20 in README.md

View workflow job for this annotation

GitHub Actions / test (3.13)

unsloppify structures.three-item-list

Three-item list (AI default). Try two items, or four. See if three is genuine.

Check notice on line 20 in README.md

View workflow job for this annotation

GitHub Actions / test (3.11)

unsloppify structures.three-item-list

Three-item list (AI default). Try two items, or four. See if three is genuine.
| 3. Structures | binary contrasts, false agency, parataxis | Skill (LLM judgment) |

Run the CLI first to nuke the obvious stuff. Let the skill handle the rest.
Expand Down Expand Up @@ -63,7 +63,22 @@

### 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):

Check notice on line 75 in README.md

View workflow job for this annotation

GitHub Actions / test (3.12)

unsloppify structures.three-item-list

Three-item list (AI default). Try two items, or four. See if three is genuine.

Check notice on line 75 in README.md

View workflow job for this annotation

GitHub Actions / test (3.13)

unsloppify structures.three-item-list

Three-item list (AI default). Try two items, or four. See if three is genuine.

Check notice on line 75 in README.md

View workflow job for this annotation

GitHub Actions / test (3.11)

unsloppify structures.three-item-list

Three-item list (AI default). Try two items, or four. See if three is genuine.

```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

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ include = [
"tests/",
"skills/",
"output-styles/",
".claude-plugin/",
"README.md",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
Expand Down
156 changes: 156 additions & 0 deletions tests/test_skill_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""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"
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:
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-dir>/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"
Loading