# lintsync — AI Reference A command-line tool (binary `lintsync`) that keeps ESLint, Prettier and Stylelint configs in sync with a shared PRESET, for one project or many, without losing local overrides. It writes the preset's rules directly into the project's existing config files (there is no `extends` package), records which keys it owns in `.lintsync/manifest.json`, and treats a rule changed by hand that the preset also wants to change as a CONFLICT instead of overwriting it. Version 0.1.0 (pre-release; interfaces may still change). This document is hand-written for AI agents and other tools that drive this CLI: every flag, default, exit code and behavior note below is verified against the TypeScript source. It covers what `--help` does not make obvious: the ownership model, when a command writes, when it blocks for input, exit codes, and the JSON shapes. For narrative docs, see the site: - Full docs (EN): https://npm.vuecraft.ru/en/packages/lintsync/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/lintsync/guide/overview - GitHub: https://github.com/macrulezru/lintsync - npm: https://www.npmjs.com/package/lintsync Links starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Read this first — rules that prevent most mistakes 1. **`sync` writes NOTHING without `--yes`** (and never with `--dry-run`). A plain `lintsync sync` in a script reports `would-update` and changes no file. To apply: `lintsync sync --yes`. To preview: `lintsync sync --dry-run` or `lintsync status`. 2. **A conflict is not applied, ever, in non-interactive use.** A conflict on any managed key withholds ALL changes for THAT tool (other tools in the same run are still applied). The conflict is reported and the command exits 1. Resolve it by deciding, then editing: `lintsync set ...` to take a value, or fix the file, then `sync` again. 3. **The interactive conflict UI opens only when ALL of these hold:** a real terminal on stdin AND stdout, no `--json`, no `--yes`, no `--dry-run`. In an agent/CI context it never opens, so a conflict always surfaces as exit code 1. 4. **`get`/`set`/`unset` do NOT update the manifest.** That is deliberate: a manual edit made with `set` shows up as a conflict on the next `sync` if the preset also manages that key. Do not use `set` to "accept the preset" — use `sync`. 5. **Everything needs a manifest.** `sync`, `status`, `get`, `set`, `unset` fail with exit 2 ("No manifest found at .lintsync/manifest.json. Run `lintsync init` first.") in a project that was never initialized. 6. **`init` without `--preset` needs a terminal.** With no TTY (or with `--json`) and no `--preset`, it prints an error and exits 2. Always pass `--preset` in automation. 7. **Use `--json` for machine reading.** Output is a single JSON document on stdout; exit codes are described in section 4. --- ## 2. Choosing a command | Goal | Command | Writes? | |---|---|---| | Set a new project up from a preset | `init --preset ` | config files, `.lintsync/manifest.json`, `package.json` scripts, installs devDependencies | | See how far a project drifted from its preset | `status` | never | | Preview what `sync` would change | `sync --dry-run` (same engine as `status`) | never | | Apply the safe preset changes | `sync --yes` | config files + manifest | | Read one config value | `get .` | never | | Change / remove one config value by hand | `set . ` / `unset .` | the config file only (manifest untouched) | | Convert a legacy config format | `migrate --to ` | writes the new file, never deletes the source | | Check or sync MANY projects | `projects add ...`, then `status --all` / `sync --all --yes` | as above, per project | | Manage saved custom presets | `presets list` / `presets remove ` | presets store | --- ## 3. Concepts - **Preset**: a named, versioned set of per-tool config values. Built-in presets, with the tools each defines: `vue-app` (eslint, prettier, stylelint), `react-app` (eslint, prettier, stylelint), `npm-lib` (eslint, prettier — no CSS), `base` (eslint, prettier, stylelint; generic). Presets live inside lintsync itself, so `npm update -g lintsync` is how preset updates arrive. Locally saved presets (built by the interactive `init` constructor) are merged into the registry and can be used by name. - **Tool**: `eslint`, `prettier` or `stylelint`. - **Managed keys**: the paths a preset owns. ESLint and Stylelint: `rules.*` (each rule is one managed key). Prettier: `*` (every top-level option). A `*` matches exactly ONE path segment. - **Manifest** `.lintsync/manifest.json` in the project root: per tool — `{ "preset": "npm-lib", "version": "0.1.0", "configPath": "eslint.config.mjs", "managed": { "rules.no-console": { "presetValue": "error", "version": "0.1.0" } } }`. `presetValue` is the value the preset had when it was last applied. - **Three-way comparison per managed key** (file value F, manifest value M, preset value P): - F equals M (you did not touch it) and F differs from P -> a CHANGE (safe, applied by `sync --yes`); the manifest is updated. - F equals P already -> nothing to write; the manifest is refreshed. - F differs from M AND from P -> a CONFLICT (you changed it, the preset wants something else). - P has no such key any more -> the key is dropped from tracking. A newly added preset rule is picked up even though it does not exist in the file yet. - **Point edits**: only the touched keys change; comments, quotes and formatting elsewhere in the file are preserved. - **Supported config formats**: JSON/JSONC (`.json`, `.jsonc`), YAML (`.yaml`, `.yml`), JS/TS (`.js .mjs .cjs .ts .mts .cts`), including ESLint flat-config arrays and CommonJS `module.exports`. In JS/TS files only LITERAL values are read or replaced: a spread, function call, imported variable or template with substitutions reads as not found and cannot be overwritten (the edit fails with an error for that tool instead of clobbering it). --- ## 4. Exit codes | Command | 0 | 1 | 2 | 3 | |---|---|---|---|---| | `sync` | clean, or changes applied / would-apply (`--dry-run`, or no `--yes`) | at least one UNRESOLVED CONFLICT | any error (no manifest, unknown preset, unreadable/unsupported file, edit failure) | — | | `status` | always, even with drift or conflicts | — | an execution error only | — | | `sync --all` / `status --all` | every project returned 0 | every project returned 1 | every project returned 2 | projects returned DIFFERENT codes | | `init` | success, or tools skipped | cancelled in the interactive menu | error (unknown preset/tool, unsupported format, write failure, `--preset` missing without a terminal) | — | | `get`, `set`, `unset` | success | — | any failure incl. bad path syntax, no manifest, path not found, unsupported edit | — | | `migrate` | success | — | error (unknown tool/format, no legacy file found, target exists) | — | | `projects *`, `presets *` | success | — | error (duplicate name, unknown name) | — | Important: `--dry-run` finding changes is exit 0, not 1. `status` NEVER returns 1 — conflicts are a state of the project, not a failure of the command. To fail a CI job on drift use `sync --dry-run` (exit 1 only on conflicts) and inspect the JSON, or `status --json` and check `tools[].status`. --- ## 5. Commands Program option: `--config ` — path of the main lintsync config file (env `LINTSYNC_CONFIG`; default is the OS config dir: Windows `%APPDATA%\lintsync\Config`, macOS `~/Library/Preferences/lintsync`, Linux `$XDG_CONFIG_HOME/lintsync`, usually `~/.config/lintsync`). It is read from argv in ANY position. The global registry (`projects.json`) and saved presets (`presets.json`) live in that directory, and the `config.json` itself. ### 5.1 `init [tool] [--preset ] [--cwd] [--force] [--json] [--quiet] [--verbose]` For each tool the preset defines (or only `[tool]`): writes a config file from the preset, records the manifest baseline, installs the needed packages as devDependencies using the DETECTED package manager (npm/pnpm/yarn/bun; runs it as a real child process), and adds npm scripts (`lint` = `eslint .`, `lint:fix` = `eslint . --fix`, `format` = `prettier --write .` — only for the tools in play). A project with no `package.json` still gets configs and manifest, just no scripts. - Without `--force`, a tool whose config already exists is SKIPPED (report status `skipped`, message "Config already exists at X (use --force to overwrite)"). For ESLint, any of `eslint.config.{js,mjs,cjs,ts,mts,cts}` counts as existing. In an interactive terminal it asks instead of skipping. - `--force` overwrites whatever file already establishes the tool's config (even if named differently from the preset's default). - Default config file names: ESLint `eslint.config.mjs`, Prettier `.prettierrc.json`, Stylelint `.stylelintrc.json`. - No `--preset` in a real terminal opens an interactive menu (built-in or saved preset, individual tools with generic defaults, or build a new preset; a newly built preset is saved and then used). - JSON shape: `{ preset: {name, version}|null, tools: [{tool, configPath, status: "created"|"skipped"|"error", message}], dependenciesInstalled: string[], scriptsUpdated: string[], exitCode, error }`. ### 5.2 `sync [--cwd] [--tool ] [--all] [--tag] [--registry] [--dry-run] [-y/--yes] [--json] [--quiet] [--verbose]` Compares the project (from the manifest) with its presets and applies SAFE changes. Writes only when `--yes` and not `--dry-run`. `--tool` restricts to one tool of the manifest. `--all` runs for every registered project instead of `--cwd` (always non-interactive; use with `--yes` to apply, `--tag ` to filter, `--registry ` for another registry file). Per tool, `status` is one of `clean`, `would-update`, `updated`, `conflict`, `error`. JSON shape (single project): ``` { "project": null, "tools": [ { "tool": "eslint", "configPath": "eslint.config.mjs", "preset": {"name": "npm-lib", "version": "0.1.0"}, "status": "conflict", "changes": [ {"path": ["rules","no-console"], "from": "warn", "to": "error"} ], "conflicts": [ {"path": ["rules","no-console"], "fileValue": "warn", "manifestValue": "error", "presetValue": "off"} ], "error": null } ], "exitCode": 1, "error": null } ``` Whole-project failures (no manifest) put the message in top-level `error` with an empty `tools` array and `exitCode` 2. `sync --all --json` returns `{ "projects": [ "> ], "exitCode": 0|1|2|3 }`. In the interactive resolver (terminal, no `--yes`/`--json`/`--dry-run`), each conflict offers "Accept preset", "Keep local", "Edit manually", with a live preview; the choices are written immediately. "Keep local" is a per-run decision, NOT a permanent pin: the same conflict returns on the next `sync` unless the file or the preset changes. ### 5.3 `status [--cwd] [--tool] [--all] [--tag] [--registry] [--json] [--quiet] [--verbose]` The read-only sibling of `sync --dry-run`: identical report, never writes, never interactive, exits 0 unless an execution error. `--verbose` adds details of each change and conflict. ### 5.4 `get `, `set `, `unset ` (`--cwd`, `--json`, `--quiet`) The path starts with the TOOL name, then bracket/dot notation: `eslint.rules["@typescript-eslint/no-unused-vars"]`, `eslint.rules.no-console`, `prettier.printWidth`, `stylelint.rules.color-no-invalid-hex`. A bare segment ends at `.`, `[` or `]`, so `/` and `@` are fine in a bare name; a name that itself contains a dot needs the bracket-and-quote form (`["a.b"]`, single or double quotes). The tool must be tracked in the manifest (run `init ` first). - `set `: the value is parsed as JSON when it parses (`100`, `true`, `null`, `["warn", {"argsIgnorePattern": "^_"}]`), otherwise taken as a literal string (`off`). Quote JSON in the shell. - `get` of a missing path exits 2 ("Path not found"). `--quiet` prints only the exit code on error. - JSON: `get` -> `{tool, path, found, value, exitCode, error}`; `set`/`unset` -> `{tool, path, [value], applied, exitCode, error}`. ### 5.5 `migrate --to [--cwd] [--json] [--quiet]` `` is `eslint`, `prettier` or `stylelint`. Target formats: eslint -> `flat` (writes `eslint.config.mjs`); prettier and stylelint -> `json`, `yaml` or `js`. Looks for the legacy file by conventional name (e.g. `.eslintrc.json/.yaml/.yml/.js/.cjs`, `.prettierrc.{json,yaml,yml,js,cjs}`, `prettier.config.js`, `.stylelintrc.{json,yaml,yml,js}`). Files with no extension (bare `.eslintrc`, `.prettierrc`) are NOT detected. NEVER deletes the source, NEVER overwrites an existing target. For ESLint ONLY `rules` is migrated mechanically; every other top-level key (`extends`, `plugins`, `env`, `parserOptions`, `globals`, `overrides`, `parser`) is listed in `needsManualReview` instead of being translated. Prettier/Stylelint keys carry over unchanged. Report: `{tool, fromPath, toPath, migratedKeys, needsManualReview, exitCode, error}`. `migrate` does not create a manifest; run `init` or adopt the file afterwards. ### 5.6 `projects add [--tags a,b] [--registry

] [--json]`, `projects remove `, `projects list [--tag ] [--json]` The registry is a global list of known projects (default `projects.json` in the config dir). `` may start with `~`. Names must be unique. Tags are free-form, comma separated (e.g. `type:npm-package,type:site`). Used by `sync --all` / `status --all`. `list --json` -> `{projects: [{name, path, tags}], exitCode}`. ### 5.7 `presets list [--json] [--presets ]`, `presets remove [--json]` Only locally saved presets (built via the interactive `init`). Built-in presets cannot be listed here or removed. --- ## 6. Recipes Set a fresh project up (non-interactive) and verify: ``` lintsync init --preset npm-lib lintsync status --json ``` Keep a project in sync in CI (fail on a conflict, apply nothing): `lintsync sync --dry-run --json` -> exit 1 means a conflict needs a human; exit 0 with `tools[].status == "would-update"` means safe drift exists. Apply preset updates after upgrading lintsync (`npm update -g lintsync`): ``` lintsync sync --dry-run # look first lintsync sync --yes # apply the safe ones lintsync sync --yes --json # if exit 1: read conflicts[], decide, then `set` or edit ``` Take the preset's value for a conflicting rule: set it to the conflict's `presetValue` (`lintsync set 'eslint.rules.no-console' error`), then run `lintsync sync --yes` — the file now equals the preset, so the manifest is refreshed and the conflict disappears. Keep your own value: leave the file as is; the conflict will be reported again every run until the preset or the file changes (there is no way to pin a divergence). Across many projects: ``` lintsync projects add web ~/work/web --tags type:site lintsync projects add lib ~/work/lib --tags type:npm-package lintsync status --all --json lintsync sync --all --tag type:npm-package --yes --json ``` Migrate then adopt: `lintsync migrate eslint --to flat`, review `needsManualReview`, then (if the project should follow a preset) `lintsync init eslint --preset npm-lib --force`. --- ## 7. Pitfalls - `sync` without `--yes` is a preview in practice. Scripts that "run sync" and expect files to change must pass `--yes`. - `--yes` disables the interactive resolver on purpose: with `--yes` a conflict is reported and exit 1, never prompted. - A conflict blocks ALL of that tool's changes, including the safe ones. Resolve the conflict to unblock the rest. - `status` exit 0 does not mean "no drift"; read `tools[].status` and `conflicts`. - `set` bypasses the manifest by design; a `set` on a managed key that the preset also changes will surface as a conflict next time. Use it for intentional local overrides of keys the preset does not manage, or to align a file with the preset's value. - JS/TS configs are edited only where values are literals. A conflict or change on a key whose current value is dynamic fails that tool with an error (exit 2) and writes nothing for it. - `init` installs packages with the project's package manager over the network; it can take a while and needs the registry reachable. - `init` does not touch an existing config unless `--force` (or an interactive "yes"). Re-running `init` on an initialized project is therefore mostly a no-op report. - `migrate` finds only conventionally named legacy files, not extension-less ones. - The registry and saved presets are per USER (OS config dir), not per project; `--config`/`LINTSYNC_CONFIG` redirects them for an isolated run.