From an Unconfigured Repository to a Gated CI Job
The outputs below come from running the sequence against a Vue project with four locales in one layer. Your numbers differ; the shapes do not.
Install the CLI
npm install -g @the-i18n-kit/cli
Or skip the install and prefix every command with npx -y @the-i18n-kit/cli@latest
— the binary is named the-i18n-cli either way, and that is the name used
throughout this page.
the-i18n-cli is also published unscoped, under the name the binary has. Install
the scoped @the-i18n-kit/cli; the unscoped package publishes from the same
source at the same versions but stops receiving updates.1. Generate a Config From Detection
Run init with --dryRun first. It reports the config it would write and
touches no disk:
the-i18n-cli init --dryRun
{
"config": {
"$schema": "https://raw.githubusercontent.com/fabkho/the-i18n-kit/main/packages/mcp/schema.json",
"context": "",
"glossary": {},
"translationPrompt": "",
"localeNotes": {}
},
"detected": {
"adapter": "vue",
"label": "Vue",
"confidence": 5,
"derivesLocaleConfig": true
},
"configPath": ".i18n-mcp.json",
"written": false
}
Read detected before you write anything. If adapter is not the framework you
expected, detection explains how to pin one rather than
working around a bad guess for the rest of this page.
derivesLocaleConfig: true is why the generated config names no locales and no
directories: this adapter reads them out of the project's own files — a Vite
plugin, a next-intl routing file, a Nuxt config — so restating them by hand
would create two answers that can disagree. When no framework adapter matches,
init probes for locale directories itself and writes localeDirs and
defaultLocale into the file instead — that is the generic path, and
derivesLocaleConfig is false on it.
Then write it:
the-i18n-cli init
ℹ Wrote .i18n-mcp.json (Vue, confidence 5)
init is non-interactive and refuses to overwrite an existing .i18n-mcp.json
without --force, so running it in a configured repository cannot cost you a
glossary. Full flags: init.
Verify: .i18n-mcp.json exists at your project root and contains a
$schema pointer.
2. Take a Health Check
status reports coverage per locale and per layer — a layer being a scoped
locale directory: a Nuxt layer, a workspace package, a shared UI library, or one
app in a monorepo. A single-app project has one layer, named root.
the-i18n-cli status
{
"locales": [
{ "code": "en-US", "total": 12, "translated": 12, "missing": 0, "empty": 0, "completion": 100 },
{ "code": "es-ES", "total": 12, "translated": 9, "missing": 3, "empty": 0, "completion": 75 },
{ "code": "fr-FR", "total": 12, "translated": 11, "missing": 1, "empty": 0, "completion": 91.7 }
],
"layers": [
{ "layer": "root", "total": 36, "translated": 32, "missing": 4, "empty": 0, "completion": 88.9 }
],
"summary": {
"referenceLocale": { "code": "en-US", "language": "en-US", "file": "en-US.json" },
"layersScanned": ["root"],
"protectedLocales": [],
"totalKeys": 36,
"translatedKeys": 32,
"missingKeys": 4,
"emptyKeys": 0,
"completionPercent": 88.9
}
}
Output is JSON whenever stdout is not a terminal, so the-i18n-cli status | jq .summary works with no flag. Logs go to stderr, which is what keeps the pipe
parseable. Pass --json to force it in a terminal too.
Check two fields:
referenceLocaleis the locale everything is compared against. Some adapters derive it and some cannot: the Vue and React adapters take the alphabetically first locale they find, which is how a project with English source strings ends up measured againstde-DE.protectedLocalesis empty until you say otherwise, so every locale is a candidate for machine translation.
Fix both in the file init wrote in step 1:
{
"$schema": "https://raw.githubusercontent.com/fabkho/the-i18n-kit/main/packages/mcp/schema.json",
"defaultLocale": "en-US",
"protectedLocales": ["de-DE"]
}
Run status again and both fields follow. A protected locale drops out of the
counted set, so the completion percentage moves — that is the confirmation the
file was read.
Verify: summary.referenceLocale.code is the locale your developers write
strings in, and summary.protectedLocales names every locale a human
maintains.
The Other Half of the Health Check
status measures locale files against each other. It cannot see a key your code
calls that no locale file defines — that key renders as a raw string in
production, and it is the failure your users notice first. check finds those:
the-i18n-cli check
{
"undefinedKeys": [
{
"key": "booking.cancelled",
"app": "default",
"searchedLayers": ["root"],
"usages": [{ "file": "src/App.vue", "line": 17 }]
}
],
"uncertainKeys": [],
"summary": {
"usedKeysChecked": 5,
"undefinedCount": 1,
"uncertainCount": 0,
"filesScanned": 2,
"locale": "en-US"
},
"gatesTripped": [
{
"name": "undefined-keys",
"counter": "undefinedCount",
"direction": "above",
"threshold": 0,
"observed": 1
}
]
}
That run exits 2, not 1. Exit 2 means a gate tripped: the command worked and
found what you asked it to fail on. Exit 1 would mean the run itself fell over
and its counters say nothing about your project. check is the one command whose
gate is always on and needs no flag, because a key that renders raw is a defect
rather than a threshold you opt into caring about. The full table is on the
CLI reference.
uncertainKeys holds references the static scanner could not resolve —
template literals and other dynamically built keys. They are reported and never
counted as findings, so a dynamic key cannot fail your build on a guess.
Verify: check exits 0 on a clean project, and exits 2 listing file and
line when you delete a key that is still called.
3. Translate the Missing Keys
Preview before spending anything. --dryRun reports what would be translated
and calls no provider at all:
the-i18n-cli translate --dryRun
{
"summary": {
"mode": "dry-run",
"totalTranslated": 0,
"totalFailed": 0,
"totalSkipped": 0,
"totalWouldTranslate": 4,
"layers": ["root"],
"referenceLocale": { "code": "en-US", "language": "en-US", "file": "en-US.json" },
"targetLocales": [
{ "code": "es-ES", "language": "es-ES", "file": "es-ES.json" },
{ "code": "fr-FR", "language": "fr-FR", "file": "fr-FR.json" }
]
}
}
targetLocales is the check that step 2 landed: the locale you protected is not
in it. Without --layer, every locale-backed layer is translated in one run and
the totals are aggregated across them.
Now the real run. Name a provider, a model and a key:
the-i18n-cli translate \
--provider google \
--model gemini-2.5-flash \
--apiKey <your-api-key>
Substitute your own key for <your-api-key>, or leave --apiKey off and export
OPENAI_API_KEY, ANTHROPIC_API_KEY or GEMINI_API_KEY to match your
provider. --provider accepts openai, anthropic and google; --model is
required whenever --provider is set. For a gateway, a proxy or a self-hosted
model speaking one of those protocols, add --baseUrl — it falls back to
I18N_BASE_URL and then to providerBaseUrl in your config file.
Leave --provider off entirely and nothing is translated: the run reports the
keys as skipped with the reason no-provider. That is the mode an agent
drives through the MCP server, where the agent's
own model does the translating.
Every key is accounted for in the result — translated, failed with a reason
from a fixed set, skipped with a reason — and provider output is validated
before it is written: placeholder parity per plural variant, and plural
variant-count parity with the source. A value that fails validation lands in
failed rather than in your locale file. Flags:
translate.
--failOnFailed to exit 2 when
any key failed.Verify: status reports a higher completionPercent than it did in step 2,
and your protected locale is untouched.
4. Gate a Pipeline on It
The commands above carry their own pass/fail decision in their exit code, so a CI job runs one command and needs no JSON parsing to decide an outcome.
GitHub Actions
name: i18n
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Fail on keys that would render raw
run: npx -y @the-i18n-kit/cli@latest check
- name: Fail when coverage drops below 90%
run: npx -y @the-i18n-kit/cli@latest status --failUnder 90
Both steps fail the job with exit 2 on findings. Drop the second step if you do
not want a coverage floor yet — check alone is the one gate worth having on
day one, and it costs no API key.
To have the pipeline fix what it finds, add the action that ships with the kit.
It installs the CLI and the SDK for your provider, runs translate, and opens a
pull request with what it wrote:
name: i18n translate
on: pull_request
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: fabkho/the-i18n-kit@main
with:
provider: google
model: gemini-2.5-flash
api_key: ${{ secrets.GEMINI_API_KEY }}
layer: root
layer is required — pass the layer name status reported under
layersScanned. Every input, with its default and whether it is required, is on
the action reference.
GitLab CI
The kit ships a template with three reusable jobs. Include it and extend the one you want:
include:
- remote: 'https://raw.githubusercontent.com/fabkho/the-i18n-kit/main/gitlab-ci.yml'
i18n-check:
extends: .i18n-check
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
.i18n-check allows exit code 2 by default, which keeps findings informational
— yellow, not red — while a scan that genuinely fell over still fails the job.
Setting allow_failure: false, as above, turns findings into a hard gate.
The job writes a GitLab Code Quality report, which puts each finding on the merge request diff. The widget shows the difference between the merge request's report and the newest one from the default branch, so the second rule above is required: with no default-branch baseline to compare against, the widget stays blank.
Verify: open a merge request that calls a key you have not defined. The job fails, and the finding appears on the diff at the line that calls it.
Where to Go Next
- Configuration — the two files kit policy can be declared in, and how each is found. A glossary and tone notes are what make provider translations sound like your product.
- Monorepos — what changes when locales live in several layers and several apps consume them, and why usage is counted per consuming app.
- Set up the MCP server — the same operations, driven by your agent instead of your terminal.
- CLI reference — every command, generated from its own definition.
Limits
checkandremove-orphansread your source statically and line by line. Keys built at runtime cannot be verified, so they are reported as uncertain and never treated as findings. That is a floor on what these commands can prove, not a bug to be configured away.- Coverage is measured against a reference locale. If that locale is itself
missing a key, nothing here reports the key as missing anywhere —
checkis what catches it, from the calling side. - The
translategate is opt-in for a reason worth knowing before you turn it on:--failOnFailedfails a job that already committed the keys that succeeded.