# markdownlint-cli puts the Markdown rules on the command line

> markdownlint-cli is the shell front end for the markdownlint rule set, with glob support, ignore files, JSON output, custom rules, exit codes designed for CI and a fix mode that rewrites your files in place. It ships a single file, requires Node 22, and its configuration discovery has two deliberate safety exceptions.

**igorshubovych/markdownlint-cli** — MarkdownLint Command Line Interface

- Repository: https://github.com/igorshubovych/markdownlint-cli
- Stars: 1,101 · Forks: 108
- Language: JavaScript
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/igorshubovych-markdownlint-cli

## The rules live elsewhere, the CLI is the shell around them

markdownlint-cli is the Command Line Interface for MarkdownLint, and the dependency list in the package metadata makes the division of labour clear. The rules come from the markdownlint package at 0.41.1, and this package is the command line wrapper around them, with commander doing the argument parsing at version 15.

That split is the reason to prefer this over embedding the library in a script. The rule set is a separate concern from the invocation, so the same rules can be applied from a pre-commit hook, from a CI job, from an editor extension and from a script, and they will agree. This package ships exactly one file, markdownlint.js, which is listed both as the main entry and as the bin, so a global install gives you a markdownlint command and nothing else.

The rest of the dependencies are the practical work of a file linter. minimatch and tinyglobby handle the glob expansion, the ignore package implements the gitignore style exclusion rules, js-yaml, jsonc-parser and smol-toml parse the configuration formats, jsonpointer resolves the --configPointer argument, deep-extend merges configuration objects, run-con handles the standard configuration locations, and markdown-it is there for parsing the Markdown itself.

The audience is anyone with Markdown to keep tidy: a documentation team with a style guide, an open source project that wants its README to follow the same conventions as its code, or a CI pipeline that should fail when a contributor adds a heading without the right punctuation.

## Three ways to install it, one of them a container

The global npm install is the standard route:

```bash
npm install -g markdownlint-cli
```

On macOS there is a Homebrew formula, which is the route most people on a Mac will take:

```bash
brew install markdownlint-cli
```

And there is a container image, published to GitHub Packages, with a run command that mounts the current directory as the working directory:

```bash
docker run -v $PWD:/workdir ghcr.io/igorshubovych/markdownlint-cli:latest "*.md"
```

The Dockerfile in the repository explains the last one. It starts from node:lts-alpine, copies the project in, runs npm install --production and then npm install --global, switches the working directory to /workdir, and sets the entrypoint to /usr/local/bin/markdownlint. So the image is a thin wrapper around the global npm install, and the volume mount is how your files get in.

The container route is the one to think about for CI, because it removes the Node version question from the runner. The package metadata declares engines node >=22, so a global install needs a Node 22 or newer, and a CI image with an older Node will fail at install time rather than at run time.

The tool also ships a .pre-commit-hooks.yaml in the repository root, so the linter can be wired into a git hook rather than only into a CI job, and the package scripts include a precommit script that runs xo and the test suite.

## The option list is where the real behaviour is

The help output in the documentation is the best description of the tool, because it is short and every option has a job.

The arguments are files, directories and globs. Then there is the -V, --version flag, the -c, --config option that takes a configuration file in JSON, JSONC, JS, TOML or YAML, and --configPointer, a JSON Pointer to an object inside the configuration file, which defaults to an empty string. The -d, --dot flag includes files and folders with a dot in the name, for example .github, which is off by default and is the flag you need if your documentation lives in a dot directory.

Then the output side. -j, --json writes the issues as JSON, -o, --output writes them to a file with no console output, and -q, --quiet writes nothing to standard output. That trio is what makes the tool usable in a pipeline, where you want machine readable findings rather than a wall of text.

Then the exclusion side. -i, --ignore takes a file, directory or glob and can be used more than once, and -p, --ignore-path points at a file containing ignore patterns.

Then the two modes that change the input source. -s, --stdin reads from standard input, and the documentation notes it does not work with files, and -f, --fix fixes basic issues, and it does not work with STDIN either.

