claudecode-linter
v2.1.177
Published
Standalone linter for Claude Code plugins and configuration files
Maintainers
Readme
claudecode-linter
Standalone linter for 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
npm install -g claudecode-linterOr run directly:
npx claudecode-linter ~/projects/my-plugin/Or build it from a clone of this repository — there is no dist/ until you build:
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:
# 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-rulesFormat
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:
# 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:
# 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?":
# 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: 42instead of a string) - Invalid enum values (e.g.,
transport: "websocket"when onlystdio/socketare accepted) - Nested-shape violations in discriminated unions (e.g.,
mcpServers.x.type: "telegraph"when onlystdio/sse/http/... are accepted) - Unknown fields in strict objects (e.g.,
filetypes/rootPatternsinside 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:
# 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:
.claudecode-lint.yamlor.claudecode-lint.ymlin the current directory.claudecode-lint.yamlor.claudecode-lint.ymlin$HOME- Bundled defaults (all rules enabled at their default severity)
Example config:
rules:
plugin-json/name-kebab-case: true
skill-md/word-count:
severity: warning
min: 50
claude-md/no-todos: falseFixers
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 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 :<version> 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:
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:
docker build -t node-claudecode-linter . # default (Dockerfile)
docker build -f Dockerfile.compile -t bun-claudecode-linter . # smaller variantBoth 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:
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 /workDocker — --fix: the mount must be read-write so fixes can be written back. Otherwise identical, plus the --fix flag:
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 /workAll four recipes here are verified. On Linux without Docker, 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):
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:
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 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.69for 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
npm install
npm run build
npm testUpdating contracts
When a new Claude Code version is released:
# 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 testThe --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):
npm run extract-contracts -- --changelogThis is automated in CI via .github/workflows/release.yml.
License
MIT
