@the-i18n-kit/cli 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +368 -0
- package/dist/_shared-Dv1EW2gy.js +248 -0
- package/dist/_shared-Dv1EW2gy.js.map +1 -0
- package/dist/add-BvXrJkqZ.js +40 -0
- package/dist/add-BvXrJkqZ.js.map +1 -0
- package/dist/bin-Cs-dvpEC.d.ts +1 -0
- package/dist/bin.js +11 -0
- package/dist/bin.js.map +1 -0
- package/dist/check-9dcNfHtC.js +49 -0
- package/dist/check-9dcNfHtC.js.map +1 -0
- package/dist/cli-CiX4llY1.js +82 -0
- package/dist/cli-CiX4llY1.js.map +1 -0
- package/dist/config/framework/stubs/next-intl-routing-DEwC7Bbk.d.ts +19 -0
- package/dist/config/framework/stubs/next-intl-routing-DEwC7Bbk.d.ts.map +1 -0
- package/dist/config/framework/stubs/next-intl-routing.js +22 -0
- package/dist/config/framework/stubs/next-intl-routing.js.map +1 -0
- package/dist/config/framework/stubs/unplugin-vue-i18n-D9yaDzRk.d.ts +25 -0
- package/dist/config/framework/stubs/unplugin-vue-i18n-D9yaDzRk.d.ts.map +1 -0
- package/dist/config/framework/stubs/unplugin-vue-i18n.js +26 -0
- package/dist/config/framework/stubs/unplugin-vue-i18n.js.map +1 -0
- package/dist/define-config-4ubtfvUy.js +14 -0
- package/dist/define-config-4ubtfvUy.js.map +1 -0
- package/dist/define-config-Dhg13592.d.ts +142 -0
- package/dist/define-config-Dhg13592.d.ts.map +1 -0
- package/dist/define-config-lQrIpPSK.d.ts +2 -0
- package/dist/define-config.d.ts +1 -0
- package/dist/define-config.js +2 -0
- package/dist/detect-Dr2skxeL.js +17 -0
- package/dist/detect-Dr2skxeL.js.map +1 -0
- package/dist/empty-p0wct8Hk.js +36 -0
- package/dist/empty-p0wct8Hk.js.map +1 -0
- package/dist/errors-coI1dhw1.js +33 -0
- package/dist/errors-coI1dhw1.js.map +1 -0
- package/dist/find-duplicates-DuI8niF7.js +31 -0
- package/dist/find-duplicates-DuI8niF7.js.map +1 -0
- package/dist/get-BskDC2wT.js +41 -0
- package/dist/get-BskDC2wT.js.map +1 -0
- package/dist/index-BtSA6-iT.d.ts +1130 -0
- package/dist/index-BtSA6-iT.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/init-D7r6iX3q.js +33 -0
- package/dist/init-D7r6iX3q.js.map +1 -0
- package/dist/list-dirs-BHk38JXZ.js +17 -0
- package/dist/list-dirs-BHk38JXZ.js.map +1 -0
- package/dist/missing-0gSZKTgt.js +51 -0
- package/dist/missing-0gSZKTgt.js.map +1 -0
- package/dist/operations-BCJd7in3.js +6464 -0
- package/dist/operations-BCJd7in3.js.map +1 -0
- package/dist/php-reader-DcgOAhTw.js +2 -0
- package/dist/php-reader-DuZ0Hyl_.js +32 -0
- package/dist/php-reader-DuZ0Hyl_.js.map +1 -0
- package/dist/providers-BAwPjeCy.js +482 -0
- package/dist/providers-BAwPjeCy.js.map +1 -0
- package/dist/remove-DR_mRiMA.js +41 -0
- package/dist/remove-DR_mRiMA.js.map +1 -0
- package/dist/remove-orphans-CcA3XQHJ.js +57 -0
- package/dist/remove-orphans-CcA3XQHJ.js.map +1 -0
- package/dist/rename-CWLfThC1.js +45 -0
- package/dist/rename-CWLfThC1.js.map +1 -0
- package/dist/scaffold-BahXKbo9.js +39 -0
- package/dist/scaffold-BahXKbo9.js.map +1 -0
- package/dist/scan-CTxs3kl7.js +31 -0
- package/dist/scan-CTxs3kl7.js.map +1 -0
- package/dist/search-BonEgmSt.js +49 -0
- package/dist/search-BonEgmSt.js.map +1 -0
- package/dist/status-C_5Khw14.js +45 -0
- package/dist/status-C_5Khw14.js.map +1 -0
- package/dist/stdout-guard-sJHFWVem.js +20 -0
- package/dist/stdout-guard-sJHFWVem.js.map +1 -0
- package/dist/translate-CnXvBi5O.js +92 -0
- package/dist/translate-CnXvBi5O.js.map +1 -0
- package/dist/translate-key-DwO9XZMc.js +73 -0
- package/dist/translate-key-DwO9XZMc.js.map +1 -0
- package/dist/update-HR8MvPlI.js +40 -0
- package/dist/update-HR8MvPlI.js.map +1 -0
- package/dist/write-CAD9xQDy.js +47 -0
- package/dist/write-CAD9xQDy.js.map +1 -0
- package/package.json +86 -0
package/README.md
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# the-i18n-cli
|
|
2
|
+
|
|
3
|
+
[](https://npmjs.com/package/the-i18n-cli)
|
|
4
|
+
[](https://github.com/fabkho/the-i18n-kit/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
CLI and core library for managing i18n translation files — supports Nuxt, Laravel, Vue, React/Next.js, and any project with JSON or PHP locale files.
|
|
7
|
+
|
|
8
|
+
Read, write, search, rename, and remove translation keys across all locales and layers from your terminal. Auto-detects your framework, discovers monorepo structures, and handles the file I/O.
|
|
9
|
+
|
|
10
|
+
Part of [the-i18n-kit](https://github.com/fabkho/the-i18n-kit) monorepo. For MCP server usage, see [the-i18n-mcp](https://www.npmjs.com/package/the-i18n-mcp).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Global install
|
|
16
|
+
npm install -g the-i18n-cli
|
|
17
|
+
|
|
18
|
+
# Or use directly with npx
|
|
19
|
+
npx the-i18n-cli --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
the-i18n-cli init # Create .i18n-mcp.json from framework detection
|
|
26
|
+
the-i18n-cli missing # Find missing translations
|
|
27
|
+
the-i18n-cli search --query "save" # Search keys and values
|
|
28
|
+
the-i18n-cli write --layer root --translations '{"common.btn.ok": {"en": "OK", "de": "OK"}}'
|
|
29
|
+
the-i18n-cli translate-key --layer root --key common.btn.save --sourceLocale en-US --sourceValue "Save"
|
|
30
|
+
the-i18n-cli translate --layer root --provider openai --model gpt-4o-mini # Auto-translate missing keys
|
|
31
|
+
the-i18n-cli remove-orphans # Find orphan keys (dry-run by default)
|
|
32
|
+
the-i18n-cli check # Find used-but-undefined keys (non-zero exit — CI gate)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
| Command | Description |
|
|
38
|
+
|---------|-------------|
|
|
39
|
+
| `init` | Create a schema-valid `.i18n-mcp.json` from framework detection. Non-interactive; refuses to overwrite without `--force` |
|
|
40
|
+
| `get` | Read translation values for specific keys |
|
|
41
|
+
| `write` | Write translation keys (`add` / `update` / `upsert` mode, default: `upsert`) |
|
|
42
|
+
| `add` | Add new translation keys (skips keys that already exist) |
|
|
43
|
+
| `update` | Update existing keys (skips keys that do not exist) |
|
|
44
|
+
| `missing` | Find keys missing in target locales |
|
|
45
|
+
| `status` | Translation coverage per locale and per layer, with one overall percentage |
|
|
46
|
+
| `search` | Search keys and values |
|
|
47
|
+
| `remove` | Remove keys from all locale files in a layer |
|
|
48
|
+
| `rename` | Rename/move a key across all locale files |
|
|
49
|
+
| `translate` | Find missing translations and translate them via LLM (see Translation Modes). Also available as `translate-missing`, matching the MCP tool name |
|
|
50
|
+
| `translate-key` | Translate one source key into target locales; can overwrite stale values |
|
|
51
|
+
| `remove-orphans` | Find and remove keys not referenced in source code (dry-run by default) |
|
|
52
|
+
| `check` | Find keys referenced in code but defined in no consumed locale layer — the inverse of `remove-orphans`. Exits non-zero when any are found, so it can gate CI. Dynamically built keys are reported as uncertain, never as hard findings |
|
|
53
|
+
| `find-duplicates` | Find keys defined in both a shared layer and a consuming child layer (with divergence detection) |
|
|
54
|
+
| `scan` | Find where translation keys are referenced in source, with file and line — use before renaming or removing a key |
|
|
55
|
+
| `scaffold` | Create empty locale files for new languages |
|
|
56
|
+
|
|
57
|
+
Run `the-i18n-cli <command> --help` for per-command options.
|
|
58
|
+
|
|
59
|
+
### Common Flags
|
|
60
|
+
|
|
61
|
+
| Flag | Description |
|
|
62
|
+
|------|-------------|
|
|
63
|
+
| `-d, --projectDir <dir>` | Project directory (default: cwd) |
|
|
64
|
+
| `--json` | Output as JSON (default when piped) |
|
|
65
|
+
| `--dryRun` | Preview changes without writing |
|
|
66
|
+
| `--output-file <path>` | `missing` / `remove-orphans` / `check`: write the full report to a file, return only a summary |
|
|
67
|
+
|
|
68
|
+
### Exit Codes and CI Gates
|
|
69
|
+
|
|
70
|
+
| Code | Meaning |
|
|
71
|
+
|------|---------|
|
|
72
|
+
| `0` | The run succeeded and no gate tripped |
|
|
73
|
+
| `1` | The run itself failed — bad API key, unreadable project, a translate run that translated nothing |
|
|
74
|
+
| `2` | The run succeeded but a gate tripped: findings exist and the tool worked |
|
|
75
|
+
|
|
76
|
+
Separating `1` from `2` lets a pipeline tell a broken setup apart from a project that simply has untranslated keys.
|
|
77
|
+
|
|
78
|
+
Gates are opt-in flags, so every invocation without one keeps the exit code it has today:
|
|
79
|
+
|
|
80
|
+
| Flag | Command | Trips when |
|
|
81
|
+
|------|---------|-----------|
|
|
82
|
+
| `--fail-on-missing` | `missing` | Any key is missing in a target locale |
|
|
83
|
+
| `--fail-on-orphans` | `remove-orphans` | Any orphan key is found (dry-run still applies) |
|
|
84
|
+
| `--fail-on-failed` | `translate` | Any key failed to translate |
|
|
85
|
+
| `--fail-under <n>` | `status` | Overall completion is below `n` percent |
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
the-i18n-cli missing --fail-on-missing # exit 2 blocks the merge
|
|
89
|
+
the-i18n-cli remove-orphans --fail-on-orphans # dry-run, but non-zero on findings
|
|
90
|
+
the-i18n-cli translate --fail-on-failed # exit 2 when the run lost keys
|
|
91
|
+
the-i18n-cli status --fail-under 90 # ratchet coverage like test coverage
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`translate` needs its own gate because exit `1` is reserved for a run that
|
|
95
|
+
translated *nothing*. A run that writes 795 keys and loses 141 is a success by
|
|
96
|
+
that measure: it exits `0`, and the partial result is committed like any other.
|
|
97
|
+
The failed keys are still missing, so re-running retries them — the gate is for
|
|
98
|
+
noticing that you need to.
|
|
99
|
+
|
|
100
|
+
Gates compose on one invocation, and a failed run outranks a tripped gate — exit `1` wins over exit `2`. When a gate trips, the JSON result gains a `gatesTripped` array naming it and reporting the observed value against the threshold; nothing else in the result changes, so existing consumers keep working:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"summary": { "totalMissingKeys": 12 },
|
|
105
|
+
"gatesTripped": [
|
|
106
|
+
{ "name": "fail-on-missing", "counter": "totalMissingKeys", "direction": "above", "threshold": 0, "observed": 12 }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`check` is the exception: it gates unconditionally with exit `1`, because a key that renders raw in production is a defect rather than a threshold.
|
|
112
|
+
|
|
113
|
+
## Getting Started on a New Project
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
the-i18n-cli init # writes .i18n-mcp.json
|
|
117
|
+
the-i18n-cli init --dry-run # report what it would write, touch nothing
|
|
118
|
+
the-i18n-cli init --force # overwrite an existing config
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`init` detects the framework and writes **only what that framework cannot tell the tool itself**. On a Nuxt, Laravel, Vue or React project the locales, layers and default locale are resolved from the framework config on every run, so `init` deliberately does not copy them into `.i18n-mcp.json` — a generated copy is a second source of truth that drifts silently the day the framework config changes. What you get is the authoring context no adapter can derive:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"$schema": "https://raw.githubusercontent.com/fabkho/the-i18n-kit/main/packages/mcp/schema.json",
|
|
126
|
+
"context": "",
|
|
127
|
+
"glossary": {},
|
|
128
|
+
"translationPrompt": "",
|
|
129
|
+
"localeNotes": {}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A project with no recognised framework is the exception: the generic adapter cannot resolve anything without `localeDirs` and `defaultLocale`, so `init` probes the common locale directory layouts and writes what it finds. The reference locale is guessed as the **fullest** locale file rather than the alphabetically first, since a source locale is the one everything else is translated from.
|
|
134
|
+
|
|
135
|
+
The result reports which adapter matched and with what confidence, so you can tell why Nuxt was chosen over generic. `init` is non-interactive, so it runs unattended in a devcontainer or CI bootstrap, and it refuses to overwrite an existing config unless you pass `--force`. Even then it preserves any `localeDirs`, `defaultLocale` and `locales` already in the file — `--force` refreshes the scaffolding, it does not discard your locale wiring.
|
|
136
|
+
|
|
137
|
+
## Coverage
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
the-i18n-cli status # per locale and per layer, plus one overall number
|
|
141
|
+
the-i18n-cli status --layer root # one layer
|
|
142
|
+
the-i18n-cli status --fail-under 90 # CI gate: exit 2 below the threshold
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
One call replaces calling `missing` per layer and counting keys yourself:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"summary": {
|
|
150
|
+
"totalKeys": 8, "translatedKeys": 3, "missingKeys": 4, "emptyKeys": 1,
|
|
151
|
+
"completionPercent": 37.5,
|
|
152
|
+
"protectedLocales": ["de-formal"], "localesChecked": 2
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Empty strings count as untranslated.** A scaffolded key nobody filled renders as a blank, so counting it as complete would let a locale read as done while showing gaps in the UI — consistent with how `missing` already treats them. They are reported separately from `missing` so you can tell a scaffold-and-forget locale from an untouched one.
|
|
158
|
+
|
|
159
|
+
**Protected locales are reported but excluded from the overall figure**, in both the project and per-layer numbers. They are maintained by hand, so counting their gaps as project debt makes a healthy project read as failing and moves a number nobody can act on.
|
|
160
|
+
|
|
161
|
+
The full per-locale and per-layer arrays grow with the project, so pass `--output-file` to write them to disk and get back only the summary.
|
|
162
|
+
|
|
163
|
+
## Referring to Locales
|
|
164
|
+
|
|
165
|
+
Anywhere a locale is named — `--ref`, `--targets`, `protectedLocales`, the keys of a `write_translations` payload — you may use the locale's **code**, its **language tag**, or its **file name** (extension included). Resolution takes them in that order.
|
|
166
|
+
|
|
167
|
+
**Prefer the code.** Codes are unique by construction; language tags are not. A project can declare an informal and a formal German that both carry `language: "de-DE"`, in which case `de-DE` is ambiguous — it resolves to the first match, and that depends on the order of your framework's locale array. The precedence rule guarantees only that a locale's own code is never shadowed by a *different* locale's language tag.
|
|
168
|
+
|
|
169
|
+
A ref that matches nothing is reported rather than silently dropped:
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"written": ["common.save"],
|
|
174
|
+
"filesWritten": 2,
|
|
175
|
+
"unresolvedLocales": [
|
|
176
|
+
{ "ref": "de-DE-formal", "keys": ["common.save"], "suggestion": "Did you mean \"de-formal\" or \"de-DE\" or \"de-DE-formal.json\"?" }
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`unresolvedLocales` is the only reliable signal that a write did less than you asked: the key still appears in `written` because the other locales succeeded, and `filesWritten` is short by one. A ref matching several locales is reported the same way under `ambiguousLocales`, naming every candidate and the one that was used. Both fields are absent when every ref resolves uniquely, so a clean write is byte-for-byte what it always was.
|
|
182
|
+
|
|
183
|
+
Note that a locale's `file` must be given with its extension (`de-DE-formal.json`); the bare stem is not a valid ref.
|
|
184
|
+
|
|
185
|
+
## Translation Modes
|
|
186
|
+
|
|
187
|
+
`translate` and `translate-key` run in one of two modes — every result reports which one ran (`mode: "provider" | "agent" | "dry-run"`).
|
|
188
|
+
|
|
189
|
+
**Provider mode** — pass `--provider` (`openai`, `anthropic`, or `google`) and `--model`; the API key comes from `--apiKey` or the provider's env var (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`). The CLI calls the LLM directly and writes validated results:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
the-i18n-cli translate --layer root --provider google --model gemini-2.5-flash
|
|
193
|
+
the-i18n-cli translate --layer root --targets de-DE,fr-FR --batchSize 25 --provider openai --model gpt-4o-mini
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
To reach an endpoint that speaks the same protocol — a gateway, a self-hosted model server, a proxy — add `--baseUrl`, or set `I18N_BASE_URL`, or `providerBaseUrl` in `.i18n-mcp.json`, in that order of precedence:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
the-i18n-cli translate --layer root --provider openai --model llama3 \
|
|
200
|
+
--baseUrl http://localhost:11434/v1 --apiKey unused
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
An API key is still required even when the endpoint ignores it — pass any placeholder for a local server. This overrides the endpoint only, so providers that also change the request shape or auth header (Azure OpenAI among them) are not reachable this way.
|
|
204
|
+
|
|
205
|
+
`google` has no endpoint override, so a base URL with it is rejected as a configuration error rather than silently ignored.
|
|
206
|
+
|
|
207
|
+
**Agent mode** — no `--provider` given. Nothing is translated: keys are reported as `skipped` with reason `no-provider`, and the result explains how to enable provider mode. (In the MCP server, agent mode instead returns fallback contexts for the host agent — see [the-i18n-mcp](https://www.npmjs.com/package/the-i18n-mcp).)
|
|
208
|
+
|
|
209
|
+
### Result contract
|
|
210
|
+
|
|
211
|
+
Translate results account for every key:
|
|
212
|
+
|
|
213
|
+
- `translated` — keys written
|
|
214
|
+
- `wouldTranslate` — `--dryRun` only: keys that would be translated
|
|
215
|
+
- `failed` — with a reason: `provider-error`, `omitted-by-model`, `truncated`, `placeholder-mismatch`, `plural-mismatch`, `write-error`
|
|
216
|
+
- `skipped` — with a reason: `no-provider`, `already-translated`, `protected-locale`
|
|
217
|
+
- Invariant: `missing = translated + wouldTranslate + failed + skipped`
|
|
218
|
+
|
|
219
|
+
Translations are validated before writing: placeholder parity per vue-i18n plural variant (`{placeholders}`, `@:linked.refs`; `:params` for PHP) and plural variant-count parity with the source. Failing values are rejected into `failed` instead of written.
|
|
220
|
+
|
|
221
|
+
Locales listed in `protectedLocales` (see Project Config) are excluded from default translate targets and reported as `skipped` with reason `protected-locale`; naming one explicitly in `--targets` overrides the protection with a warning.
|
|
222
|
+
|
|
223
|
+
## How Orphan Detection Works
|
|
224
|
+
|
|
225
|
+
`remove-orphans` and `check` share a line-based static scanner. Knowing exactly what it can and cannot see is essential before deleting keys.
|
|
226
|
+
|
|
227
|
+
**Usage evidence the scanner recognizes:**
|
|
228
|
+
|
|
229
|
+
| Class | Example | Effect |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| Static keys | `t('a.b.c')`, `$te('a.b')`, `__('a.b')` | exact match |
|
|
232
|
+
| Template patterns | `` t(`a.b.${x}`) `` | keys matching `a.b.<one segment>` count as used (`dynamic-matched`) |
|
|
233
|
+
| Same-file const prefixes | `const base = 'a.b'` + `` t(`${base}.title`) `` | resolved to the exact key |
|
|
234
|
+
| Unresolved variable segments | `` t(`${somePath}.title`) `` | conservatively matches **any** `*.title` key |
|
|
235
|
+
| Concat prefixes | `'a.b.' + x`, `x + '.label'` | pattern-matched like templates (single-segment prefixes included) |
|
|
236
|
+
| Multiline calls | prefix on a different line than `t(` | caught by bare-string fallbacks (heuristic) |
|
|
237
|
+
| Multi-app scoping | key in a shared layer | usage counts only from apps that consume the layer; cross-app usages are reported as `misplacedUsages`, never removed |
|
|
238
|
+
|
|
239
|
+
**The scan never removes a key it is unsure about.** Dynamic references are detected, not ignored: a key that *could* be produced by a template pattern, a concatenated prefix or an ambiguous probe is classified as used and left alone. Deletion is reserved for keys with no evidence of use anywhere in a consuming app. On a real 8,000-key project roughly 12% of keys land in the protective buckets — that is the scan working, not failing.
|
|
240
|
+
|
|
241
|
+
**Classification buckets** — only `orphanKeys` is ever removed; everything else is protective:
|
|
242
|
+
|
|
243
|
+
- `orphanKeys` — no evidence of use in any consuming app: safe to remove
|
|
244
|
+
- `dynamic-matched` — a dynamic pattern could produce this key: counted as used
|
|
245
|
+
- `uncertainKeys` — evidence is ambiguous (e.g. `$te`-only probes): never removed
|
|
246
|
+
- `ignored` — matched by `orphanScan.ignorePatterns`: never scanned
|
|
247
|
+
- `misplacedUsages` — used only from non-consuming apps: never removed
|
|
248
|
+
|
|
249
|
+
**Known blind spots** (declare these families in `orphanScan.ignorePatterns`):
|
|
250
|
+
|
|
251
|
+
- Prefixes stored in **cross-file** constants, class fields, or object properties typed as plain strings (`obj.translationPath`) — the scanner widens these to conservative suffix patterns, but treat such families as declared-dynamic
|
|
252
|
+
- Keys composed at runtime from data (API responses, database values, enums built dynamically)
|
|
253
|
+
- Keys referenced only outside the scanned source (backend responses, external configs, docs)
|
|
254
|
+
|
|
255
|
+
**Recommended code style — anchor dynamic keys, don't avoid them.** Dynamic keys are tracked and are often the right design; what matters is that the *namespace* stays literal at the call site:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
t(`${prefix}.title`) // widens to any key ending .title
|
|
259
|
+
t(`components.integrations.${type}.title`) // widens to one segment under a known namespace
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Both are counted as used, but the first suppresses every `.title` key in the project — on a large catalog that can be hundreds of keys the scan can no longer audit. Keeping a literal leading segment costs nothing and keeps the report meaningful. Literal key maps (`const KEYS = { draft: 'x.status.draft' } as const`) are the fully-static alternative where the set is closed.
|
|
263
|
+
|
|
264
|
+
Prefixes assembled in another scope defeat this even when they are literal — a `computed` returning `'a.b.' + x` reaches the call site as an opaque variable. Inline the namespace instead of the whole key.
|
|
265
|
+
|
|
266
|
+
`remove-orphans` is dry-run by default; run removals as a reviewed MR and treat the report's `uncertainKeys`/`dynamicKeys` sections as the audit trail.
|
|
267
|
+
|
|
268
|
+
## Supported Frameworks
|
|
269
|
+
|
|
270
|
+
| Framework | Locale Format | Auto-Detection | Locale Directories Probed |
|
|
271
|
+
|-----------|--------------|----------------|---------------------------|
|
|
272
|
+
| **Nuxt** (v3+) | JSON | `nuxt.config.ts` with `@nuxtjs/i18n` | `i18n/locales/` per app and per layer; honours each layer's `langDir` (default `locales`) |
|
|
273
|
+
| **Laravel** (9+) | PHP arrays or JSON | `artisan`, `composer.json`, `lang/` | `lang/` or `resources/lang/` — PHP subdirectories (`lang/en/*.php`) or flat JSON (`lang/en.json`) |
|
|
274
|
+
| **Vue** (SPA, v3) | JSON | `vue` in dependencies without Nuxt; `vue-i18n` raises confidence | `src/locales`, `locales`, `src/i18n/locales`, `i18n/locales`, `src/plugins/i18n/locales`, `src/i18n` — or a `localeDir`/`messages` path read out of `src/i18n/index.{ts,js}`, `src/plugins/i18n.{ts,js}`, `src/i18n.{ts,js}`, `i18n.{ts,js}` |
|
|
275
|
+
| **React / Next.js** | JSON | `next`, or `react` + `react-dom`, without Vue/Nuxt; `next-intl`, `next-translate`, `next-i18next`, `react-i18next` or `react-intl` raises confidence | `messages`, `public/locales`, `locales`, `src/i18n`, `src/locales`, `i18n` — namespaced (`messages/en/common.json`) or flat (`locales/en.json`). A `next.config.{ts,js,mjs}` using `createNextIntlPlugin` or `next-translate` pins the directory directly |
|
|
276
|
+
| **Generic** | JSON or PHP | `localeDirs` + `defaultLocale` in `.i18n-mcp.json` | Exactly the paths listed in `localeDirs` |
|
|
277
|
+
|
|
278
|
+
Detection is confidence-scored: the highest-scoring adapter wins, and a `.i18n-mcp.json` carrying both `localeDirs` and `defaultLocale` outscores framework inference. Set `"framework": "vue"` (or any adapter name) to force one adapter.
|
|
279
|
+
|
|
280
|
+
### Read, not guessed
|
|
281
|
+
|
|
282
|
+
Where a project already declares its locales, the adapter reads that file rather than inferring from directory order:
|
|
283
|
+
|
|
284
|
+
| Setup | Read from | Gives |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| next-intl | `src/i18n/routing.ts` — `defineRouting({ ... })` | `locales`, `defaultLocale` |
|
|
287
|
+
| next-translate | `i18n.js` | `locales`, `defaultLocale` |
|
|
288
|
+
| Next.js Pages Router | `next.config.{ts,js,mjs}` — `i18n: { ... }` | `locales`, `defaultLocale` |
|
|
289
|
+
| Vue + `@intlify/unplugin-vue-i18n` | `vite.config.{ts,js}` — the plugin's `include` | locale directories |
|
|
290
|
+
|
|
291
|
+
These files are executed, so a `next.config.js` wrapped in `withNextIntl(...)` or `withSentryConfig(...)` may fail without the right environment. That is never fatal: the CLI warns and falls back to the directory probing above, exactly as it behaved before it could read them.
|
|
292
|
+
|
|
293
|
+
A value you declare yourself always wins over both. To pin the reference locale regardless of what any framework config says:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { defineI18nKitConfig } from 'the-i18n-cli/config'
|
|
297
|
+
|
|
298
|
+
export default defineI18nKitConfig({
|
|
299
|
+
defaultLocale: 'en',
|
|
300
|
+
localeDirs: ['src/locales'],
|
|
301
|
+
})
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Programmatic API
|
|
305
|
+
|
|
306
|
+
The CLI also exports all operations as a library for use in other tools:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { detectConfig, getMissingTranslations, addTranslations, translateKey } from 'the-i18n-cli'
|
|
310
|
+
|
|
311
|
+
const config = await detectConfig('/path/to/project')
|
|
312
|
+
const missing = await getMissingTranslations({ projectDir: '/path/to/project' })
|
|
313
|
+
|
|
314
|
+
await translateKey({
|
|
315
|
+
projectDir: '/path/to/project',
|
|
316
|
+
layer: 'root',
|
|
317
|
+
key: 'common.actions.save',
|
|
318
|
+
sourceLocale: 'en-US',
|
|
319
|
+
sourceValue: 'Save',
|
|
320
|
+
targetLocales: 'all',
|
|
321
|
+
overwrite: true,
|
|
322
|
+
})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Project Config
|
|
326
|
+
|
|
327
|
+
Project-specific context goes in `i18n-kit.config.ts` at your project root, where the editor checks it:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { defineI18nKitConfig } from 'the-i18n-cli/config'
|
|
331
|
+
|
|
332
|
+
export default defineI18nKitConfig({
|
|
333
|
+
context: 'B2B SaaS booking platform',
|
|
334
|
+
glossary: {
|
|
335
|
+
Booking: "Core concept. Dutch: 'Boeking'.",
|
|
336
|
+
},
|
|
337
|
+
protectedLocales: ['en-us', 'de-formal'],
|
|
338
|
+
})
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Read directly, with no build step — a `protectedLocales` entry that only takes effect once something has been built is a locale that quietly goes unprotected in a fresh checkout. `.ts`, `.mts`, `.js`, `.mjs` and `.cjs` all work, and the file is looked up from the working directory upwards, the way `eslint` and `tsconfig` resolve theirs.
|
|
342
|
+
|
|
343
|
+
`.i18n-mcp.json` accepts the same keys and remains supported:
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"$schema": "node_modules/the-i18n-mcp/schema.json",
|
|
348
|
+
"context": "B2B SaaS booking platform",
|
|
349
|
+
"glossary": {
|
|
350
|
+
"Booking": "Core concept. Dutch: 'Boeking'.",
|
|
351
|
+
"Resource": "A bookable entity (room, desk, person)"
|
|
352
|
+
},
|
|
353
|
+
"translationPrompt": "Professional but approachable tone. Keep translations concise.",
|
|
354
|
+
"localeNotes": {
|
|
355
|
+
"de": "Informal German (du)",
|
|
356
|
+
"de-formal": "Formal German (Sie)"
|
|
357
|
+
},
|
|
358
|
+
"protectedLocales": ["en-us", "de-formal"]
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Use one or the other. Both may be present, but declaring the same key in both is an error naming both files rather than a silent precedence rule.
|
|
363
|
+
|
|
364
|
+
See the [full config reference](https://github.com/fabkho/the-i18n-kit#project-config) for all options. `samplingPreferences` is deprecated and ignored (accepted for backward compatibility) — configure a provider instead.
|
|
365
|
+
|
|
366
|
+
## License
|
|
367
|
+
|
|
368
|
+
[MIT](https://github.com/fabkho/the-i18n-kit/blob/main/LICENSE)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { n as writeResult } from "./stdout-guard-sJHFWVem.js";
|
|
2
|
+
import { i as toErrorMessage } from "./errors-coI1dhw1.js";
|
|
3
|
+
import { a as resolveProviderBaseUrl, i as createTranslateFn, l as log, s as loadProjectConfig, t as BASE_URL_ENV } from "./providers-BAwPjeCy.js";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
//#region src/commands/_shared.ts
|
|
6
|
+
/** Factory for commands that call an operation and output its result. */
|
|
7
|
+
function createCommand(opts) {
|
|
8
|
+
return defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: opts.name,
|
|
11
|
+
description: opts.description
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
...sharedArgs,
|
|
15
|
+
...opts.args ?? {}
|
|
16
|
+
},
|
|
17
|
+
async run({ args }) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await opts.run(args);
|
|
20
|
+
const runFailed = isTotalFailure(result) || (opts.failWhen?.(result) ?? false);
|
|
21
|
+
const decision = resolveExitCode(result, requestedGates(opts.gates ?? [], args), runFailed);
|
|
22
|
+
outputResult(withGateReport(result, decision.tripped), args);
|
|
23
|
+
if (decision.code !== 0) process.exitCode = decision.code;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
emitErrorResult(error, args);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pure decision from an operation result plus the gates the caller requested
|
|
33
|
+
* to an exit code. A failed run outranks a tripped gate — exit 1 wins over
|
|
34
|
+
* exit 2 — and gates are not even consulted in that case, because counters
|
|
35
|
+
* from a run that fell over say nothing about the project.
|
|
36
|
+
*/
|
|
37
|
+
function resolveExitCode(result, gates, runFailed = false) {
|
|
38
|
+
if (runFailed) return {
|
|
39
|
+
code: 1,
|
|
40
|
+
tripped: []
|
|
41
|
+
};
|
|
42
|
+
const tripped = [];
|
|
43
|
+
for (const gate of gates) {
|
|
44
|
+
const observed = observedValue(result, gate.counter);
|
|
45
|
+
if (observed === void 0 || !trips(gate, observed)) continue;
|
|
46
|
+
tripped.push({
|
|
47
|
+
...gate,
|
|
48
|
+
observed
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
code: tripped.length > 0 ? 2 : 0,
|
|
53
|
+
tripped
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read a gate's counter off result.summary. Works on inline results and on
|
|
58
|
+
* the { reportFile, summary } shape alike, since both carry the summary.
|
|
59
|
+
* A missing or non-numeric counter yields undefined and never trips a gate.
|
|
60
|
+
*/
|
|
61
|
+
function observedValue(result, counter) {
|
|
62
|
+
if (result === null || typeof result !== "object") return void 0;
|
|
63
|
+
const summary = result.summary;
|
|
64
|
+
if (summary === null || typeof summary !== "object") return void 0;
|
|
65
|
+
const value = summary[counter];
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
67
|
+
}
|
|
68
|
+
function trips(gate, observed) {
|
|
69
|
+
return gate.direction === "below" ? observed < gate.threshold : observed > gate.threshold;
|
|
70
|
+
}
|
|
71
|
+
/** Filter the declared gates down to the ones this invocation asked for. */
|
|
72
|
+
function requestedGates(specs, args) {
|
|
73
|
+
const requested = [];
|
|
74
|
+
for (const spec of specs) {
|
|
75
|
+
const raw = args[spec.flag];
|
|
76
|
+
if (raw === void 0 || raw === null || raw === false || raw === "") continue;
|
|
77
|
+
const threshold = spec.threshold ?? Number(raw);
|
|
78
|
+
if (!Number.isFinite(threshold)) continue;
|
|
79
|
+
requested.push({
|
|
80
|
+
name: kebabCase(spec.flag),
|
|
81
|
+
counter: spec.counter,
|
|
82
|
+
direction: spec.direction ?? "above",
|
|
83
|
+
threshold
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return requested;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Name a tripped gate by the flag that requested it, so the JSON says
|
|
90
|
+
* "fail-on-missing" — what the user typed — rather than "failOnMissing".
|
|
91
|
+
*/
|
|
92
|
+
function kebabCase(flag) {
|
|
93
|
+
return flag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Attach the gate report without disturbing the rest of the result: consumers
|
|
97
|
+
* parsing today's shape keep working, and a run where nothing tripped is
|
|
98
|
+
* byte-for-byte what it was before gates existed.
|
|
99
|
+
*/
|
|
100
|
+
function withGateReport(result, tripped) {
|
|
101
|
+
if (tripped.length === 0) return result;
|
|
102
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) return result;
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
gatesTripped: tripped
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* True when a run completed but achieved nothing: failures present and zero
|
|
110
|
+
* successes. Covers translate-missing-style results (summary.totalFailed /
|
|
111
|
+
* summary.totalTranslated) and translate-key-style results (top-level
|
|
112
|
+
* failed[] / translated[]). Results without those fields are never a
|
|
113
|
+
* total failure, so unrelated commands are unaffected.
|
|
114
|
+
*/
|
|
115
|
+
function isTotalFailure(result) {
|
|
116
|
+
if (result === null || typeof result !== "object") return false;
|
|
117
|
+
const r = result;
|
|
118
|
+
const summary = r.summary;
|
|
119
|
+
if (summary !== null && typeof summary === "object") {
|
|
120
|
+
const s = summary;
|
|
121
|
+
if (typeof s.totalFailed === "number" && typeof s.totalTranslated === "number") return s.totalFailed > 0 && s.totalTranslated === 0;
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(r.failed) && Array.isArray(r.translated)) return r.failed.length > 0 && r.translated.length === 0;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const sharedArgs = {
|
|
127
|
+
projectDir: {
|
|
128
|
+
type: "string",
|
|
129
|
+
alias: "d",
|
|
130
|
+
description: "Project directory (default: cwd)"
|
|
131
|
+
},
|
|
132
|
+
json: {
|
|
133
|
+
type: "boolean",
|
|
134
|
+
description: "Output as JSON (default for non-TTY)",
|
|
135
|
+
default: false
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
/** Output result — stdout always carries the result, machine-parseable when piped/--json */
|
|
139
|
+
function outputResult(data, args) {
|
|
140
|
+
if (!(args.json || !process.stdout.isTTY) && data !== null && typeof data === "object" && "reportFile" in data) {
|
|
141
|
+
const { reportFile, ...rest } = data;
|
|
142
|
+
log.info(`Wrote report to: ${reportFile}`);
|
|
143
|
+
writeResult(JSON.stringify(rest, null, 2) + "\n");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
writeResult(JSON.stringify(data, null, 2) + "\n");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Failure output. In JSON mode stdout must still carry parseable JSON —
|
|
150
|
+
* consumers pipe it into jq, and zero bytes is a parse error — so the
|
|
151
|
+
* structured error object IS the result on stdout; the human-readable
|
|
152
|
+
* message goes to stderr in every mode. Exit code stays non-zero (callers
|
|
153
|
+
* set it).
|
|
154
|
+
*/
|
|
155
|
+
function emitErrorResult(error, args) {
|
|
156
|
+
log.error(toErrorMessage(error));
|
|
157
|
+
if (!(args.json || !process.stdout.isTTY)) return;
|
|
158
|
+
const payload = { error: {
|
|
159
|
+
code: errorCode(error),
|
|
160
|
+
message: toErrorMessage(error)
|
|
161
|
+
} };
|
|
162
|
+
writeResult(JSON.stringify(payload, null, 2) + "\n");
|
|
163
|
+
}
|
|
164
|
+
/** ToolError/FileIOError carry codes; Node errors expose e.g. ENOENT. */
|
|
165
|
+
function errorCode(error) {
|
|
166
|
+
if (error instanceof Error) {
|
|
167
|
+
const code = error.code;
|
|
168
|
+
if (typeof code === "string") return code;
|
|
169
|
+
if (error.name === "ConfigError") return "CONFIG_ERROR";
|
|
170
|
+
}
|
|
171
|
+
return "UNKNOWN_ERROR";
|
|
172
|
+
}
|
|
173
|
+
/** Provider selection flags shared by the translate commands. */
|
|
174
|
+
const providerArgs = {
|
|
175
|
+
provider: {
|
|
176
|
+
type: "string",
|
|
177
|
+
description: "LLM provider: \"openai\", \"anthropic\", or \"google\". Required for automatic translation.",
|
|
178
|
+
valueHint: "openai|anthropic|google"
|
|
179
|
+
},
|
|
180
|
+
model: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "Model name (required when --provider is set)"
|
|
183
|
+
},
|
|
184
|
+
apiKey: {
|
|
185
|
+
type: "string",
|
|
186
|
+
description: "API key (falls back to OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY env)."
|
|
187
|
+
},
|
|
188
|
+
baseUrl: {
|
|
189
|
+
type: "string",
|
|
190
|
+
description: `Provider base URL for gateways, self-hosted models and proxies speaking the provider's protocol. Falls back to ${BASE_URL_ENV}, then providerBaseUrl in .i18n-mcp.json. Not supported by "google".`
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Build a TranslateFn from the provider flags, or undefined when no provider
|
|
195
|
+
* was given (agent mode). The base URL resolves flag > env > project config;
|
|
196
|
+
* the config file is only read when neither of the first two is set, so a
|
|
197
|
+
* fully-flagged invocation never depends on config discovery.
|
|
198
|
+
*/
|
|
199
|
+
async function resolveProviderTranslateFn(args) {
|
|
200
|
+
if (!args.provider) return void 0;
|
|
201
|
+
if (!args.model) throw new Error("--model is required when --provider is set");
|
|
202
|
+
let baseUrl = resolveProviderBaseUrl({
|
|
203
|
+
flag: args.baseUrl,
|
|
204
|
+
env: process.env[BASE_URL_ENV]
|
|
205
|
+
});
|
|
206
|
+
if (!baseUrl) baseUrl = resolveProviderBaseUrl({ config: (await loadProjectConfig(args.projectDir ?? process.cwd()))?.providerBaseUrl });
|
|
207
|
+
if (baseUrl) log.debug(`Provider base URL: ${redactBaseUrl(baseUrl)}`);
|
|
208
|
+
return createTranslateFn({
|
|
209
|
+
provider: args.provider,
|
|
210
|
+
model: args.model,
|
|
211
|
+
apiKey: args.apiKey,
|
|
212
|
+
baseUrl
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Strip anything secret-shaped from a base URL before it reaches a log.
|
|
217
|
+
* Gateways are routinely addressed as https://user:pass@host or with the key
|
|
218
|
+
* in a query parameter, so keep only origin and path.
|
|
219
|
+
*/
|
|
220
|
+
function redactBaseUrl(raw) {
|
|
221
|
+
let url;
|
|
222
|
+
try {
|
|
223
|
+
url = new URL(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return "<unparseable base URL>";
|
|
226
|
+
}
|
|
227
|
+
const credentials = url.username || url.password ? "<redacted>@" : "";
|
|
228
|
+
const query = url.search || url.hash ? " (query redacted)" : "";
|
|
229
|
+
return `${url.protocol}//${credentials}${url.host}${url.pathname}${query}`;
|
|
230
|
+
}
|
|
231
|
+
/** Split a comma-separated string into a trimmed array, or return undefined */
|
|
232
|
+
function splitList(val) {
|
|
233
|
+
if (!val) return void 0;
|
|
234
|
+
return val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
235
|
+
}
|
|
236
|
+
/** Parse a JSON string with a user-friendly error */
|
|
237
|
+
function parseJsonArg(value, argName) {
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(value);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const detail = err instanceof SyntaxError ? err.message : String(err);
|
|
242
|
+
throw new Error(`Invalid JSON in --${argName}: ${detail}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
export { resolveProviderTranslateFn as a, providerArgs as i, emitErrorResult as n, splitList as o, parseJsonArg as r, createCommand as t };
|
|
247
|
+
|
|
248
|
+
//# sourceMappingURL=_shared-Dv1EW2gy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_shared-Dv1EW2gy.js","names":[],"sources":["../src/commands/_shared.ts"],"sourcesContent":["import { defineCommand } from 'citty'\nimport type { CommandDef } from 'citty'\nimport { log } from '../utils/logger.js'\nimport { toErrorMessage } from '../utils/errors.js'\nimport { writeResult } from '../utils/stdout-guard.js'\nimport { createTranslateFn, resolveProviderBaseUrl, BASE_URL_ENV } from '../llm/providers.js'\nimport type { LlmProvider } from '../llm/providers.js'\nimport type { TranslateFn } from '../core/types.js'\nimport { loadProjectConfig } from '../config/project-config.js'\n\n/** Factory for commands that call an operation and output its result. */\nexport function createCommand(opts: {\n name: string\n description: string\n args?: Record<string, unknown>\n /**\n * Command-specific CI gate: when it returns true for the (already\n * emitted) result, the process exits non-zero — e.g. `check` failing\n * the build on undefined-key findings. Runs in addition to the generic\n * isTotalFailure check.\n */\n failWhen?: (result: unknown) => boolean\n /**\n * Opt-in CI gates this command accepts. The factory reads the requesting\n * flag off args and evaluates every gate uniformly, so no command grows\n * bespoke exit logic. Declaring a gate does not add its flag — pair each\n * spec with an entry in `args`.\n */\n gates?: GateSpec[]\n /**\n * Receives citty's parsed args. `any` is deliberate: each command declares\n * its own `args` shape and citty does not thread that type through to the\n * handler, so narrowing here only moves the cast into all nineteen command\n * modules. The shape is validated by citty against the `args` above.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see above\n run: (args: any) => Promise<unknown>\n}): CommandDef {\n return defineCommand({\n meta: { name: opts.name, description: opts.description },\n args: { ...sharedArgs, ...(opts.args ?? {}) },\n async run({ args }) {\n try {\n const result = await opts.run(args)\n const runFailed = isTotalFailure(result) || (opts.failWhen?.(result) ?? false)\n const decision = resolveExitCode(result, requestedGates(opts.gates ?? [], args), runFailed)\n outputResult(withGateReport(result, decision.tripped), args)\n // Assign only on a non-zero decision: a clean run must leave the exit\n // code exactly as it found it, as it did before gates existed.\n if (decision.code !== EXIT_SUCCESS) process.exitCode = decision.code\n } catch (error) {\n emitErrorResult(error, args)\n process.exitCode = EXIT_RUN_FAILED\n }\n },\n }) as CommandDef\n}\n\n/** The run succeeded and no gate tripped. */\nexport const EXIT_SUCCESS = 0\n/** The run itself failed — a bad API key, an unreadable project, a total translate failure. */\nexport const EXIT_RUN_FAILED = 1\n/** The run succeeded but a requested gate tripped — findings exist, the tool worked. */\nexport const EXIT_GATE_TRIPPED = 2\n\n/**\n * A CI gate a command accepts. `flag` is the arg that requests it; `counter`\n * is the field of `result.summary` carrying the observed value. Omitting\n * `threshold` takes it from the flag's own value, so a boolean flag pairs\n * with `threshold: 0` and a numeric one (`--fail-under 90`) omits it.\n */\nexport interface GateSpec {\n flag: string\n counter: string\n /** 'above' trips when observed > threshold (default); 'below' when observed < threshold. */\n direction?: 'above' | 'below'\n threshold?: number\n}\n\n/** A gate the caller asked for, with its threshold already resolved. */\nexport interface RequestedGate {\n name: string\n counter: string\n direction: 'above' | 'below'\n threshold: number\n}\n\n/** A gate that tripped, as reported in the result. */\nexport interface TrippedGate extends RequestedGate {\n observed: number\n}\n\nexport interface ExitDecision {\n code: typeof EXIT_SUCCESS | typeof EXIT_RUN_FAILED | typeof EXIT_GATE_TRIPPED\n tripped: TrippedGate[]\n}\n\n/**\n * Pure decision from an operation result plus the gates the caller requested\n * to an exit code. A failed run outranks a tripped gate — exit 1 wins over\n * exit 2 — and gates are not even consulted in that case, because counters\n * from a run that fell over say nothing about the project.\n */\nexport function resolveExitCode(\n result: unknown,\n gates: RequestedGate[],\n runFailed = false,\n): ExitDecision {\n if (runFailed) return { code: EXIT_RUN_FAILED, tripped: [] }\n\n const tripped: TrippedGate[] = []\n for (const gate of gates) {\n const observed = observedValue(result, gate.counter)\n if (observed === undefined || !trips(gate, observed)) continue\n tripped.push({ ...gate, observed })\n }\n\n return {\n code: tripped.length > 0 ? EXIT_GATE_TRIPPED : EXIT_SUCCESS,\n tripped,\n }\n}\n\n/**\n * Read a gate's counter off result.summary. Works on inline results and on\n * the { reportFile, summary } shape alike, since both carry the summary.\n * A missing or non-numeric counter yields undefined and never trips a gate.\n */\nfunction observedValue(result: unknown, counter: string): number | undefined {\n if (result === null || typeof result !== 'object') return undefined\n const summary = (result as Record<string, unknown>).summary\n if (summary === null || typeof summary !== 'object') return undefined\n const value = (summary as Record<string, unknown>)[counter]\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n\nfunction trips(gate: RequestedGate, observed: number): boolean {\n return gate.direction === 'below' ? observed < gate.threshold : observed > gate.threshold\n}\n\n/** Filter the declared gates down to the ones this invocation asked for. */\nfunction requestedGates(specs: GateSpec[], args: Record<string, unknown>): RequestedGate[] {\n const requested: RequestedGate[] = []\n for (const spec of specs) {\n const raw = args[spec.flag]\n if (raw === undefined || raw === null || raw === false || raw === '') continue\n const threshold = spec.threshold ?? Number(raw)\n if (!Number.isFinite(threshold)) continue\n requested.push({\n name: kebabCase(spec.flag),\n counter: spec.counter,\n direction: spec.direction ?? 'above',\n threshold,\n })\n }\n return requested\n}\n\n/**\n * Name a tripped gate by the flag that requested it, so the JSON says\n * \"fail-on-missing\" — what the user typed — rather than \"failOnMissing\".\n */\nfunction kebabCase(flag: string): string {\n return flag.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)\n}\n\n/**\n * Attach the gate report without disturbing the rest of the result: consumers\n * parsing today's shape keep working, and a run where nothing tripped is\n * byte-for-byte what it was before gates existed.\n */\nfunction withGateReport(result: unknown, tripped: TrippedGate[]): unknown {\n if (tripped.length === 0) return result\n if (result === null || typeof result !== 'object' || Array.isArray(result)) return result\n return { ...(result as Record<string, unknown>), gatesTripped: tripped }\n}\n\n/**\n * True when a run completed but achieved nothing: failures present and zero\n * successes. Covers translate-missing-style results (summary.totalFailed /\n * summary.totalTranslated) and translate-key-style results (top-level\n * failed[] / translated[]). Results without those fields are never a\n * total failure, so unrelated commands are unaffected.\n */\nexport function isTotalFailure(result: unknown): boolean {\n if (result === null || typeof result !== 'object') return false\n const r = result as Record<string, unknown>\n\n const summary = r.summary\n if (summary !== null && typeof summary === 'object') {\n const s = summary as Record<string, unknown>\n if (typeof s.totalFailed === 'number' && typeof s.totalTranslated === 'number') {\n return s.totalFailed > 0 && s.totalTranslated === 0\n }\n }\n\n if (Array.isArray(r.failed) && Array.isArray(r.translated)) {\n return r.failed.length > 0 && r.translated.length === 0\n }\n\n return false\n}\n\nconst sharedArgs = {\n projectDir: {\n type: 'string' as const,\n alias: 'd',\n description: 'Project directory (default: cwd)',\n },\n json: {\n type: 'boolean' as const,\n description: 'Output as JSON (default for non-TTY)',\n default: false,\n },\n}\n\n/** Output result — stdout always carries the result, machine-parseable when piped/--json */\nfunction outputResult(data: unknown, args: { json?: boolean }): void {\n const jsonMode = args.json || !process.stdout.isTTY\n if (\n !jsonMode &&\n data !== null &&\n typeof data === 'object' &&\n 'reportFile' in (data as Record<string, unknown>)\n ) {\n const { reportFile, ...rest } = data as Record<string, unknown>\n log.info(`Wrote report to: ${reportFile}`)\n writeResult(JSON.stringify(rest, null, 2) + '\\n')\n return\n }\n // JSON mode emits the full result (including reportFile when present) as pure JSON\n writeResult(JSON.stringify(data, null, 2) + '\\n')\n}\n\n/**\n * Failure output. In JSON mode stdout must still carry parseable JSON —\n * consumers pipe it into jq, and zero bytes is a parse error — so the\n * structured error object IS the result on stdout; the human-readable\n * message goes to stderr in every mode. Exit code stays non-zero (callers\n * set it).\n */\nexport function emitErrorResult(error: unknown, args: { json?: boolean }): void {\n log.error(toErrorMessage(error))\n const jsonMode = args.json || !process.stdout.isTTY\n if (!jsonMode) return\n const payload = { error: { code: errorCode(error), message: toErrorMessage(error) } }\n writeResult(JSON.stringify(payload, null, 2) + '\\n')\n}\n\n/** ToolError/FileIOError carry codes; Node errors expose e.g. ENOENT. */\nfunction errorCode(error: unknown): string {\n if (error instanceof Error) {\n const code = (error as { code?: unknown }).code\n if (typeof code === 'string') return code\n if (error.name === 'ConfigError') return 'CONFIG_ERROR'\n }\n return 'UNKNOWN_ERROR'\n}\n\n/** Provider selection flags shared by the translate commands. */\nexport const providerArgs = {\n provider: { type: 'string' as const, description: 'LLM provider: \"openai\", \"anthropic\", or \"google\". Required for automatic translation.', valueHint: 'openai|anthropic|google' },\n model: { type: 'string' as const, description: 'Model name (required when --provider is set)' },\n apiKey: { type: 'string' as const, description: 'API key (falls back to OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY env).' },\n baseUrl: { type: 'string' as const, description: `Provider base URL for gateways, self-hosted models and proxies speaking the provider's protocol. Falls back to ${BASE_URL_ENV}, then providerBaseUrl in .i18n-mcp.json. Not supported by \"google\".` },\n}\n\n/**\n * Build a TranslateFn from the provider flags, or undefined when no provider\n * was given (agent mode). The base URL resolves flag > env > project config;\n * the config file is only read when neither of the first two is set, so a\n * fully-flagged invocation never depends on config discovery.\n */\nexport async function resolveProviderTranslateFn(args: {\n provider?: string\n model?: string\n apiKey?: string\n baseUrl?: string\n projectDir?: string\n}): Promise<TranslateFn | undefined> {\n if (!args.provider) return undefined\n if (!args.model) {\n throw new Error('--model is required when --provider is set')\n }\n\n let baseUrl = resolveProviderBaseUrl({ flag: args.baseUrl, env: process.env[BASE_URL_ENV] })\n if (!baseUrl) {\n const projectConfig = await loadProjectConfig(args.projectDir ?? process.cwd())\n baseUrl = resolveProviderBaseUrl({ config: projectConfig?.providerBaseUrl })\n }\n if (baseUrl) log.debug(`Provider base URL: ${redactBaseUrl(baseUrl)}`)\n\n return createTranslateFn({\n provider: args.provider as LlmProvider,\n model: args.model,\n apiKey: args.apiKey,\n baseUrl,\n })\n}\n\n/**\n * Strip anything secret-shaped from a base URL before it reaches a log.\n * Gateways are routinely addressed as https://user:pass@host or with the key\n * in a query parameter, so keep only origin and path.\n */\nexport function redactBaseUrl(raw: string): string {\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return '<unparseable base URL>'\n }\n const credentials = url.username || url.password ? '<redacted>@' : ''\n const query = url.search || url.hash ? ' (query redacted)' : ''\n return `${url.protocol}//${credentials}${url.host}${url.pathname}${query}`\n}\n\n/** Split a comma-separated string into a trimmed array, or return undefined */\nexport function splitList(val: string | undefined): string[] | undefined {\n if (!val) return undefined\n return val.split(',').map(s => s.trim()).filter(Boolean)\n}\n\n/** Parse a JSON string with a user-friendly error */\nexport function parseJsonArg<T = Record<string, Record<string, string>>>(\n value: string,\n argName: string,\n): T {\n try {\n return JSON.parse(value) as T\n } catch (err) {\n const detail = err instanceof SyntaxError ? err.message : String(err)\n throw new Error(`Invalid JSON in --${argName}: ${detail}`)\n }\n}\n"],"mappings":"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"}
|