And finally the rule selection. -r, --rules includes custom rule files, which can be a file, a directory, a glob or a package. --enable and --disable take a list of rules, for example --enable MD013 MD041.

The pairing of -s and -f is a design decision worth noticing. A tool that can rewrite a file on disk cannot also be reading that same file from a pipe, and the documentation states both limitations rather than leaving them to be discovered.

## Configuration discovery, and the two formats that are opt in

The CLI argument --config is not required, and when it is not provided the tool uses the run-con package to merge configuration from all supported locations, then overlays any configuration from the first matching file of .markdownlint.jsonc, .markdownlint.json, .markdownlint.yaml or .markdownlint.yml in the current folder.

So in the ordinary case you drop a .markdownlint.json in your repository root and it is picked up. The sample configuration in the documentation shows the shape:

```json
{
  "default": true,
  "MD003": { "style": "atx_closed" },
  "MD007": { "indent": 4 },
  "no-hard-tabs": false,
  "whitespace": false
}
```

The named rules carry per rule options, the MD003 style set to atx_closed and the MD007 indent set to 4, and the alias style entries turn individual rules off. The rule set itself is the one from the markdownlint package, so the documentation for the rules is the documentation you need, not something this repository maintains.

Now the two exceptions, and they are the most interesting thing in the configuration section.

JavaScript configuration files must be provided via the --config argument, and they are not automatically loaded because running untrusted code is a security concern. A JS config must have the .js or .cjs extension, must export a configuration object with module.exports, and if your workspace is ESM-only, with type set to module in the root package.json, the configuration file should end with .cjs. A JS configuration may also require npm packages internally, which is how you share one configuration across projects.

TOML configuration files must also be provided via --config, and they are not automatically loaded.

Both of those are the same judgement applied twice. A configuration file that the tool loads without being told to is a file that can run code or pick up settings the user did not intend, and a linter that runs in CI should not execute a stray file from a directory it happens to be pointed at. If you want either format, you name it, and the naming is the consent.

## enable and disable beat the config file, and the -- trap

Two behaviours here cause real confusion the first time you hit them.

The first is precedence. The documentation states that --enable and --disable override configuration files, so if a configuration file disables MD123 and you pass --enable MD123, it will be enabled. And if a rule is passed to both --enable and --disable, it will be disabled. So the disable list wins over the enable list, and both win over the file.

The second is the variadic argument trap, and the documentation calls it out with a note. Because --enable and --disable are variadic arguments that accept multiple values, it is necessary to end the list by passing -- before the files argument, like so:

```bash
markdownlint --disable MD013 -- README.md
```

Without the --, the parser cannot tell where the list of rule names ends and the file list begins, so a command like markdownlint --disable MD013 README.md has an ambiguous meaning. The -- is what terminates the variadic list, and the file arguments then follow.

The same note applies to the other variadic-looking options, and the ignore examples make the point concrete. With shells like Bash, it may be necessary to quote globs so they are not interpreted by the shell, because --ignore *.md would be expanded by Bash to --ignore a.md b.md and so on before markdownlint-cli ever sees it, causing it to ignore only the first file, since --ignore takes a single parameter even though it can be used multiple times. Quoting the glob, --ignore '*.md', passes it through unexpanded.

The documented globbing examples make the platform difference explicit. On Windows CMD, markdownlint **/*.md --ignore node_modules. On Linux Bash, markdownlint '**/*.md' --ignore node_modules. Same lint, different quoting rules.

## Ignore files are applied in a defined order

The exclusion rules are worth understanding because a file that is silently not linted is the most common way a linter gives a false sense of coverage.

If a .markdownlintignore file is present in the current folder it is used to ignore files and directories, according to the rules for gitignore. If the -p or --ignore-path option is present, the specified file is used instead of .markdownlintignore. So the ignore file is chosen by precedence, not merged.

The order of operations is then fixed and documented. First, files, directories and globs passed on the command line are enumerated. Second, exclusions from -p or --ignore-path, if specified, or from .markdownlintignore, if present, are applied. Third, exclusions from any -i or --ignore options are applied.

