The anti-slop CLI for vibe coders — 120 rules, no LLM, runs in CI.
The vibe coder's tool belt is a hand-rolled set of CLI primitives. Cursor, Claude Code, Cline, Aider, Codex — the model writes; the developer reviews. The missing piece is a deterministic gate that catches the AI design fingerprint before it merges. Not an LLM judge. Not a SaaS dashboard. A command-line linter that runs in milliseconds, plugs into pre-commit, and exits non-zero on a finding. Here is the engineering write-up.
Why a CLI is the right shape for vibe coding
Vibe coding sits inside an existing developer toolchain: git, a package manager, an IDE that shells out, a hook runner. The toolchain is text in and text out. A SaaS dashboard or a Figma plugin is the wrong shape — it sits next to the toolchain instead of inside it. Anything that interrupts the keyboard-only loop costs more than the time the loop saved.
The CLI shape gets four things for free: composability with shell pipelines, native pre-commit integration, native CI integration, and no auth handshake. A command that reads files and exits non-zero is the most boring API surface on a developer laptop, which is exactly why it is the right one.
ux-skill's ux lint command is built on those constraints. One binary entry point. JSON or human-readable output. Configurable threshold. No network call. No background daemon.
The architecture in one paragraph
The package is a Python wheel published to PyPI under the name uxskill, with an npm wrapper that vendors a self-contained Python runtime so JavaScript teams without Python tooling still get the same CLI. Inside, the engine loads data/anti-patterns.json — 100 entries today, each a regex with a severity, category, scope, and fix string. The scanner walks the target directory, filters by file extension against each rule's scope, runs each regex against the matching files, and emits a structured findings array. The CLI prints the array as a JSON document or a human-readable list. Findings at or above the threshold cause a non-zero exit.
Nothing about the architecture is novel. The engineering work is in the catalog, the test suite, and the cross-IDE distribution — not in the runner. 75 unit tests cover the rule loader, the scope filter, the regex compiler, the threshold gate, and the JSON output format. Every release ships a green test matrix on Python 3.10, 3.11, 3.12, and 3.13.
The command surface
The lint subcommand is the most-used. Three modes cover every real use case:
# Mode 1 — default: scan ./, threshold high, exit non-zero on findings $ uxskill lint [OK] Scanned 142 files in 412ms · 0 findings at threshold high # Mode 2 — scan a subtree, lower the threshold $ uxskill lint apps/web/src --threshold medium # Mode 3 — JSON output, scan only staged files (used by pre-commit) $ uxskill lint --json --staged | jq '.findings | length'
Flags that matter day to day:
--threshold <low|medium|high|critical>— only count findings at or above this severity. Default ishigh.--json— emit a JSON document instead of human-readable output. Used by CI and editor plugins.--staged— scan only files staged in git. The right mode for pre-commit.--rules <id,id,id>— restrict the scan to a comma-separated list of rule ids. Useful for targeted CI jobs.--ignore <id,id>— suppress specific rule ids. Each ignore should land with a comment in the repo's.uxskillignorefile explaining why.--report <path>— write the JSON report to a file. Used by CI artifact upload.
The JSON shape
The output schema is stable. Editor plugins, CI dashboards, and IDE integrations rely on it.
{
"files_scanned": 142,
"rules_loaded": 100,
"exit_code": 1,
"findings": [
{
"rule_id": "font-stretched-display",
"rule_name": "Body face used at display size",
"severity": "high",
"category": "Typography",
"file": "src/components/Hero.tsx",
"line": 18,
"column": 12,
"excerpt": "font-['Inter'] text-7xl leading-none",
"fix": "Pair the body face with a distinct display face"
}
]
}
Every field is documented in docs/schema/findings.md. The schema is versioned via the schema_version field in data/anti-patterns.json — consumers can pin to a major version and trust that minor versions are additive.
Pre-commit, GitHub Actions, GitLab CI, Husky, Lefthook
Five integration recipes, all built on the same exit-code contract. Pick the one that matches your hook runner; the rest is identical.
pre-commit (Python ecosystem)
# .pre-commit-config.yaml repos: - repo: local hooks: - id: uxskill-lint name: ux-skill anti-pattern lint entry: uxskill lint --threshold high --staged language: python additional_dependencies: [uxskill] files: \.(tsx|jsx|vue|css|scss|html|astro|blade)$ pass_filenames: true
GitHub Actions
# .github/workflows/ux-lint.yml name: ux-lint on: {pull_request: {}, push: {branches: [main]}} jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: {python-version: '3.11'} - run: pip install uxskill - run: uxskill lint --threshold high --report lint.json - if: failure() uses: actions/upload-artifact@v4 with: {name: ux-lint-report, path: lint.json}
GitLab CI
# .gitlab-ci.yml ux-lint: image: python:3.11-slim stage: test script: - pip install uxskill - uxskill lint --threshold high --report lint.json artifacts: when: on_failure paths: [lint.json]
Husky (Node ecosystem)
# .husky/pre-commit #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx uxskill@latest lint --threshold high --staged || exit 1
Lefthook (multi-language)
# lefthook.yml pre-commit: commands: ux-lint: glob: '*.{tsx,jsx,vue,css,scss,html,astro,blade}' run: uxskill lint --threshold high --staged {staged_files}
Every recipe uses the same flags. The contract is the exit code; everything else is decoration.
Performance, measured honestly
Numbers from a 2023 MacBook Air, M2, scanning a Next.js repo with 200 components, 84 stylesheets, and 12 megabytes of source under apps/web/src:
| Scenario | Files scanned | Rules loaded | Wall time |
|---|---|---|---|
| Cold start, full repo | 342 | 100 | 412 ms |
| Warm, full repo | 342 | 100 | 96 ms |
| Warm, staged diff (8 files) | 8 | 100 | 18 ms |
| Warm, single file | 1 | 100 | 7 ms |
| GitHub Actions, cold runner | 342 | 100 | ~7.2 s (incl. pip install) |
The pre-commit path is the one developers feel. Eighteen milliseconds for the staged-diff case is comfortably under the perceptual threshold — the hook is invisible.
Make the slow path good and the fast path invisible. The CLI is the fast path.
What the 120 rules cover
The catalog is grouped by category. The counts are current as of v2.1:
| Category | Count | What it catches |
|---|---|---|
| A11y | 28 | Alt text, focus styles, click target size, color-only state, ARIA on interactive elements, viewport that blocks pinch-zoom. |
| Content | 16 | Generic placeholder names, sample copy leaks, marketing verbs in headlines, fake testimonial stars, placeholder pricing. |
| Typography | 14 | Body face at display sizes, hairline body weights, rainbow gradient on text, all-caps body, missing font-feature-settings. |
| Color | 12 | Default AI gradient over white, chrome-y multi-stop gradients, neon on light surfaces, low-contrast text on cards. |
| Quality | 10 | Inline style attribute on shipping components, console.log leaks, dead imports, hardcoded pixels where tokens exist. |
| Layout | 8 | Three equal cards in a row, full-bleed gradient hero, centered-everything axis, 100vh without 100dvh fallback. |
| Motion | 6 | Fade-in-up on every direct child, no reduced-motion branch, animating layout properties instead of transform. |
| Visual | 4 | Drop shadows on flat designs, blur backdrops without fallback, gratuitous neumorphism, glassmorphism on white. |
| Performance | 2 | Animating layout properties, animating filters in scroll handlers. |
| Total | 100 | From 35 at v2.0.0 launch to 100 in the v2.1 cycle. |
The recommender is the upstream half
The linter is the downstream half of the loop. The upstream half is ux recommend — five parallel lookups over a 1,182-entry catalog merged into a single structured design system. The recommender narrows the model's choices before the prompt; the linter punishes the model when the choices slip anyway.
Both halves share one source-of-truth document at .ux/design-system/MASTER.md. Every IDE's rules file (.cursorrules, CLAUDE.md, .windsurfrules) is generated from that document. There is one place to look when something is wrong, one place to edit when something needs to change.
Read more in the recommender write-up and the regex-linter engineering post.
The CLI is the floor, not the ceiling.
The linter catches structural fingerprints — literal tokens, class names, hex values, DOM shapes. It does not catch "the section rhythm is wrong" or "the copy reads cold." Those need a review pass, by a human or a stronger model. The CLI is the deterministic floor; /ux-critique ships in the same package for the taste pass.
The reason the CLI never calls an LLM is the same reason a unit test never calls an LLM. Reproducibility is the entire value proposition. If the lint result depends on a model's mood, the lint is no longer a lint — it is a recommendation, and recommendations belong in a different layer.
What lives in .uxskillignore
Every codebase has legitimate edge cases. A design tool that needs to render an emoji literal. A docs page that quotes a regex pattern. A demo file that shows the wrong-way example. The .uxskillignore file is a per-repo list of rule-and-path pairs, each with a required reason:
# .uxskillignore # Demo page documents what NOT to do; the lint trips on its own examples. docs/anti-patterns.html font-stretched-display, default-ai-gradient # Inline SVG icon stamps are legitimate in this design system. src/components/Stamp.tsx icon-emoji-stamp # Story file for Chromatic visual diffs. src/**/*.stories.tsx fade-in-up-everywhere
The format is two columns: a glob and a comma-separated rule id list. Anything to the right of a # is treated as a comment. The required-reason convention is enforced by code review, not by the parser.
Cross-IDE distribution
The CLI is one package; every IDE that shells out reaches the same engine. Specifically:
- Claude Code —
/plugin install ux@ux-skill; slash commands map to the CLI subcommands. - Cursor —
npx uxskill@alpha init; emits.cursorrulesand registers a pre-commit hook. - Windsurf — same npx initializer; emits
.windsurfrules. - Cline · Continue · Aider · Codex · Roo Code — read whatever rules file is at the repo root. The CLI emits all three formats from the same recommendation.
- JetBrains · Zed · GitHub Copilot · Gemini Code Assist — same story. The CLI does not care which editor is on the other side of the file.
Walkthroughs for each: Cursor, Windsurf, Zed, JetBrains, GitHub Copilot.
The vibe coder's tool belt, one slot at a time
Every serious vibe coder ends up with the same five things in their belt by month two: a strong IDE (Cursor or Claude Code), a hook runner (pre-commit, Husky, or Lefthook), an LLM router (Aider, Continue, or Codex), an MCP server or two (sequential thinking, GitHub, filesystem), and a deterministic gate for the output. ux-skill is built for the last slot — the gate.
14 stars on GitHub at time of writing, against 84k for the largest competitor in the marketplace. The fight is not for the headline number, it is for the slot. Once the gate is in the belt, the rest of the loop compounds.
Related reading
- Regex linter for AI coding — the engineering write-up on the rule format itself.
- Vibe coding is real — but your AI still ships the same defaults — the workflow framing.
- Python design system generator — the upstream half of the loop.
- Anti-AI-slop tools for Claude Code — where ux-skill sits in the toolset.