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
- Host: GitHub
- URL: https://github.com/retif/claudecode-linter
- Owner: retif
- License: mit
- Created: 2026-03-05T10:01:22.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-06-07T01:14:31.000Z (17 days ago)
- Last Synced: 2026-06-07T03:42:17.385Z (17 days ago)
- Topics: anthropic, claude, claude-code, cli, linter, plugin, typescript
- Language: TypeScript
- Size: 2.15 MB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 5
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Security: SECURITY.md
Awesome Lists containing this project
README
# claudecode-linter
[](https://github.com/retif/claudecode-linter/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/claudecode-linter)
[](https://github.com/retif/claudecode-linter/blob/main/LICENSE)
[](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.

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