Metadata-Version: 2.4
Name: kaeris
Version: 0.2.40
Summary: AI localization CLI — translate .xcstrings/JSON/YAML/.strings/Markdown/.po/ARB & 6 more formats into 46 languages
Author: KAERIS
License: MIT
Project-URL: Homepage, https://kaeris.dev
Project-URL: Documentation, https://kaeris.dev/developer.html
Project-URL: Repository, https://github.com/kaeris-io/kaeris-cli
Project-URL: Issues, https://github.com/kaeris-io/kaeris-cli/issues
Keywords: i18n,l10n,localization,translation,ai,cli,xcstrings,string-catalog,xcode,markdown,mdx,flutter,arb
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: MIT License
Classifier: Topic :: Software Development :: Localization
Classifier: Topic :: Software Development :: Internationalization
Classifier: Environment :: Console
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

<img src="https://kaeris.dev/icon-512.png" alt="KAERIS" width="72" align="left" style="margin-right:16px" />

# KAERIS CLI

Check locale files in CI and translate them into **46 languages** from the terminal.

[![PyPI](https://img.shields.io/pypi/v/kaeris)](https://pypi.org/project/kaeris/)
[![npm](https://img.shields.io/npm/v/kaeris)](https://www.npmjs.com/package/kaeris)
[![tests](https://github.com/kaeris-io/kaeris-cli/actions/workflows/tests.yml/badge.svg)](https://github.com/kaeris-io/kaeris-cli/actions/workflows/tests.yml)
[![license](https://img.shields.io/github/license/kaeris-io/kaeris-cli)](https://github.com/kaeris-io/kaeris-cli/blob/main/LICENSE)

```bash
pip install kaeris                                           # check and translate, Python 3.8+
npx kaeris check --source locales/en.json --langs de,fr,ja   # the check only, Node 18+
```

- **`kaeris check`** fails the build when a translation is missing, a key is left over, or a
  placeholder was lost or renamed. Offline, no key, no API call.
- **`kaeris translate`** writes 46 languages. `--only-new` translates only the keys you added
  and leaves the rest of each file as it was.
- **`--verify`** translates each line back and flags the ones whose meaning may have changed,
  with a reason and a suggested fix. `kaeris fix` writes the fixes into the files.
- **13 formats**: JSON, YAML, `.strings`, `.xcstrings`, Markdown/MDX, `.po`, ARB, Android XML,
  CSV, XLIFF 1.2, `.properties`, `.resx`, Fluent `.ftl`.
- **No dependencies**: Python standard library only.
- **CI**: [kaeris-io/kaeris-action](https://github.com/kaeris-io/kaeris-action) for GitHub,
  [recipes](https://kaeris.dev/ci-github-actions.html) for GitLab, pre-commit and npm scripts.

**Plans.** `kaeris check` is free. Translation: Free (no account, 8 languages per run),
Pro $19/month with 12M characters, Team/API $79/month with 60M characters, Lifetime $79 once
with your own OpenRouter key. See [kaeris.dev/pricing](https://kaeris.dev/pricing.html).

[Docs](https://kaeris.dev/docs) · [Pricing](https://kaeris.dev/pricing.html) ·
[Releases](https://github.com/kaeris-io/kaeris-cli/releases) ·
[Discussions](https://github.com/kaeris-io/kaeris-cli/discussions) ·
[Status](https://kaeris.dev/status.html) · [Security](https://github.com/kaeris-io/kaeris-cli/blob/main/SECURITY.md)

## Install

```bash
pip install kaeris
# or, without installing:
pipx run kaeris --help
```

### Windows: `kaeris` is not recognized

`pip` puts `kaeris.exe` into Python's `Scripts` folder, and on Windows that folder is often not
on `PATH` — with the Python install manager (`pythoncore-3.14-64`) it never is. pip says so
in a yellow `WARNING: The script kaeris.exe is installed in '…\Scripts' which is not on PATH`.

Every command works without it — run the module through Python instead:

```powershell
python -m kaeris translate locales/en.json --langs de,fr
py -m kaeris --version
```

To type plain `kaeris`, add the folder to your user `PATH` once. This prints the folder:

```powershell
python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
```

(after a per-user pip install it is `python -c "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user'))"`).
Add it in *Settings → System → About → Advanced system settings → Environment Variables →
Path (your user) → New*, then open a new terminal. `pipx install kaeris` is the other way: pipx
keeps its own folder on `PATH` (`pipx ensurepath`).

## Config file (`kaeris.json`) — set it up once, forget it

```bash
kaeris init                    # generic config
kaeris init --preset next-intl # or: i18next, react-i18next, react-intl, vue-i18n, flutter-arb
```

Writes a `kaeris.json` in the current directory with `source`, `langs`, `keep`, `context`, `tone`,
`icu`, `only_new`, `out` and `format`. Once it exists, just run:

```bash
kaeris translate
```

with no arguments — the CLI reads `kaeris.json` from the current directory (or `--config PATH`,
passed before the subcommand) for anything you don't pass on the command line. **Precedence: CLI
flag > kaeris.json > built-in default** — so you can still override any one setting for a single
run, e.g. `kaeris translate --langs es` or `kaeris translate --no-icu`. `kaeris init` refuses to
overwrite an existing `kaeris.json` unless you pass `--force`.

## Quick start

```bash
# Translate a whole file into Spanish, French and Japanese
kaeris translate locales/en.json --langs es,fr,ja --out locales

# Only translate keys that are missing from the existing target files
kaeris translate locales/en.json --langs es,fr,ja --out locales --only-new

# Keep brand/product names verbatim in every language (glossary / do-not-translate)
kaeris translate locales/en.json --langs es,de --keep "KAERIS,GitHub,OpenRouter"
# (--glossary is the same flag, if that name is easier to remember)

# Tell the model what the app is, so ambiguous strings get the right sense
# ("Bank" the money kind, not the river kind; "Play" the game, not the theatre)
kaeris translate locales/en.json --langs de,ja --context "a mobile bank for teenagers"

# Translation QA — back-translation and meaning check for your review (verify.json lists the lines to read)
kaeris translate locales/en.json --langs de,ja --verify

# …and write the fixes it found into the files (free, offline; see "kaeris fix" below)
kaeris translate locales/en.json --langs de,ja --verify --apply-fixes

# How many characters would that use? Nothing is translated or spent
kaeris translate locales/en.json --langs de,ja --verify --dry-run

# List all supported languages
kaeris languages
```

Output files are written next to the source (or into `--out`), named by language:
`es.json`, `fr.json`, `ja.json` (or `values-es/strings.xml` for Android, etc.).

**Markdown and MDX** are translated as documents, not as key/value files: `guide.md` becomes
`de.md`, `ja.md` and so on. Prose is translated; everything that is not prose is not. Fenced and
indented code blocks, URLs, inline code, HTML and JSX tags, MDX `import`/`export` lines, heading
anchors (`{#install}`) and front-matter configuration (`slug`, `date`, `tags`) come back byte
for byte — only front-matter fields a reader actually sees (`title`, `description`, `summary`,
…) are translated. Link *text* is translated, link *targets* are not.

One limitation, said out loud: text inside a multi-line JSX component is preserved rather than
translated. Guessing at the boundaries of arbitrary JSX with regexes is how you ship a broken
page.

**Xcode String Catalogs are the exception**, because the format is: one `.xcstrings` file holds
every language, so there is one file to write, not one per language. `kaeris translate
Localizable.xcstrings --langs de,fr,ja` updates that catalog in place — your comments, your
extraction states, languages already in the file and anything marked `shouldTranslate: false`
are carried through untouched. Plurals are expanded to the categories each language actually
needs: an English `one`/`other` becomes `one`/`few`/`many`/`other` in Russian and all six forms
in Arabic, with `%lld` left exactly where it was.

## Authentication & tiers

| Tier | How | Chars per file | Languages per run |
|------|-----|----------------|-------------------|
| **Free** (anonymous) | no key | 10,000 | 8 |
| **Free API key** | `--key` with the free key from [kaeris.dev/docs](https://kaeris.dev/docs#key) | 50,000 | 8 |
| **Pro / Team/API** | `--key kaerisp_…` or `KAERIS_API_KEY` | 100k / 1M | all 46 |
| **Lifetime (BYOK)** | `--key` **and** `--openrouter-key sk-or-…` | 1,000,000, no monthly volume (you pay OpenRouter for tokens) | all 46 |

All 46 languages are available to everyone — the free tier translates up to **8 of them per
run**, so `--langs` with a longer list is refused before anything is charged or written.

```bash
export KAERIS_API_KEY=kaerisp_xxxxxxxx
export KAERIS_OPENROUTER_KEY=sk-or-v1-xxxx   # Lifetime/BYOK only
kaeris translate en.json --langs de,uk
```

Prefer the environment variable to `--key`: a key typed on the command line stays in your shell
history and shows in the process list.

Get a key at <https://kaeris.dev/pricing.html>. A free OpenRouter key: <https://openrouter.ai/keys>.

## `kaeris quota` — how much of the month is left

Paid plans include a monthly character volume (Pro 12M, Team/API 60M, counted as
characters × languages). Ask before a large run, rather than finding out from a refusal in the
middle of one:

```bash
kaeris quota
# ✓ Monthly volume: 1.2M of 12M used · 10.8M left · resets 2026-09-01
```

Every `kaeris translate` prints the same line when it finishes, and raises it to a warning once
**80%** of the month is spent — the point at which we also email the address the plan was bought
with, and again at **95%**. Lifetime (BYOK) has no monthly volume: you pay OpenRouter for tokens
directly.

## `--dry-run` — what a run will cost, before it starts

`kaeris quota` tells you what is left after a run. `--dry-run` tells you what a run will use,
before anything is spent:

```bash
kaeris translate locales/en.json --langs de,fr,ja --only-new --dry-run
```
```text
locales/en.json  (json, only new and edited; 240 keys, 11,820 characters)
  language  to translate  reused  up to date  charged
  de                   7       0         233      164
  fr                  12       3         225      410
  ja                 240       0           0   11,820

Total: 12,394 characters will be charged.
Pro: 10,800,000 of 12,000,000 left this month; after this run 10,787,606 left, resets 2026-12-01.
```

It reads your files, your lock (`--lock`), your project memory (`--tm`) and your settings, works
out which lines a real run would send to the model, and prices them the way the server does:
characters sent, per language. Lines the lock says are current and lines the memory already
has are not counted. With `--verify`, each language also costs twice the lines verify
reads (the back-translation and the independent check): what is translated now, plus remembered
lines that never passed verify; with `--verify-all`, the whole file.

- Nothing is translated and no file is sent. It asks the server only for your plan, the same
  call as `kaeris quota`. Without a key, or with the server out of reach, you still get the
  character count, and what is left is reported as unknown.
- It names what the server would refuse (a file over your plan's size, too many languages,
  more than is left this month). The exit code stays 0: the estimate is the result.
- `--json` prints the same as JSON on stdout (per file and language: keys, characters sent,
  reused, verify, charged), for CI.

## Project translation memory

`kaeris.lock` remembers a **key** and the hash of the English behind it. That is enough to
notice an edited string and nothing else: rename `nav.save` to `toolbar.save` and the identical
sentence is translated and charged again; the same "Save changes" in `common.json` and in
`checkout.json` is paid for twice; a colleague who just cloned the repo re-translates what you
already bought.

`kaeris-tm.json` remembers the **text**:

```
source string + the settings it was translated under  ->  { de: …, fr: … }
```

It is written next to your source file, and you commit it. Reuse then survives renamed keys,
strings moved between files, deleted-and-restored keys, and a fresh clone. Matching strings are
handed to the API as already-known and are **not charged** — the same reuse path the web app
uses.

The settings are part of the key deliberately: a string translated with `--tone formal`, a
different glossary or a different model is a different answer, so changing any of them
retranslates rather than serving something produced under rules that no longer apply. Use
`--no-tm` to ignore it for one run.

With `--verify`, the meaning check reads what this run translates: new and edited lines. A
remembered line is read until it passes once (`kaeris-tm.json` marks it, `"v": {"de": true}`),
then it is trusted and not charged again. A line the check flagged, or whose translation changed,
loses the mark and is read on the next run. Placeholder, ICU and tag checks are free and still
run on every line, remembered or not. `--verify-all` reads everything again.

## What the run actually did

Every `kaeris translate` ends with two lines about the run itself:

```
→ Run: openai/gpt-6-luna · Pro · 3/3 languages · 412 strings · 18.4s
     124k charged · 38k reused free  ·  icu, verify, formal
```

`--receipt run.json` writes the same thing as JSON: model, plan, languages requested against
languages actually delivered, characters charged against characters reused for free, the
settings that were applied, and — the useful one — **which of your glossary terms were really
present in the source**. A term that was never in the file was never going to be kept, and
nothing used to say so; the run just looked clean.

It carries counts and your own settings, never your strings, so it is safe to commit next to
the translations and read in a diff.

## `kaeris fix` — apply what `--verify` found

`--verify` writes `verify.json`: every line to read, why, and — where the meaning check had one —
a corrected translation. `kaeris fix` writes those into your translated files:

```bash
kaeris fix --from locales/verify.json            # apply
kaeris fix --from locales/verify.json --dry-run  # only say what would change
```

- A suggested fix is written only if our checks pass it: every placeholder, tag and ICU branch,
  the line breaks and the `*emphasis*` of the source are still there, and nothing new is wrong.
- A line that lost a line break or tore an emphasis, with no fix suggested, gets them put back
  where they fall in the source.
- It prints what was applied and, for every line it left alone, why. A line that needs a new
  translation is named, not re-translated: that would be a model call, charged to your quota
  like any translation. Applying is free and makes no network call.
- The file keeps its layout, encoding and line endings; running it twice changes nothing.
- What it did is recorded in `verify.json` for anything that has to read it (a CI step, an
  agent), so nobody parses the log: `_applied` and `_skipped`, each a list of
  `{"lang", "key", "how", "why"}`. `how` is `"fix"` (the meaning check's line) or `"structure"`
  (line breaks / emphasis put back), and null for a skipped line; `why` says what was done or
  why the line was left alone. `_summary.by_lang` holds `lines` and `to_review` per language.
  `kaeris fix --json` prints the same on stdout (`applied`, `skipped`, `counts`); `--dry-run`
  writes nothing, `verify.json` included.

```json
"_applied": [{"lang": "de", "key": "save", "how": "fix", "why": "suggested fix"}],
"_skipped": [{"lang": "de", "key": "greet", "how": null,
              "why": "the suggested fix was not applied: lost placeholder {name}"}],
"_summary": {"lines": 40, "to_review": 3, "error": 2, "review": 1, "unchecked": 0,
             "by_lang": {"de": {"lines": 40, "to_review": 3, "error": 2, "review": 1, "unchecked": 0}}}
```

## `kaeris check` — the i18n firewall

A **local, static, no-API** check: compares your source file against each target locale and
fails (non-zero exit) if anything's missing or broken — the kind of check no other i18n tool
gates a merge on.

```bash
kaeris check                              # reads source/langs/out from kaeris.json
kaeris check --source en.json --langs de,fr,ja --out locales
kaeris check --strict                     # also fail on extra/stale keys in a target
kaeris check --json                       # machine-readable output for CI/agents
kaeris check --ci                         # same exit-code contract; a stable, named entry point for pipelines
```

It runs the same deterministic quality detectors that power the web app — offline, with no
account and no network. **RED** problems fail the build; **YELLOW** are advisory (pass
`--strict` to fail on those too).

**RED — breaks the build (non-zero exit):**

- **Missing keys** — a key exists in the source but not in a target (untranslated).
- **Placeholder mismatch** — a target string's placeholders (`{name}`, `%s`, `%d`, `{{x}}`,
  `${x}`, ICU `{count, plural, ...}`) don't match the source's — dropped, renamed, or
  hallucinated placeholders crash or silently drop data at runtime.
- **Number drift** — a number in the source changed or vanished in the translation
  (`Delete 5 files` → `Delete 50 files`).
- **Broken entities / encoding** — double-encoded (`&amp;amp;`) or malformed `\u` escapes.
- **Lost inline tags** — an HTML/markup tag (`<b>`, `</b>`, …) present in the source is gone.
- **ICU / CLDR plural completeness** — the target language is missing a plural category its
  CLDR rules require.
- **Glossary term dropped** (opt-in) — a term listed under `keep` in kaeris.json (or passed as
  `kaeris check --glossary "KAERIS,GitHub"`) isn't carried into the translation.
- **Glossary term translated another way** (opt-in) — a term of `kaeris-glossary.json` is in the
  source, and the translation does not use the word agreed for that language (see below).

**YELLOW — advisory warnings (`--strict` to fail):**

- **Empty translation** — the target string is empty (or only whitespace) while the source has text. Projects often keep empty strings on purpose (a fallback language fills them), so this is a warning, not a failure.
- **Untranslated leftover** — the target still reads as the source language.
- **Register / casing drift**, **cross-key inconsistency**, and **UI-overflow risk** (a
  translation much longer than its source, relative to that language's typical expansion).
- **Extra/stale keys** — present in a target but not the source.

Exit codes: `0` clean, `1` a RED problem was found (or a YELLOW under `--strict`), `2` bad
usage (source not found, no languages given, etc.) — drop it straight into CI:

```yaml
- run: kaeris check --source locales/en.json --langs es,fr,de,ja --out locales
```

### Glossary: one word per term, per language

`--keep` says "never translate this". When a term must be translated, but always the same way,
put it in `kaeris-glossary.json` next to `kaeris.json`:

```json
{"terms": [
  {"term": "workspace", "pos": "noun", "note": "the shared project area, not a desk",
   "translations": {"de": "Arbeitsbereich", "fr": "espace de travail", "ru": "рабочее пространство"}},
  {"term": "KAERIS", "keep": true}
]}
```

`kaeris translate` sends the terms with the strings, and `kaeris check` reads every translation
against them:

```text
$ kaeris check --source en.json --langs de,fr,ru
→ Glossary: 1 term(s), 1 kept as is
✓ de: complete & placeholder-safe
✗ fr: 1 translation fault(s):
    open: glossary: "workspace" should be "espace de travail"
! ru: 1 warning(s):
    leave: glossary: "workspace" should be "рабочее пространство" — not found, not even as an inflected form; check this line
✗ 1 translation fault
```

Here `en.json` is `{"open": "Open the workspace", "leave": "Leave the workspace"}`; German
said «Arbeitsbereich» both times, French said «bureau» once, Russian said «рабочей области».

- In the source a term counts as a whole word: `Pro` is not found in "product". Set
  `"case_sensitive": true` when the case matters. English plurals count ("workspaces").
- In the translation an inflected form passes: every word of the agreed translation is matched
  by its stem, so «Name des Arbeitsbereichs» and «в рабочем пространстве» are fine.
- A miss is an error in languages whose words keep their shape (French, Spanish, Italian,
  Portuguese, Japanese, Chinese…). In languages whose words change shape or join into compounds
  (Russian, Ukrainian, Polish, Finnish, German, Arabic…) it is a warning: there "not found" can
  still be correct grammar, so a person decides. `--strict` fails on warnings too.
- A list gives accepted variants: `"fr": ["se connecter", "connexion"]`; the first is the one
  the model is asked for.
- `keep: true` entries work like `--keep`.
- With `--only-new`, changing a term re-translates only the strings that use it and do not
  already say it the new way; `kaeris.lock` remembers what each term asked for. Strings from the
  project memory that say it the old way are not reused.

The file is found as `--glossary-file PATH`, then `"glossary_file"` in `kaeris.json`, then
`./kaeris-glossary.json`. A file that cannot be read or has a broken entry stops the run with
exit code 2 and says which entry.

### Every format `translate` writes

`check` reads the same formats `translate` does — JSON, YAML, `.strings`, `.xcstrings`, `.po`,
ARB (its `@`-metadata is ignored), Android XML, CSV, XLIFF, `.properties`, `.resx`, Fluent and
Markdown — and finds each language's file where `translate` writes it (`de.yml`, `app_de.arb`,
`res/values-de/strings.xml`, `de.lproj/Localizable.strings`, …; `--pattern` for any other
layout). The same checks run on all of them.

| Format | Compared by | Plurals |
|---|---|---|
| JSON, ARB, YAML | dotted key | ICU `{count, plural, …}` checked against the language's CLDR forms |
| `.strings`, `.properties`, `.resx` | key | none in the format |
| `.po` | `msgid` (+ `msgctxt`) | each `msgstr[n]`; the header must declare as many forms as the language needs |
| Android XML | `name` | `<plurals>` read as one ICU plural, so Japanese's single form is complete and Russian's two is not |
| XLIFF 1.2 | trans-unit `id`: `<source>` against `<target>` | — |
| CSV | key, one column per language (`key,en,de,…`), in the file itself and in the `translations/<name>.csv` that `translate` writes; a blank cell is untranslated | — |
| Fluent | message, `.attribute`, selector variant | a plural selector is checked like ICU |
| Markdown / MDX | the numbered blocks of the document (no keys); code, URLs and tags must survive | — |

Not covered: `.stringsdict` (use a String Catalog), XLIFF 2.0, YAML with anchors, aliases, tags or
several documents (named, not guessed at: exit code 2), and Fluent placeables inside a plural arm
other than `{ $var }`. With `--sarif`, findings in formats other than JSON/ARB are placed on
line 1 of the file.

A `.xcstrings` and a CSV are checked differently because the format is different: the source
language and every translation live in one file, so there is nothing to open per language — both
sides come out of the same parse.

```bash
kaeris check --source Localizable.xcstrings --langs de,ru,ar
```

Worth running even if you never translate with us: Xcode does not fail a build for a missing
translation, and it says nothing at all when a Russian plural carries only the two forms English
had — which reads wrong to every user whose count ends in 2, 3 or 4. Entries marked
`shouldTranslate: false` are not demanded, and a key with no localization at all counts as its
own source string, the way Xcode treats it.

### Findings in your pull request

```bash
kaeris check --source locales/en.json --langs de,fr --out locales --sarif kaeris.sarif
```

Writes a SARIF 2.1.0 report (kaeris ≥ 0.2.12). Upload it with
`github/codeql-action/upload-sarif` and every finding becomes an annotation on the exact line
of the locale file, inside the diff — the GitHub Action takes it as `sarif-file`. Written even
when the run is clean, so the upload step never fails on a missing file. Inline annotations are
free on public repositories; private ones need GitHub Advanced Security.

## CI/CD (GitHub Actions) — translations as PRs, plus an i18n firewall

Add two workflows and translations arrive as a normal pull request — reviewed and merged like
any other code change, no separate translation tool to context-switch into — while a second
workflow blocks merges that leave a locale incomplete or placeholder-broken.

The [`kaeris-translate`](https://github.com/kaeris-io/kaeris-action/blob/main/action.yml) composite action has
two modes:

- **`mode: translate`** (default) — runs `kaeris translate`, then a `kaeris check --json` before
  *and* after so it can report real numbers, and exposes outputs: `changed` (`"true"` only if a
  locale/lockfile actually changed — gate your PR step on this), `summary` (a markdown report:
  keys translated, a per-language matrix, placeholder-loss count, overflow-warning count when
  available), `missing` and `placeholder-issues` (totals).
- **`mode: check`** — runs `kaeris check --ci` (no API call). Non-zero exit fails the step (and
  the job) if any target locale is missing keys or has a placeholder mismatch — the i18n firewall.

**1. Auto-PR on every push** — see
[`translate.example.yml`](https://github.com/kaeris-io/kaeris-cli/blob/main/.github/workflows/translate.example.yml):

```yaml
- uses: kaeris-io/kaeris-action@v2
  id: kaeris
  with:
    mode: translate
    source: locales/en.json
    languages: es,fr,de,ja
    out: locales
    only-new: "true"

- uses: peter-evans/create-pull-request@v6
  if: steps.kaeris.outputs.changed == 'true'
  with:
    title: "i18n: new translations"
    body: ${{ steps.kaeris.outputs.summary }}
    branch: kaeris/i18n-updates
    add-paths: locales/**
```

**2. Firewall on every PR** — see
[`i18n-check.example.yml`](https://github.com/kaeris-io/kaeris-cli/blob/main/.github/workflows/i18n-check.example.yml):

```yaml
- uses: kaeris-io/kaeris-action@v2
  with:
    mode: check
    source: locales/en.json
    languages: es,fr,de,ja
    out: locales
```

Add that job to branch protection as a required status check and a PR simply cannot merge with
an untranslated or placeholder-broken locale.

## How incremental mode works

`--only-new` (JSON) parses your source and each existing translation, then translates a key if
it's **missing** from the target **or its source text changed** since the last run — and merges
the results back, preserving your existing translations and any non-string values (numbers,
booleans). No more re-translating (and re-paying for) the whole file every time you add one
string, and no more silently-stale translations when you edit an existing English string.

Change detection is powered by `kaeris.lock` — a small JSON file written next to your source
file after every incremental run. It records a SHA-256 of each source string **plus the
settings that produced it**: your tone, your glossary, your app context, and the model. Change any of them and
the whole locale is re-translated, so it never ends up a mix of two tones or two models. Every
tier runs the same model (GPT-6 Luna), so a plan change alone never forces a re-translation —
the lock records the model anyway, so the day we change it, you find out from a full re-run
rather than from a locale quietly built by two models. The terms of `kaeris-glossary.json` are
the exception: a changed term re-translates only the strings that use it (see "Glossary" above).
It's what lets
`--only-new` notice an edited key even though it's still present in the target; without it, a
plain "is this key missing?" check would skip the key and leave the old (now wrong) translation
in place. Commit `kaeris.lock` alongside your source file so the check works across machines/CI.
Override its location with `--lock PATH` or `"lock"` in `kaeris.json` (default: `kaeris.lock`
next to the source).

## All flags

Anything not passed falls back to `kaeris.json`, then to the built-in default.

**`kaeris translate [FILE]`**

| Flag | What it does |
|------|--------------|
| `--langs es,fr,ja` | target languages (`kaeris languages` lists all 46) |
| `--out DIR` | where to write; default is next to the source |
| `--only-new` / `--no-only-new` | translate only new/changed keys (JSON, ARB) — or force a full run |
| `--lock PATH` | where `kaeris.lock` lives (default: next to the source) |
| `--assume-current` | first incremental run on a project already translated: trust the existing target strings instead of re-translating everything |
| `--keep "A,B"` / `--glossary "A,B"` | terms to carry over verbatim (two names for one flag) |
| `--glossary-file PATH` | terms and how each language must say them (default `./kaeris-glossary.json`) |
| `--context "…"` | one line about the app, so ambiguous words get the right sense |
| `--tone neutral\|formal\|casual` | tone of voice |
| `--model openai/gpt-4.1` | Lifetime (BYOK): the OpenRouter model to translate with; `"models": {"de": "…"}` in `kaeris.json` sets one per language. Changing it re-translates under `--only-new`; other plans get an error naming the plan |
| `--icu` / `--no-icu` | hint that strings use ICU MessageFormat, so plurals/select survive |
| `--source-lang en` | base language code; also detects `locales/<lang>/<namespace>.json` layouts |
| `--verify` | back-translation and meaning check for your review; prints `⚠ meaning:` lines and writes `verify.json` |
| `--verify-all` | with `--verify`: read every line again, including lines the project memory already checked |
| `--back-lang en` | language to back-translate into with `--verify` |
| `--apply-fixes` | with `--verify`: write the fixes into the translated files, only where our checks pass them; same as `kaeris fix` afterwards |
| `--translate-service-keys` | also translate keys that start with `_` (`"_status"`); by default they are copied through untouched in JSON, YAML and ARB |
| `--digits native\|latin` | digits in the result: `native` (default) keeps what the language writes (fa ۲۰۲۷, bn ২০২৭), `latin` rewrites them to 0-9; placeholders are left as they are |
| `--lang-case keep\|lower` | case of the language code in new file and folder names (`zh-Hant.json` or `zh-hant.json`); a file or folder that already exists in another case is always written under its own name |
| `--jobs N` | jobs kept running at once (default 4): several files side by side, and with `--only-new` the languages of one file; results are still written and reported in order. `--jobs 1` sends one at a time |
| `--quiet` | no progress output |
| `--receipt PATH` | write a JSON record of the run — counts and settings only, no strings |
| `--tm PATH` | project translation memory file (default `kaeris-tm.json` next to the source) |
| `--no-tm` | ignore and do not update the project translation memory |
| `--dry-run` | estimate only: characters this run would be charged per file and language, and what is left on your plan; translates and sends nothing |
| `--json` | with `--dry-run`: the estimate as JSON on stdout |

**`kaeris check`**

| Flag | What it does |
|------|--------------|
| `--source FILE`, `--langs`, `--out DIR` | what to compare (or leave them to `kaeris.json`) |
| `--pattern "{lang}.json"` | target filename pattern — set it if your locales are named `de/common.json`, `strings.de.json`, … |
| `--strict` | also fail on extra/stale keys and YELLOW warnings |
| `--json` | machine-readable result |
| `--ci` | same exit-code contract, a stable entry point for pipelines |
| `--glossary "A,B"` | fail a target that dropped one of these terms |
| `--glossary-file PATH` | fail a target that translated a term of this file another way (default `./kaeris-glossary.json`) |
| `--sarif FILE` | write a SARIF 2.1.0 report (needs kaeris ≥ 0.2.12) |

**`kaeris init`** — `--preset NAME`, `--force` (overwrite an existing `kaeris.json`).
**`kaeris quota`** — no flags; reads the key from `--key` or `KAERIS_API_KEY` (needs kaeris ≥ 0.2.15).
**Global** — `--key`, `--openrouter-key`, `--config PATH` (before the subcommand), `--version`.

## Environment variables

- `KAERIS_API_KEY` — API key
- `KAERIS_OPENROUTER_KEY` — OpenRouter key (BYOK)
- `KAERIS_API_URL` — override the API base URL
- `NO_COLOR` — disable coloured output
- `KAERIS_NO_UPDATE_CHECK=1` — turn off the once-a-day "new version available" line on stderr (it is already silent in CI, in `--json` mode and when stderr is not a terminal)

## License

MIT
