pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
305 lines (251 loc) • 15.4 kB
Markdown
# Configuring pi-lens
There are **two** pi-lens config files:
| File | Scope | Notes |
| --- | --- | --- |
| `.pi-lens.json` | the project | Committed or not, your call. Nearest one wins **per field** — a package can override one setting without restating the repo root's. |
| `~/.pi-lens/config.json` | the machine | Your defaults across every project. The winning global location is selected by the [global-location table](#global-config-location); see [environment variables](environment-variables.md) for the knobs. |
Both files have the same shape, with one exception noted below the example:
everything LSP-related lives under an `lsp` namespace inside them.
```jsonc
{
"$schema": "https://raw.githubusercontent.com/apmantza/pi-lens/master/docs/schema/pi-lens-config-v1.json",
"ignore": ["dist/**"],
"maxProjectFiles": 8000,
"rules": { "high-complexity": { "threshold": 25 } },
"lsp": {
"disabledServers": ["typos"],
"warmFiles": ["src/main.rs"],
"servers": {
"my-server": {
"name": "My Custom LSP",
"extensions": [".myext"],
"command": "my-lsp-server",
"args": ["--stdio"],
"covers": ["shellcheck"]
}
},
"serverOverrides": {
"rust": {
"initializationOptions": { "check": { "command": "clippy" } }
}
}
}
}
```
Each model-facing tool accepts `tools.<name>.enabled` in the config file. Valid
names include `ast_grep_search`, `ast_grep_replace`, `ast_grep_outline`,
`lsp_navigation`, `lens_diagnostics`,
`lens_diagnostic_mark`, `symbol_search`, `module_report`, `project_report`,
`read_symbol`, `read_enclosing`, `effective_config`, `analyze`, `health`,
`latency`, `project_scan`, and `rebuild`. The activation loader and MCP
lifecycle tools `session_start`, `turn_end`, and `session_end` remain enabled
because their host protocols require them.
### `lsp.servers.<id>.covers` — declaring which runners a custom server subsumes
A custom server entry accepts an optional `covers` array naming dispatch
runner ids the server subsumes: while that server is a file's selected primary
language server, the runners it names defer to the warm lane instead of also
running as CLI scans. `covers: ["shellcheck"]` on a shell LSP, for example,
makes the ShellCheck runner report a skip (`covered-by-primary`) for the files
that server covers instead of double-reporting the same findings.
- Values are runner ids (`shellcheck`, `shfmt`, `taplo`, … — the dispatch
registry's ids). A member pi-lens does not recognize is dropped with a
`PILENS_CFG_0005` warning naming the entry; the server itself still
registers and runs its language-server lane.
- An empty `covers` array claims nothing and is recorded as configured.
- Across files, the nearest tier that sets the field supplies the whole
array — a project value replaces the user-level value for that server;
fields are never united across tiers.
**Some settings are global-only.** A handful of switches — `lsp.enabled`
(`--no-lsp`), `tests.enabled`, `delta.enabled` and the other session-wide
toggles — are decided once for the machine, not per project, so writing one in a
`.pi-lens.json` does nothing. It is not ignored quietly: the project loader says
so, naming the key. `docs/settings.md` lists which flags are which.
## Which file wins
One order, lowest precedence first. A later tier replaces an earlier tier's
value **for that field only** — objects are merged field-wise, never replaced
whole, so setting one key never silently drops the rest of a section.
1. **global** — `~/.pi-lens/config.json`.
2. **project root** — the outermost `.pi-lens.json` at or above your working
directory.
3. **nested-project** — every `.pi-lens.json` between that root and your working
directory, outermost first. The nearest file wins, per field.
Those three are the tiers the config **files** resolve through, and they are the
only ones this resolution populates. Four more tiers are reserved in the
precedence table — `builtin` below them, and `env`, `cli`, `host` above — and
nothing writes into them yet; #2427 (env/CLI) and #2416 (host and project trust)
are what fill them in.
Until they do, environment variables and CLI flags are read by their own
accessors rather than through this resolution, and their effective precedence
for a pi-lens toggle is:
1. a `PI_LENS_*` environment variable set to `1` — checked first, and it wins
outright;
2. the matching `--lens-*` / `--no-*` CLI flag;
3. the nearest project `.pi-lens.json`, then the outer ones (project-scoped
settings only);
4. `~/.pi-lens/config.json`;
5. the built-in default.
Subsystem-specific env overrides follow the same shape: a
`PI_LENS_REVIEW_GRAPH_MAX_FILES` beats a `.pi-lens.json`'s
`reviewGraph.maxFiles`. `docs/environment-variables.md` and `docs/settings.md`
are the per-setting references.
### Global config location
The global file is selected by this order, highest precedence first. The
canonical default is still `~/.pi-lens/config.json`; `PI_LENS_HOME` relocates
machine-generated data and state, not this file. This is the complete truth
table for the three existence axes.
| `PI_LENS_CONFIG_PATH` | `~/.pi-lens/config.json` exists | `PI_CODING_AGENT_DIR` set and `extensions/pi-lens.json` exists | Winner |
| --- | --- | --- | --- |
| unset | no | no | `~/.pi-lens/config.json` (canonical default) |
| unset | no | yes | `$PI_CODING_AGENT_DIR/extensions/pi-lens.json` |
| unset | yes | no | `~/.pi-lens/config.json` (grandfathered existing file) |
| unset | yes | yes | `~/.pi-lens/config.json` (the host file is shadowed; shadowed file reported once per session (#3299)) |
| set | no | no | the explicit `PI_LENS_CONFIG_PATH` file |
| set | no | yes | the explicit `PI_LENS_CONFIG_PATH` file |
| set | yes | no | the explicit `PI_LENS_CONFIG_PATH` file |
| set | yes | yes | the explicit `PI_LENS_CONFIG_PATH` file |
If an existence probe for a recognized candidate errors (for example,
`ENOTDIR`, `EACCES`, or `ELOOP`), that candidate's tier is retained rather
than falling through to a lower location. The subsequent read reports the
degraded config under `PILENS_CFG_0001`; a probe error is not treated as
absence.
pi-lens does not write this file. Create or edit it yourself, then start a new
process. For an XDG-like setup, put generated data and state at a durable data
root and choose one config surface:
```sh
export PI_LENS_HOME="$HOME/.local/share/pi-lens"
# XDG-shaped host: use its config directory.
export PI_CODING_AGENT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/pi"
mkdir -p "$PI_CODING_AGENT_DIR/extensions"
```
For hosts without `PI_CODING_AGENT_DIR`, use an explicit config path instead:
```sh
export PI_LENS_CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/pi-lens/config.json"
mkdir -p "$(dirname "$PI_LENS_CONFIG_PATH")"
```
When migrating an existing `~/.pi-lens/config.json`, create the new file first
and delete the old file second. While both exist, the old file wins by design;
deleting it first would leave the process without the intended settings. Copy
the file (or hand-author it, since pi-lens has no writer), verify the new
location, then run `rm "$HOME/.pi-lens/config.json"` to opt out of
grandfathering. If the new host file is later deleted, the old file wins again
while it exists; with neither file present, the canonical default path is used.
See also the [environment-variable reference](environment-variables.md#config-location)
and [settings overview](settings.md#the-three-ways-to-configure-pi-lens).
### One exception: `lsp.disabledServers` is a denial, not a value
Ordinary settings are last-tier-wins. A **denial** is not, because the tier
that made it is usually the one you control and the tier that would override it
is usually one that arrived with somebody else's checkout.
`lsp.disabledServers` resolves as the **union of every tier's entries**. A
project `.pi-lens.json` can add to it and can never subtract from it, so a
repository cannot re-enable a server you turned off in
`~/.pi-lens/config.json`. There is no vocabulary for un-denying an entry: if
you change your mind, edit the file that denied. The provenance reports, per
denied server, the tier that contributed it.
```console
$ # which servers run for this file, and why
$ pilens_effective_config file=src/main.rs
✗ typos — disabled-by-config (global ~/.pi-lens/config.json → /lsp/disabledServers/0)
```
The union spans **both spellings**: a document's deprecated root keys
(`servers`, `serverOverrides`, `disabledServers`, `warmFiles`) are read
into the `lsp` namespace before any tier is merged, so one setting is resolved
once no matter which spelling each file uses. Migrating does not change the
answer, staying un-migrated is not a way around the denial, and a half-migrated
pair of files merges rather than one clobbering the other.
Two rules make the rest of the table unambiguous:
- **The search stops at `$HOME`.** pi-lens never reads a config file in your
home directory or above it. A stray `pi-lens.json` in `$HOME` (or at `C:\`)
is not adopted by every project on the machine. The machine-global file is
read by its own path, so it is unaffected.
- **The canonical spelling wins.** Where a legacy file or a legacy key means the
same thing as the canonical one, the canonical one is used — otherwise the
migration below could never be completed.
## Legacy locations (still read; being removed)
These are read for their deprecation window and then **removed**. Each one you
still have produces one warning per setting, naming exactly where to move it —
carrying the stable code `PILENS_CFG_0003` (a deprecated file) or
`PILENS_CFG_0002` (a deprecated key), so you can match or suppress on the code
rather than on the prose.
| Legacy | Move it to | Code |
| --- | --- | --- |
| `.pi-lens/lsp.json` | `.pi-lens.json` → `lsp.*` | `PILENS_CFG_0003` |
| `pi-lsp.json` | `.pi-lens.json` → `lsp.*` | `PILENS_CFG_0003` |
| `pi-lens.json` (undotted) | `.pi-lens.json` | `PILENS_CFG_0003` |
| `~/.pi-lens/lsp.json` | `~/.pi-lens/config.json` → `lsp.*` | `PILENS_CFG_0003` |
| `servers` at the file root | `lsp.servers` | `PILENS_CFG_0002` |
| `serverOverrides` at the file root | `lsp.serverOverrides` | `PILENS_CFG_0002` |
| `disabledServers` at the file root | `lsp.disabledServers` | `PILENS_CFG_0002` |
| `warmFiles` at the file root | `lsp.warmFiles` | `PILENS_CFG_0002` |
**Deprecated since 4.1.4. Read for the last time before 5.0.0.** The window is
declared as data in `clients/config-diagnostic-codes.ts`
(`DEPRECATED_CONFIG_SURFACES`) and enforced by test, so the schedule above and
the code cannot drift apart. `docs/public-api-stability.md` describes the policy
these dates instantiate.
A `.pi-lens.json` that mixes both spellings is fine while you migrate: the
canonical key wins, and the keys you have not moved yet keep working.
Only keys pi-lens actually recognizes get "move it to …" advice. A key in a
legacy file that is not a pi-lens setting at all — a typo, or a leftover from
another tool — cannot be migrated anywhere, so it gets the ordinary
unrecognized-key notice (`PILENS_CFG_0001`) and is counted in ONE whole-file
`PILENS_CFG_0003` notice for the file rather than being told to move.
## When a config is ignored
A file that cannot be read or parsed is **ignored, never partially applied** —
pi-lens runs on defaults for it and says so once, with the code
`PILENS_CFG_0001`. A field whose value does not match its declared type is
dropped on its own (`PILENS_CFG_0005`), and an unrecognized field is dropped
with a message naming the key (`PILENS_CFG_0004`). If resolving a file fails
internally the whole file is ignored and said so under its own code
(`PILENS_CFG_0008`), so "one field went missing" and "none of this file is in
effect" are never the same code. Nothing about your config is ever ignored
silently.
The number of notices one file can produce is bounded PER NOTICE LIST, because
the number of keys in a file is not. There are two lists, split by who composes
them rather than by what they say — both are about values that were rejected:
- what **resolving** the file produced — the per-field rejections
(`PILENS_CFG_0004`, `PILENS_CFG_0005`, `PILENS_CFG_0006`) together with the
deprecation notices (`PILENS_CFG_0002`, `PILENS_CFG_0003`), which share this
list;
- what the **loader** reading the file produced on its own — unknown top-level
keys and settings it refused (`PILENS_CFG_0001`).
Each list is bounded at 20 records: up to 19 notices plus, when the bound bit,
a single `PILENS_CFG_0007` summary giving the count that was suppressed, so a
truncated list always says that it is truncated. That summary is about the
LIST, not about the file: a config whose every setting was applied can still
overflow the bound, so it is worded and recorded as a summary rather than as an
ignored config.
One notice is never suppressed by that bound: `PILENS_CFG_0008`, which says the
whole file is out of effect. It is not one more rejected key competing for a
slot — it is what tells you the rejections above it are no longer the whole
story — so it is kept however full the list already was.
## Asking what is actually in effect
You never have to reconstruct the table above by hand. `pilens_effective_config`
(MCP) and `effective_config` (pi) return the resolved configuration with the
provenance of **every** leaf — the tier, the file, the key, and the trust
decision that applied — plus, for a file you name, its language, every LSP
server with the reason it was selected or denied, and the runners that would
dispatch. That is the answer to "why is this running" and to "why is this *not*
running", without reading a log.
Naming a `file` resolves the configuration **at that file's own directory**,
which is where the runtime decides from — so a nested `repo/sub/.pi-lens.json`
layer contributes to the answer, appears in the reported document list, and is
named as the file behind any decision it made. The walk runs upward and is
**confined to `cwd`**: a `file` that resolves outside `cwd` — including a
sibling package in the same monorepo — is rejected rather than answered from
its own unrelated tree, because the per-file answer is only ever correct when
it is a superset of the workspace's own; the rejection names the `cwd` it was
measured against and the remedy is to re-query with `cwd` set to that file's
own workspace. A confined `file` always passes back through the workspace's
own documents on its way up, so they are always included too.
It reports **sources, never values**. Environment values never appear, a custom
server's command line is cut to the binary itself, and every path is rewritten
`~`-relative. There is no un-redacted mode: the un-redacted data is the config
file you already have.
`pilens_health` embeds the same provenance as **counts per tier** — how many
settings each source decided, and which `PILENS_CFG_*` notices the resolution
produced — so a session's config posture is visible without the detail.
## See also
- `docs/globalconfig.md` — every key of `~/.pi-lens/config.json`, in detail.
- `docs/settings.md` — the CLI flags and what they map to.
- `docs/environment-variables.md` — the `PI_LENS_*` tier.
- `docs/public-api-stability.md` — what `x-stability`, the `PILENS_CFG_*` codes,
and the deprecation windows commit pi-lens to.