ux
Blog · CLI · 2026-05-28

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:

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 repo342100412 ms
Warm, full repo34210096 ms
Warm, staged diff (8 files)810018 ms
Warm, single file11007 ms
GitHub Actions, cold runner342100~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
A11y28Alt text, focus styles, click target size, color-only state, ARIA on interactive elements, viewport that blocks pinch-zoom.
Content16Generic placeholder names, sample copy leaks, marketing verbs in headlines, fake testimonial stars, placeholder pricing.
Typography14Body face at display sizes, hairline body weights, rainbow gradient on text, all-caps body, missing font-feature-settings.
Color12Default AI gradient over white, chrome-y multi-stop gradients, neon on light surfaces, low-contrast text on cards.
Quality10Inline style attribute on shipping components, console.log leaks, dead imports, hardcoded pixels where tokens exist.
Layout8Three equal cards in a row, full-bleed gradient hero, centered-everything axis, 100vh without 100dvh fallback.
Motion6Fade-in-up on every direct child, no reduced-motion branch, animating layout properties instead of transform.
Visual4Drop shadows on flat designs, blur backdrops without fallback, gratuitous neumorphism, glassmorphism on white.
Performance2Animating layout properties, animating filters in scroll handlers.
Total100From 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.

Honest scope

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:

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

Install the gate

One CLI. Three install paths. No LLM.

120 deterministic regex rules, a 1,182-entry catalog, 22 commands across 17 IDEs. Plugs into pre-commit, Husky, Lefthook, GitHub Actions, GitLab CI. MIT-licensed. No telemetry. No account.

$ /plugin marketplace add Laith0003/ux-skill
$ /plugin install ux@ux-skill
— or —
$ pip install uxskill
— or —
$ npx uxskill@alpha init