An open API service indexing awesome lists of open source software.

https://github.com/retif/claudecode-linter

Standalone linter and formatter for Claude Code plugins and configuration files — 90 rules across 8 artifact types with auto-fix
https://github.com/retif/claudecode-linter

anthropic claude claude-code cli linter plugin typescript

Last synced: 11 days ago
JSON representation

Standalone linter and formatter for Claude Code plugins and configuration files — 90 rules across 8 artifact types with auto-fix

Awesome Lists containing this project

README

          

# claudecode-linter

[![CI](https://github.com/retif/claudecode-linter/actions/workflows/ci.yml/badge.svg)](https://github.com/retif/claudecode-linter/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/claudecode-linter)](https://www.npmjs.com/package/claudecode-linter)
[![license](https://img.shields.io/npm/l/claudecode-linter)](https://github.com/retif/claudecode-linter/blob/main/LICENSE)
[![Socket Badge](https://socket.dev/api/badge/npm/package/claudecode-linter)](https://socket.dev/npm/package/claudecode-linter)

Standalone linter for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugins and configuration files.

Validates `plugin.json`, `SKILL.md`, agent/command markdown, `hooks.json`, `mcp.json`, `settings.json`, `CLAUDE.md`, `.lsp.json`, and `monitors/monitors.json` files. plugin.json, settings.json, the agent/skill/command frontmatter, .lsp.json and monitors.json are checked against JSON Schemas auto-extracted from Claude Code's runtime Zod validators — failures the linter reports are the same failures Claude Code would raise at session start.

![demo](assets/demo.gif)

## Install

```bash
npm install -g claudecode-linter
```

Or run directly:

```bash
npx claudecode-linter ~/projects/my-plugin/
```

Or build it from a clone of this repository — there is no `dist/` until you build:

```bash
git clone https://github.com/retif/claudecode-linter
cd claudecode-linter
npm ci && npm run build # install dependencies, compile to dist/
node dist/index.js path/to/plugin/
```

Commands elsewhere in this README are written as `claudecode-linter …` (the global / `npx` install); from a clone, that is `node dist/index.js …`.

## Usage

### Lint

Check plugin artifacts for errors without modifying files. This is the default mode — `--lint` is optional:

```bash
# Lint a plugin directory
claudecode-linter --lint path/to/plugin/
claudecode-linter path/to/plugin/ # same thing

# Lint multiple paths
claudecode-linter plugin-a/ plugin-b/

# JSON output
claudecode-linter --output json path/to/plugin/

# Errors only
claudecode-linter --quiet path/to/plugin/

# Filter by rule
claudecode-linter --rule plugin-json/name-kebab-case path/to/plugin/

# Enable/disable specific rules
claudecode-linter --enable skill-md/word-count --disable claude-md/no-todos path/to/plugin/

# List all available rules
claudecode-linter --list-rules
```

### Format

Reformat all artifacts for consistent style (sorted keys, normalized indentation, trailing whitespace, kebab-case names, quoted YAML values). No lint output — just formats and reports what changed:

```bash
# Format all artifacts in place
claudecode-linter --format path/to/plugin/
```

### Fix

Fix lint violations in place, then lint the result — output shows only issues that remain after fixing:

```bash
# Fix issues in place
claudecode-linter --fix path/to/plugin/

# Preview fixes without writing (shows diff)
claudecode-linter --fix-dry-run path/to/plugin/
```

### Detect

Print which Claude Code artifact types a path contains — one machine-readable
type per line — and set the exit code (`0` if any found, `1` if none). Intended
for a generic git hook that gates the linter on "is this repo a Claude Code
plugin?":

```bash
# one artifact type per line
claudecode-linter --detect path/to/repo/

# JSON array
claudecode-linter --detect --output json path/to/repo/

# git hook: only lint repos that actually contain Claude Code artifacts
claudecode-linter --detect . >/dev/null 2>&1 && claudecode-linter .
```

### Example Output

```
$ claudecode-linter my-plugin/

my-plugin/skills/example/SKILL.md
warn Body has 117 words (recommended: 500-5000) skill-md/body-word-count:5

my-plugin/.claude/settings.json
error "settings.json" should only exist at user level (~/.claude/).
Use "settings.local.json" for project-level settings settings-json/scope-file-name
warn "env" is a user-level field — it has no effect in
project-level settings.local.json settings-json/scope-field:9:3

1 error, 2 warnings
```

```
$ claudecode-linter --fix-dry-run my-plugin/

--- my-plugin/skills/deploy/SKILL.md
+++ my-plugin/skills/deploy/SKILL.md (fixed)
-name: My Deploy Skill
+name: my-deploy-skill
-description: Use when the user asks to "deploy": handles both cases.
+description: "Use when the user asks to \"deploy\": handles both cases."
```

```
$ claudecode-linter my-plugin/
No issues found.
```

## Artifact Types

| Type | Files | Rules |
|------|-------|-------|
| plugin-json | `.claude-plugin/plugin.json` | 13 |
| skill-md | `skills/*/SKILL.md` | 16 |
| agent-md | `agents/*.md` | 20 |
| command-md | `commands/*.md` | 10 |
| hooks-json | `hooks/hooks.json` | 9 |
| settings-json | `.claude-plugin/settings.json` | 25 |
| mcp-json | `.claude-plugin/mcp.json` | 16 |
| claude-md | `CLAUDE.md` | 10 |
| lsp-json | `.lsp.json` | 3 |
| monitors-json | `monitors/monitors.json` | 3 |

## Schema-derived rules

Seven `*/schema-valid` rules — for `plugin-json`, `settings-json`, `skill-md`, `agent-md`, `command-md`, `lsp-json` and `monitors-json` — validate against JSON Schemas that are *auto-extracted from Claude Code's cli.js bundle* — the same Zod schemas the runtime calls `.safeParse(content)` on. This catches:

- **Missing required fields** (e.g., LSP server without `extensionToLanguage`)
- **Wrong field types** (e.g., `name: 42` instead of a string)
- **Invalid enum values** (e.g., `transport: "websocket"` when only `stdio`/`socket` are accepted)
- **Nested-shape violations** in discriminated unions (e.g., `mcpServers.x.type: "telegraph"` when only `stdio`/`sse`/`http`/... are accepted)
- **Unknown fields** in strict objects (e.g., `filetypes`/`rootPatterns` inside an LSP server config — common confusion with editor-LSP shapes from other ecosystems)

The schemas live at `contracts/{plugin,lsp,monitors}.schema.json` and are regenerated by `npm run extract-contracts` on every Claude Code release.

`lsp-json/no-lsp-servers-wrapper` catches a specific authoring mistake: putting your `.lsp.json` content under a top-level `lspServers` key. That wrapper belongs **inline in plugin.json** only — the dedicated file is a flat map of server-name → config.

`monitors-json/unique-names` enforces Claude Code's "monitor names must be unique within a plugin" check (a `refine()` predicate that JSON Schema can't express natively).

## Configuration

Generate a config file with all rules and their default severities:

```bash
# Create .claudecode-lint.yaml in current directory
claudecode-linter --init

# Create in a specific directory
claudecode-linter --init ~/projects/my-plugin/

# Create in home directory (applies globally)
claudecode-linter --init ~
```

claudecode-linter looks for config in this order:

1. `.claudecode-lint.yaml` or `.claudecode-lint.yml` in the current directory
2. `.claudecode-lint.yaml` or `.claudecode-lint.yml` in `$HOME`
3. Bundled defaults (all rules enabled at their default severity)

Example config:

```yaml
rules:
plugin-json/name-kebab-case: true
skill-md/word-count:
severity: warning
min: 50
claude-md/no-todos: false
```

## Fixers

Both `--format` and `--fix` run the same fixers. The difference: `--format` only formats and reports changes, `--fix` also lints the result afterwards.

Formatting is powered by [prettier](https://prettier.io/) for consistent JSON and markdown output. Custom logic handles domain-specific transformations that prettier can't (key sorting, YAML fixes, kebab-case normalization).

| Artifact | Prettier | Custom logic |
|----------|----------|--------------|
| plugin-json | Tab-indented JSON | Canonical key ordering |
| hooks-json | 2-space JSON | Alphabetical key sorting |
| mcp-json | 2-space JSON | Server name sorting, canonical field ordering |
| settings-json | 2-space JSON | Canonical key ordering, permission array sorting |
| skill-md / agent-md / command-md | Markdown body | Frontmatter YAML normalization, kebab-case names, pre-parse quoting |
| claude-md | Markdown | Blank line before headings |

## Exit Codes

| Code | Meaning |
|------|---------|
| 0 | No errors |
| 1 | Lint errors found |
| 2 | Fatal error |

## Running on untrusted plugins

claudecode-linter is a **static analyzer** — it parses and validates the artifacts it inspects, it never executes them. There is no `eval`, no `child_process`, no declared hooks are run, and no MCP servers are spawned. Linting **trusted** code needs no special isolation.

For **untrusted** plugins — especially with `--fix`, which writes files back to disk — run the linter sandboxed. claudecode-linter is verified to run correctly fully confined: no network, a read-only root filesystem, all Linux capabilities dropped, `no-new-privileges`, a non-root UID, and only the target directory mounted.

### The Docker image

Two multi-arch (`linux/amd64`, `linux/arm64`) images are published to the GitHub Container Registry — two separate packages, each with its own `:latest` rolling tag and `:` tag:

| Image | Built from | Notes |
|-------|-----------|-------|
| `ghcr.io/retif/node-claudecode-linter` | `Dockerfile` — `node:24-alpine` | default |
| `ghcr.io/retif/bun-claudecode-linter` | `Dockerfile.compile` — `bun build --compile` single executable | smaller (~44 MB compressed) |

**Pull a published image:**

```bash
docker pull ghcr.io/retif/node-claudecode-linter # default (node:24-alpine)
docker pull ghcr.io/retif/bun-claudecode-linter # smaller (bun --compile)
```

**Or build it locally** from a checkout of this repo:

```bash
docker build -t node-claudecode-linter . # default (Dockerfile)
docker build -f Dockerfile.compile -t bun-claudecode-linter . # smaller variant
```

Both images behave identically. The `docker run` recipes below use `ghcr.io/retif/node-claudecode-linter`; substitute `ghcr.io/retif/bun-claudecode-linter` or a locally-built tag as you prefer.

### Sandboxed invocation

**Docker — read-only lint:**

```bash
docker run --rm --network none --read-only --tmpfs /tmp \
--user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \
-v "$PWD":/work:ro -w /work ghcr.io/retif/node-claudecode-linter /work
```

**Docker — `--fix`:** the mount must be read-write so fixes can be written back. Otherwise identical, plus the `--fix` flag:

```bash
docker run --rm --network none --read-only --tmpfs /tmp \
--user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \
-v "$PWD":/work -w /work ghcr.io/retif/node-claudecode-linter --fix /work
```

All four recipes here are verified. On Linux without Docker, [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) gives the equivalent boundary: `--unshare-all` cuts network (confirmed: `ECONNREFUSED` inside the sandbox), and nothing is writable except — for `--fix` — the target directory (confirmed: a write outside it is refused).

**bwrap — read-only (lint):**

```bash
bwrap \
--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
--unshare-all --die-with-parent \
--chdir "$PWD" \
claudecode-linter "$PWD"
```

**bwrap — read-write (`--fix`):** the later `--bind` overrides the read-only root for just the target directory:

```bash
bwrap \
--ro-bind / / --bind "$PWD" "$PWD" \
--dev /dev --proc /proc --tmpfs /tmp \
--unshare-all --die-with-parent \
--chdir "$PWD" \
claudecode-linter --fix "$PWD"
```

`--ro-bind / /` can be replaced with explicit per-path `--ro-bind` entries (e.g. just `/usr`, `/nix`, and the target) for least-read-authority.

See [`SECURITY.md`](SECURITY.md) for the full security model, the audited input-handling hardening, and how to report a vulnerability.

## Versioning

This linter's version tracks the Claude Code version it was extracted from:

- **Contract sync**: version matches Claude Code exactly (e.g., `2.1.69` for Claude Code v2.1.69)
- **Linter-only bugfix**: pre-release suffix `2.1.69-patch.1`, `2.1.69-patch.2`, etc.

Pre-release versions sort below the base version in npm (`2.1.69-patch.1 < 2.1.69`), but `^2.1.68` will still resolve them. When the next Claude Code version is released (e.g., `2.1.70`), it supersedes all patches.

## Development

```bash
npm install
npm run build
npm test
```

### Updating contracts

When a new Claude Code version is released:

```bash
# 1. Extract contracts from latest Claude Code
npm run extract-contracts

# Or extract from a specific version
npm run extract-contracts -- --version 2.1.58

# 2. Generate src/contracts.ts from the JSON
npm run generate-contracts

# 3. Build and test
npm run build && npm test
```

The `--version` flag is useful for testing the CI pipeline: extract an older version, commit it, then let CI detect the newer latest version and run the full release flow.

Use `--changelog` to also write a `CHANGELOG_ENTRY.md` file with a markdown drift report (used by CI):

```bash
npm run extract-contracts -- --changelog
```

This is automated in CI via `.github/workflows/release.yml`.

## License

MIT