CLI reference
poly lint [PATHS]...poly fmt [PATHS]...Shared flags
Section titled “Shared flags”Both poly lint and poly fmt accept every flag below unless the description says otherwise.
Global flags
Section titled “Global flags”These work on every subcommand, before or after it (poly --color never lint and
poly lint --color never are the same).
| Flag | Effect |
|---|---|
--config <PATH> |
Use an explicit config file. Bypasses nested config resolution. |
--color <auto|always|never> |
When to colour output. auto (the default) colours a terminal and honours NO_COLOR. Replaces --no-color. |
--offline |
Never use the network. Also set by POLY_OFFLINE=1. Uncached tree-sitter grammars are treated as missing, catalog-tool downloads are refused, and remote extends and [[hooks.sources]] use only what poly.lock pins and the cache already holds (anything else fails with an offline mode error). POLY_COMPONENT_MIRROR (base URL replacing the origin of component downloads) and POLY_COMPONENT_CACHE (component cache root) are the related environment variables. |
-q, --quiet |
Trim pretty output to the findings and the summary. Conflicts with --verbose and --debug. |
-v, --verbose |
Pretty output includes descriptions, URLs and metadata, and names every skipped file rather than the first 3 per skip reason. For poly hooks run, also shows the output of jobs that passed. |
--debug |
Include cache hit/miss and timing data. Implies --verbose. Conflicts with --quiet. |
Writing changes
Section titled “Writing changes”| Flag | Effect |
|---|---|
--fix |
Apply lint fixes or formatting in place. |
--exit-nonzero-on-fix |
poly fmt --fix only. Exit 1 after rewriting files instead of 0 (errors still exit 2). For a CI step that must fail when the tree was not already formatted. |
A dry run is the default for poly fmt; --fix writes and exits 0 on success. poly fmt --check
was removed: it is still accepted for one major version, hidden, and fails with a hint to drop it.
Generated files
Section titled “Generated files”| Flag | Effect |
|---|---|
--fix-generated |
Also rewrite files whose header stamps a content hash over the body (<project>:hash:<digest>). |
To skip machine-generated files for one run (banner or stamp), use --set discovery.generated=false;
--set discovery.generated=true pins the default back on in a repo that set
[discovery] generated = false. These replace --skip-generated and --include-generated.
Hash-stamped files are the only generated files poly withholds a write from — poly fmt skips
them and poly lint --fix reports without rewriting — because reformatting invalidates the hash
and the generator’s verify step then reports drift on a file nobody edited. A plain DO NOT EDIT
or @generated banner does not hold poly back: those files are linted, formatted and fixed like
any other, so --fix-generated has no effect on them.
Under --set discovery.generated=false the files are reported as skipped, so they count against --deny-skips
and --max-skips.
Discovery
Section titled “Discovery”| Flag | Effect |
|---|---|
--exclude <GLOB> |
Exclude paths from discovery. Repeatable; merged with [discovery] exclude. An unanchored glob matches at any depth — lead with / to anchor it to the config directory. |
--include-excluded |
Check explicitly named files or directory roots even when excluded. Overrides [discovery] force_exclude. Exclusions below an included directory remain active. |
Run-scoped overrides
Section titled “Run-scoped overrides”| Flag | Effect |
|---|---|
--set <KEY=VALUE> |
Override one config key for this run. Repeatable. A TOML layer applied above poly.local.toml, so it beats every file. KEY is a dotted TOML path; VALUE is parsed as a TOML value (true, 3, ["a/**"]) and falls back to a plain string. |
--on-missing <fail|skip> |
What to do when a file needs an engine component that is not installed. Overrides [components] on_missing. fail (the default) exits 2 when a needed component is not installed; skip skips its files and charges them to the skip budget, so --deny-skips still fails. A download, verification or compatibility failure is an error either way. |
--set discovery.force_exclude=true applies [discovery] exclude to explicitly named files too (the
default, so it matters only in a repo that set force_exclude = false); it replaces
--force-exclude. Together with the discovery.generated values above, that covers the three flags
--set replaced. A malformed KEY=VALUE fails the run with exit 2.
Selecting engines
Section titled “Selecting engines”| Flag | Effect |
|---|---|
--only <ENGINE> |
Run only the named engines. Repeatable and comma-separated (--only ruff,typos). Conflicts with --skip. |
--skip <ENGINE> |
Run every routed engine except the named ones. |
Engines are named by the id you configure them under — ruff, oxc, typos, rumdl, quality,
astgrep. poly config show lists the ones your config touches.
Three things worth knowing before you reach for it:
- It narrows, it never widens. An engine your config leaves off stays off when you name it.
--only uncommenton a repo without[engines.uncomment] enabled = trueselects nothing rather than switching the backend on, so the flag can never become a second, invisible way to enable a tool. - Engine ids are unique per language, not globally.
--only biomeselects every engine answering to that name — the CSS one and the GraphQL one both. - A name no engine answers to fails the run with exit 2 and a list of what is recognized. A
typo’d
--only ruffswould otherwise empty every plan, check nothing, and exit 0.
Naming engines also skips the whole-project phase, which is built from tools (cargo clippy,
cargo-deny) rather than engines; poly lint prints a note saying so. That is usually the point —
running one cheap engine without paying for a cargo build is half of what the flag is for.
Files left with no selected engine are reported as skipped with their own reason, and are not
charged to --deny-skips / --max-skips:
skipped main.rs: no engine selected by --only/--skip for this fileTo turn an engine off for every run rather than one invocation, use
enabled in poly.toml instead.
The whole-project phase (poly lint only)
Section titled “The whole-project phase (poly lint only)”| Flag | Effect |
|---|---|
--workspace auto |
The default. Run the phase for a whole-repository run, skip it for a path-scoped one, and follow [workspace] run. |
--workspace always |
Run the whole-project phase even though explicit paths were given. |
--workspace never |
Skip the whole-project phase — cargo clippy / sort / machete / deny and any other configured whole-workspace tools. Equivalent to [workspace] run = "never". |
These replace the old --workspace / --no-workspace pair. The phase is never path-scoped: under --workspace always the tools cover the whole repository regardless
of the named paths and of [discovery] exclude, and the run says so on stderr.
Output
Section titled “Output”| Flag | Effect |
|---|---|
--format <pretty|json|toon> |
Output format. Default: pretty. |
--color, -q, -v and --debug are global flags.
The levels are a ladder — quiet < normal < verbose < debug — each a superset of the one
below. --quiet drops the per-file discovery and skip detail printed beneath the findings; every
count and reason stays in the summary, so nothing is hidden, only un-itemised. Findings still print
and exit codes are unchanged. It has no effect on --format json / toon.
The json / toon document
Section titled “The json / toon document”poly lint and poly fmt emit one object, not a bare array of per-file records:
{ "results": [{ "path": "src/main.py", "diagnostics": [], "config": 0 }], "errors": [], "skipped": [{ "path": "main.zig", "reason": "no lint rules for Zig" }], "summary": { "checked": 1, "skipped": 1, "errored": 0 }, "configs": [{ "hash": "1/9f2c4a1b7e3d05f8" }]}Gate on summary.checked, not on an empty results. A run that skipped every file reports no
diagnostics and is not an error, so without the summary it is indistinguishable from a clean pass.
The three counts are not a partition of results, and cannot be derived from it:
- a file that was checked and found clean produces no record at all —
resultsholds only files with something to report; - a file whose language poly has no lint rules for can be both a skip and a result, because the cross-cutting backends (ast-grep, the quality tier, and typos when enabled) still run over it.
errors and skipped repeat what the corresponding records already carry, deliberately: the defect
they exist to close is a consumer reading a clean-looking list and concluding the files are fine.
When the binary does not link an engine family and a run needs its component, the document also
carries components: { used, installed_now, missing } and summary.missing_components. Each
missing entry has the component name, a kind (offline, install_never, download_failed,
verify_failed or incompatible), the reason, and whether it is blocking. A bundled build that
links every family omits both fields. Components are fetched only before a run is planned, never
while files are being checked.
configs is the fingerprint of every configuration that governed the run — root, the nearest
governing poly.toml directory relative to the run root (omitted entirely at the root, and
under --config, which bypasses the cascade), and hash, a version-framed blake3 digest of the
fully merged table. Each result’s config field indexes into configs (omitted when 0), so two clean reports
are only comparable when their configs entries match: a poly.toml, a poly.local.toml, a
nested config or an extends base can move underneath an otherwise-identical run.
The MCP server returns this same document, so both surfaces answer the coverage question identically.
Coverage gates
Section titled “Coverage gates”| Flag | Effect |
|---|---|
--deny-skips |
Exit 2 if any charged file was skipped (see below). Equivalent to --max-skips 0. |
--max-skips <N> |
Exit 2 if more than N charged files were skipped. |
Execution
Section titled “Execution”| Flag | Effect |
|---|---|
--no-cache |
Bypass the result cache. |
-j, --jobs <N> |
Parallel jobs. Default: logical cores. |
Skips and coverage
Section titled “Skips and coverage”Skipped files — a language poly has no lint rules for, no matching engine, a generated file, a
binary file, an engine every backend disabled by config, or an engine --only/--skip deselected
— are always counted and their reasons summarized, so a run that checked nothing cannot look like a
clean pass.
Only some of those reasons count against --deny-skips / --max-skips: a reason that names a
limit of poly is charged, a reason that names an instruction the caller gave (--only/--skip,
enabled = false) is not. See
Coverage and withdrawal for the full split.
poly lint reports a file whose language nothing in the run lints as:
skipped main.zig: no lint rules for Zigand keeps it out of the linted count. This covers the tier-2 languages the code-quality tier cannot
model — Zig, Swift, Dart, Gleam, Elixir, Nix, Scala, Lua, R — and any language whose linter is
opt-in or missing from PATH; a shell script with no shellcheck installed is reported rather
than counted.
A file a directory walk could not identify as any language is counted separately —
N file(s) of unrecognized type not checked, with the first few named — rather than itemised as a
skip, since every repository is full of images, lock files and snapshots no linter was ever going
to read. A path you name on the command line is different: naming it is a request to check it, so
it is reported as a skip.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
Clean: no lint findings, no formatting drift, or all writes succeeded. |
1 |
Error-severity lint findings remain, a whole-project tool failed, or (for poly fmt) files would change. Warning-severity findings alone never trigger this — the quality tier (and typos, when enabled) doesn’t redden CI. |
2 |
The run verified less than it claims: a file an engine failed on, a formatter that did not converge within its pass cap, a skip budget exceeded (--deny-skips / --max-skips), a config or pipeline error, or a machine-readable report that failed to serialize. |
poly doctor — which poly am I actually running?
Section titled “poly doctor — which poly am I actually running?”poly doctor # human report; exits 1 when something is actively wrongpoly doctor --format json # the same report, for a bug report or a CI check (also: --format toon)Run this before filing a bug. It prints the resolved path of the running executable with its
version and build identifier, every poly on PATH in order with the version each one
reports, the config files in effect, and the cache directory — then exits non-zero on a real
defect: a competing install on PATH, a poly that cannot report its own version, or a config
that fails to load. Each finding carries the concrete remedy, including the fact that a
cargo-installed ~/.cargo/bin/poly needs rm, not cargo uninstall poly.
It also warns, without failing, about configuration that will not do what it looks like: an
exclude glob that matches at more than one depth, a wildcard exclude (bazel-*) that only prunes
directories below the config root, and a hook job that needs the untracked node_modules under
staged isolation. The version probe of each poly on PATH is time-bounded;
POLY_PROBE_TIMEOUT_SECS raises the bound.
poly --version reports the build identifier too:
$ poly --versionpoly 0.31.0 (release build v0.31.0, release)versus poly 0.31.0 (dev build v0.31.0-8-g18aa5e8, debug) — so a development build carrying unreleased
changes cannot be quoted as a release. The identifier comes from git describe at build time;
outside a git checkout it reads unknown rather than guessing, and packagers can set
POLY_BUILD_ID.
When another poly on PATH differs from the running one, every command warns once on stderr and
points at poly doctor. A correctly-installed poly finds a single entry and prints nothing;
POLY_NO_SHADOW_WARN=1 silences it regardless.
poly components — engine components
Section titled “poly components — engine components”poly components list # every component: bundled, installed, not installedpoly components status [PATHS] [--verify] # what these paths need; --verify re-checks installed archivespoly components install <name|preset|all> # download and verify (also: uninstall)poly components sync [PATHS] # install everything these paths needpoly components prune [--all] [--dry-run] # drop cached archives the lock no longer referencespoly components import <archive> # seed an air-gapped host from a downloaded archivecomponent is an alias. Every subcommand takes --format pretty|json|toon; the machine-readable
documents carry schema: 1. Selection, offline use and mirrors are covered in
Components and offline use.
A build that links every engine family is bundled: its components never download, and
install only prints a note. list and status never download on any build; they read the cache
and nothing else. status exits 1 when a component the paths need is missing or fails --verify.
install, sync and import exit 2 on a failure. sync --offline is a check that exits 2 when
something is missing. prune keeps archives the lock references for any target and archives used within
--keep-days (default 30), and clears staging directories an interrupted install left behind;
--all removes every archive. --dry-run lists exactly what a real prune removes. A file a loaded
library still holds (Windows) is reported as skipped and left for a later prune. sync covers components only; grammars and
tool downloads are fetched by the run that needs them. poly doctor carries a components section
with the flavor, cache root, installed components and what the current directory needs.
poly lock — pin remote sources
Section titled “poly lock — pin remote sources”poly lock [--upgrade] [--check] [--config <PATH>]poly lock writes poly.lock, the one lock file for remote extends bases ([[extends]]) and
Git hook sources ([[hook_sources]]). A plain run keeps every revision already recorded and
resolves only new sources; --upgrade re-resolves every symbolic ref to its latest object ID.
--check writes nothing and exits 1 when poly.lock would change, so CI can gate on it.
poly.lock replaces poly-config.lock, poly-hooks.lock, poly config update and
poly hooks update; commands read only poly.lock.
poly config — what did poly actually parse?
Section titled “poly config — what did poly actually parse?”poly config show [--format <toml|json|toon>]poly config schema [--output <PATH>]poly config validate [PATHS]...poly config show prints the effective, fully-merged configuration — every section and key
poly resolved after extends bases, the nested poly.toml cascade and poly.local.toml have all
been applied:
# poly effective configuration## merged from: /repo/poly.toml# /repo/poly.local.toml# extends: path ../baseline/poly.toml# hooks: present
[defaults]line_length = 120...
[engines.ruff]mccabe_max_complexity = 3The default output is a valid TOML document, so diff against your own poly.toml shows exactly
what poly kept — including keys poly does not recognize, which are printed as written rather than
dropped. [defaults], [discovery] and [workspace] are shown fully resolved, so a
setting nobody wrote (line_length = 120) is still visible; every other section is shown exactly
as merged.
--format json / --format toon emit the same document as { "config": …, "resolution": … },
where resolution carries the config path, the files that were merged, the resolved extends
bases, and whether [hooks] is present — the facts the TOML form carries as comments.
poly lock resolves symbolic extends git refs and Git hook sources to pinned object IDs and
writes poly.lock. Config validation reports unknown top-level sections, unknown keys within a
recognized section, and wrongly-typed values as warnings — a misspelled key no longer parses
silently and does nothing.
poly config validate checks one or more poly.toml files (a directory means its poly.toml;
with no path, the nearest one) without loading or downloading any engine component. Option keys
come from the component index compiled into poly. A key that an engine recognises only by probing
its own option type is checked in-process when that engine is linked into the binary. Otherwise
it is accepted, and the engine’s component is listed as partially validated. Configuring an
engine whose component is in [components] disable is an error. Exit 0 means valid; 2 means
a problem was found or a file could not be read. extends bases are not resolved.
poly config schema prints the JSON Schema for poly.toml — derived from the same config types
the parser uses, with each backend’s declared [engines.<id>] option keys included. Use it for
editor validation and autocomplete; the schema is also published at
https://goldziher.github.io/poly/poly.schema.json. --output <PATH> writes it to a file. See the
configuration reference.
poly commit, poly hooks, poly cache, poly mcp
Section titled “poly commit, poly hooks, poly cache, poly mcp”poly commit --message "feat: add backend" # or a path: poly commit .git/COMMIT_EDITMSGpoly hooks install # a foreign hook is kept as <hook>.legacy and runs before polypoly hooks install --replace-lefthook # discard a lefthook hook instead of keeping it inertpoly hooks run pre-commit -v # also show the output of jobs that passedpoly cache statspoly cache gcpoly cache cleanpoly --config /path/to/poly.toml mcp| Command | Flags |
|---|---|
poly hooks run [STAGE] |
--all-files (the whole tracked tree, not the staged files), --message-file <PATH> (required to run commit-msg / prepare-commit-msg directly), -j <N>, --no-cache, --no-sccache (and the global -v, --config) |
poly hooks install |
--hook-type <TYPE> (repeatable; default: the stages poly.toml configures), --overwrite (discard an existing hook instead of keeping it as <hook>.legacy), --replace-lefthook |
poly hooks uninstall |
--hook-type <TYPE>; restores a kept <hook>.legacy |
poly cache gc |
--max-age <DAYS>, --max-size <SIZE> (500M, 2G) |
poly cache … |
--cache-dir <DIR> operates on another cache directory |
See Git hooks for the full [hooks] surface, and
Agents and MCP for the MCP tool reference. In short: the MCP server
is stdio-only; lint, format_check, components, cache_stats, rules, config_show and
version are read-only; lint_fix, format_write, cache_clean, components_install and
components_uninstall are mutating; workspace_lint and
workspace_lint_fix run the whole-project phase as async Tasks — the call returns a task handle
and the client polls tasks/get, while a client without the tasks capability gets a synchronous
result instead.
poly migrate
Section titled “poly migrate”poly migrate # dry-run: report what would move into poly.tomlpoly migrate --write # absorb tool configs into poly.toml, remove redundant filespoly migrate folds settings from ruff, taplo, markdownlint and typos config files — and
from pyproject.toml’s [tool.ruff] / [tool.typos] / [tool.codespell] — into poly.toml,
then deletes or strips only the sources poly can fully honor. Files it delegates to
(rustfmt.toml, .golangci.yml, clippy.toml, …) and anything not fully representable are kept.
It also absorbs git-hook configs into [hooks]: lefthook.yml (with every file it extends,
merged the way lefthook merges them) and .pre-commit-config.yaml.
- A direct call to a tool poly runs itself —
ruff format/ruff check,oxlint,oxfmt,rubyfmt,shfmt,golangci-lint,buf format,buildifier,actionlint, … — becomes thefmt/lintbuiltin, and the catalog tool it needs is enabled (and version-pinned) under[tools]. A job that rewrote files (--fix,-w,stage_fixed) maps to the builtin in fix mode, which re-stages what it rewrites; a lefthook job that left its fixes unstaged is reported as a behavior change. Flags with a poly equivalent are carried as config —--unsafe-fixes,shfmt -i N([engines.shfmt] indent_width),golangci-lint --new-from-rev, buildifier’s-lint=fix— and each job’s ownfiles/excludescope becomes a builtinjobsentry, so no exclude is dropped. A call whose flags poly cannot reproduce stays a command. - Every other job becomes
[hooks.<stage>.commands.<name>](keeping{staged_files}) or, forbash path/to/script.sh …,[hooks.<stage>.scripts.<name>]. A job with no{staged_files}becomesworkspace = true.globandexclude(including regex-style excludes, where they can be translated) becomefiles/exclude, andstage_fixedis kept. - Nested
group:trees becomeprioritybands: lower bands finish first, and jobs sharing a band run together. Builtins always run at priority 0. - pre-commit hooks with a known id map to the
fmt,lintandfile_safetybuiltins (detect-aws-credentialstoaws_credentials,requirements-txt-fixertorequirements_txt_fixer);repo: localhooks become commands. Aprek runjob is dropped once the pre-commit config is fully absorbed. - Git-hook argument placeholders (
{0},{1}, …,{git_args}) are carried as written; poly substitutes them itself. - A ruff config’s
extendchain is followed, a target ruff would infer fromrequires-pythonis written out, and the ruff defaults poly’s own would replace (line length 88, …) are pinned, so the migrated config behaves as the ruff config did. - Under
--recurse, a nested directory’s hooks are written to its ownpoly.toml(they then run on commit, scoped to that directory), and virtualenvs,.gitignored trees andbazel-*output are not descended into.
Anything it cannot map — a placeholder poly does not substitute ({push_files}), skip / only
git-operation conditions, an unknown pre-commit hook, an exclude that cannot be translated, a
root or guard on a job that became a builtin — is listed as unrepresented in the plan, and the
source file is kept.
| Flag | Effect |
|---|---|
--report |
Say explicitly that this is a dry run. This is the default behaviour. |
--write |
Apply the migration. |
--recurse |
Walk nested projects, skipping .git, node_modules, virtualenvs, build output and .gitignored trees. |
--verify |
Re-run lint and format after writing. |
--strip-superseded |
Additionally strip pyproject.toml [tool.*] sections for the Python tools ruff supersedes — black, isort, flake8, … |
--to-v2 |
After the importers, rewrite poly.toml and poly.local.toml from the v1 to the v2 schema (ADR 0038), keeping comments. Conflicts with --verify. See Migrating to poly 2. |
--allow-dirty |
Let it write into a repository with uncommitted changes. |
It takes an optional path, defaulting to the current directory.
poly rules — custom rules
Section titled “poly rules — custom rules”poly rules test [DIR]... # verify rules against their *-test.yml snippetspoly rules list [DIR]... # list every resolved rule (built-in pack + user rules)poly rules list --format json # same rows as JSON (also: --format toon)poly rules list prints one row per rule — id, language, builtin or user, the severity it
reports at under the current config, and the rule’s own declared default (off for an opt-in
rule) — so a warning you did not recognise can be traced to the rule that raised it, and turned
off. --format json / --format toon carry the same fields.
With no DIR, both read [engines.astgrep] rule_dirs from the nearest poly.toml. poly rules test exits
non-zero on any failed snippet: a valid snippet that matched, an invalid one that didn’t, a
fixed: autofix that differed, or a test naming an unknown rule id.