So the command line globs decide what is considered at all, the ignore file decides what is dropped, and the inline --ignore options are the last word. That ordering is the opposite of what people often assume, where an --ignore on the command line is expected to win over everything. Here the ignore file is applied before the inline options, and both are applied after enumeration, which means an --ignore of a path that was never enumerated has no effect.

The .dockerignore file in the repository root is a different thing entirely and is there for the container image, not for the linter.

## The fix mode rewrites files and will not fix everything

The --fix option is the one that changes your repository, so the documentation is careful about it.

When --fix is specified, markdownlint-cli tries to apply all fixes reported by the active rules and reports any issues that remain. Because this option makes changes to the input files, it is good to make a backup first or work with files under source control so any unwanted changes can be undone.

And the second note is the important one. Because not all rules include fix information when reporting issues, fixes may overlap, and not all issues are fixable, --fix will not usually address all issues.

That is an honest statement about the nature of a rule set, and it is a warning about what a CI job should expect. If your pipeline runs --fix and then fails on the remaining findings, you have built a job that rewrites files on the server and then complains, which is a bad combination. The sane pattern is --fix locally in a pre-commit hook where a human can see the diff, and a plain lint in CI where the exit code is the signal.

The exit codes are built for that split. Zero is linting successful with no errors, with warnings possible. One is linting successful with some errors, and warnings possible. Two is unable to write the -o or --output output file. Three is unable to load a custom rule from -r or --rules. And the documentation lists further codes beyond that.

The rule about the difference between a warning and an error matters for the exit code, because a repository that treats warnings as errors needs a rule configuration that promotes them, and that is a configuration decision rather than a command line flag.

## Conclusion

markdownlint-cli is worth adopting if your repository has Markdown in it and you want the style of that Markdown to be enforced the same way your code style is, because the rules live in a separate package, the configuration is a file you can commit, and the exit codes are built for a CI job. It is the wrong tool if your Markdown is generated, if you want a linter that understands your specific dialect, or if you expect the fix mode to leave no findings behind, since the documentation is explicit that it will not usually address all issues. Before you wire it into CI, read the exit code table and the configuration discovery rules, and note that the current release is v0.49.1 from 2026-07-14 while the repository was pushed to on 2026-10-14.

## FAQ

### How do I install markdownlint-cli?

The documented routes are npm install -g markdownlint-cli, brew install markdownlint-cli on macOS, or the container image ghcr.io/igorshubovych/markdownlint-cli:latest, which is run with a volume mount of the current directory. The package requires Node 22 or newer.

### Why do I need -- before the file names when using --enable or --disable?

Because --enable and --disable are variadic arguments that accept multiple values, the list has to be terminated before the file arguments start. The documentation gives markdownlint --disable MD013 -- README.md as the form to use, and notes that a rule passed to both --enable and --disable is disabled.

### How do I configure markdownlint-cli?

Put a .markdownlint.jsonc, .markdownlint.json, .markdownlint.yaml or .markdownlint.yml in the current folder and it is picked up automatically, or pass a file with --config. JSON, JSONC, YAML, INI and TOML are supported, and JS configuration files are supported as well but must be passed through --config because they can execute code.

### Will --fix resolve all the markdownlint findings?

No. The documentation states that not all rules include fix information when reporting issues, that fixes may overlap, and that not all issues are fixable, so --fix will not usually address all issues. It also warns to make a backup or work under source control, because the option modifies the input files.

### Which files does markdownlint-cli ignore?

A .markdownlintignore file in the current folder is used according to gitignore rules, and the -p or --ignore-path option replaces it. The order of operations is to enumerate the command line arguments first, then apply the ignore file, then apply any -i or --ignore options.

## Sources

- [igorshubovych/markdownlint-cli on GitHub](https://github.com/igorshubovych/markdownlint-cli)
- [Issues](https://github.com/igorshubovych/markdownlint-cli/issues)
- [License: MIT](https://github.com/igorshubovych/markdownlint-cli/blob/master/LICENSE)
- [README](https://github.com/igorshubovych/markdownlint-cli/blob/master/README.md)
- [Releases](https://github.com/igorshubovych/markdownlint-cli/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/igorshubovych-markdownlint-cli
