@the-i18n-kit/cli 5.0.0 → 7.0.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -58
  3. package/dist/bin.js +1 -6
  4. package/dist/bin.js.map +1 -1
  5. package/dist/{_shared-DFyy3sh8.js → cli-waEjbUM1.js} +157 -27
  6. package/dist/cli-waEjbUM1.js.map +1 -0
  7. package/dist/config/framework/stubs/{next-intl-routing-Cwaa3f4R.d.ts → next-intl-routing-i6Mlxwdt.d.ts} +1 -1
  8. package/dist/config/framework/stubs/{next-intl-routing-Cwaa3f4R.d.ts.map → next-intl-routing-i6Mlxwdt.d.ts.map} +1 -1
  9. package/dist/{define-config-BaZ1dZph.d.ts → define-config-ChY3jk6K.d.ts} +26 -4
  10. package/dist/define-config-ChY3jk6K.d.ts.map +1 -0
  11. package/dist/{define-config-CLgKN-7N.d.ts → define-config-e0B0VHq7.d.ts} +1 -1
  12. package/dist/define-config.d.ts +1 -1
  13. package/dist/descriptors-10sgyqEs.js +1225 -0
  14. package/dist/descriptors-10sgyqEs.js.map +1 -0
  15. package/dist/detector-BJTkyhQ0.js +5 -0
  16. package/dist/detector-DtFY4qm3.js +2189 -0
  17. package/dist/detector-DtFY4qm3.js.map +1 -0
  18. package/dist/{index-DCG3dOdC.d.ts → index-CdjJ71Ag.d.ts} +665 -250
  19. package/dist/index-CdjJ71Ag.d.ts.map +1 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +8 -5
  22. package/dist/json-writer-D0b6vEax.js +2 -0
  23. package/dist/json-writer-ek8yEI5R.js +342 -0
  24. package/dist/json-writer-ek8yEI5R.js.map +1 -0
  25. package/dist/{operations-CTo44gPu.js → operations-CNqEBD__.js} +1505 -3285
  26. package/dist/operations-CNqEBD__.js.map +1 -0
  27. package/dist/operations-D7IMu_xY.js +8 -0
  28. package/dist/php-reader-BGx6Ii2d.js +2 -0
  29. package/dist/{php-reader-3Fgw80zK.js → php-reader-DRpcRVzv.js} +6 -3
  30. package/dist/{php-reader-3Fgw80zK.js.map → php-reader-DRpcRVzv.js.map} +1 -1
  31. package/dist/{providers-CHE2ffi6.js → project-config-C3ao4Uii.js} +24 -214
  32. package/dist/project-config-C3ao4Uii.js.map +1 -0
  33. package/dist/providers-BbVPelvp.js +406 -0
  34. package/dist/providers-BbVPelvp.js.map +1 -0
  35. package/dist/report-By1-5JtO.js +37 -0
  36. package/dist/report-By1-5JtO.js.map +1 -0
  37. package/dist/report-CHZgH9Wy.js +2 -0
  38. package/package.json +11 -23
  39. package/dist/_shared-DFyy3sh8.js.map +0 -1
  40. package/dist/add-C-qs8iBa.js +0 -40
  41. package/dist/add-C-qs8iBa.js.map +0 -1
  42. package/dist/check-BI1orjXb.js +0 -41
  43. package/dist/check-BI1orjXb.js.map +0 -1
  44. package/dist/cli-DsFuVJNP.js +0 -74
  45. package/dist/cli-DsFuVJNP.js.map +0 -1
  46. package/dist/config/framework/stubs/unplugin-vue-i18n-1ud7-Ly8.d.ts +0 -25
  47. package/dist/config/framework/stubs/unplugin-vue-i18n-1ud7-Ly8.d.ts.map +0 -1
  48. package/dist/config/framework/stubs/unplugin-vue-i18n.js +0 -26
  49. package/dist/config/framework/stubs/unplugin-vue-i18n.js.map +0 -1
  50. package/dist/define-config-BaZ1dZph.d.ts.map +0 -1
  51. package/dist/detect-7cVfsNJP.js +0 -17
  52. package/dist/detect-7cVfsNJP.js.map +0 -1
  53. package/dist/empty-DB8Hl_8u.js +0 -36
  54. package/dist/empty-DB8Hl_8u.js.map +0 -1
  55. package/dist/find-duplicates-CmEqBfby.js +0 -42
  56. package/dist/find-duplicates-CmEqBfby.js.map +0 -1
  57. package/dist/get-BE21Iy6N.js +0 -41
  58. package/dist/get-BE21Iy6N.js.map +0 -1
  59. package/dist/index-DCG3dOdC.d.ts.map +0 -1
  60. package/dist/init-BtzmhsOM.js +0 -33
  61. package/dist/init-BtzmhsOM.js.map +0 -1
  62. package/dist/list-dirs-DUQbwvlE.js +0 -17
  63. package/dist/list-dirs-DUQbwvlE.js.map +0 -1
  64. package/dist/missing-CcYrgd54.js +0 -51
  65. package/dist/missing-CcYrgd54.js.map +0 -1
  66. package/dist/move-B2px6Ck-.js +0 -50
  67. package/dist/move-B2px6Ck-.js.map +0 -1
  68. package/dist/operations-CTo44gPu.js.map +0 -1
  69. package/dist/php-reader-CpnaPSpZ.js +0 -2
  70. package/dist/providers-CHE2ffi6.js.map +0 -1
  71. package/dist/remove-DTbqM4bR.js +0 -41
  72. package/dist/remove-DTbqM4bR.js.map +0 -1
  73. package/dist/remove-orphans-Dfzu7hRz.js +0 -57
  74. package/dist/remove-orphans-Dfzu7hRz.js.map +0 -1
  75. package/dist/rename-C_rFKkU4.js +0 -45
  76. package/dist/rename-C_rFKkU4.js.map +0 -1
  77. package/dist/rename-notice-BV8HNX3O.js +0 -25
  78. package/dist/rename-notice-BV8HNX3O.js.map +0 -1
  79. package/dist/rename-notice-Cx1vuTRz.js +0 -2
  80. package/dist/scaffold-CUimlNqR.js +0 -39
  81. package/dist/scaffold-CUimlNqR.js.map +0 -1
  82. package/dist/scan-BkOIYhjZ.js +0 -31
  83. package/dist/scan-BkOIYhjZ.js.map +0 -1
  84. package/dist/search-0io1BcsY.js +0 -49
  85. package/dist/search-0io1BcsY.js.map +0 -1
  86. package/dist/status-Bp-SzjTN.js +0 -45
  87. package/dist/status-Bp-SzjTN.js.map +0 -1
  88. package/dist/translate-cCb2Z57w.js +0 -92
  89. package/dist/translate-cCb2Z57w.js.map +0 -1
  90. package/dist/translate-key-DHLWEmrA.js +0 -73
  91. package/dist/translate-key-DHLWEmrA.js.map +0 -1
  92. package/dist/update-DV4ijRVB.js +0 -40
  93. package/dist/update-DV4ijRVB.js.map +0 -1
  94. package/dist/write-Czzm_Nb0.js +0 -47
  95. package/dist/write-Czzm_Nb0.js.map +0 -1
  96. /package/dist/{bin-DrKRgnr9.d.ts → bin-NyzIHE2F.d.ts} +0 -0
@@ -0,0 +1,406 @@
1
+ import { r as ToolError } from "./errors-coI1dhw1.js";
2
+ //#region src/core/shared.ts
3
+ /**
4
+ * Look up a locale directory by layer name and throw LAYER_NOT_FOUND with fuzzy
5
+ * matching hints if the name is not found.
6
+ */
7
+ function findLayerOrThrow(config, layer) {
8
+ const localeDir = config.localeDirs.find((d) => d.layer === layer);
9
+ if (!localeDir) throw new ToolError(`Layer not found: "${layer}". Available: ${config.localeDirs.map((d) => d.layer).join(", ")}.${config.projectConfig?.layerRules?.find((r) => r.layer === layer) ? ` Note: "${layer}" matches a layerRules entry in .i18n-mcp.json but is not an internal layer name.` : ""} Use discover to see all layers.`, "LAYER_NOT_FOUND");
10
+ return localeDir;
11
+ }
12
+ /**
13
+ * Look up a layer that will be written to: same as findLayerOrThrow, but also
14
+ * rejects alias layers (writes must target the source layer).
15
+ */
16
+ function findWritableLayerOrThrow(config, layer) {
17
+ const localeDir = findLayerOrThrow(config, layer);
18
+ if (localeDir.aliasOf) throw new ToolError(`Layer "${layer}" is an alias of "${localeDir.aliasOf}". Modify the source layer "${localeDir.aliasOf}" instead.`, "LAYER_IS_ALIAS");
19
+ return localeDir;
20
+ }
21
+ /**
22
+ * Resolve the reference locale (requested or project default) and throw
23
+ * REFERENCE_LOCALE_NOT_FOUND when it does not exist.
24
+ */
25
+ function findReferenceLocaleOrThrow(config, requested) {
26
+ const refCode = requested ?? config.defaultLocale;
27
+ const refLocale = findLocaleImpl(config, refCode);
28
+ if (!refLocale) throw new ToolError(`Reference locale not found: "${refCode}". Available: ${config.locales.map((l) => l.code).join(", ")}. Pass a valid locale code as referenceLocale, or omit it to use the project default.`, "REFERENCE_LOCALE_NOT_FOUND");
29
+ return refLocale;
30
+ }
31
+ /**
32
+ * The locale directories an operation should scan: one named layer, or every
33
+ * non-alias layer. Alias layers are skipped because they point at another
34
+ * layer's files, so scanning both would count the same keys twice.
35
+ *
36
+ * Throws rather than returning empty: an operation with nothing to scan has no
37
+ * meaningful result, and a mistyped layer name should say so.
38
+ */
39
+ function resolveLayersToScan(config, layer) {
40
+ const layers = layer ? config.localeDirs.filter((d) => d.layer === layer) : config.localeDirs.filter((d) => !d.aliasOf);
41
+ if (layers.length === 0) {
42
+ if (layer) findLayerOrThrow(config, layer);
43
+ throw new ToolError("No locale directories found. Run discover to verify the project setup.", "LAYER_NOT_FOUND");
44
+ }
45
+ return layers;
46
+ }
47
+ function localeRefInfo(locale) {
48
+ return {
49
+ code: locale.code,
50
+ ...locale.language ? { language: locale.language } : {},
51
+ ...locale.file ? { file: locale.file } : {},
52
+ ...locale.name ? { name: locale.name } : {}
53
+ };
54
+ }
55
+ function localeAcceptedRefs(locale) {
56
+ return [...new Set([
57
+ locale.code,
58
+ locale.language,
59
+ locale.file
60
+ ].filter(Boolean))];
61
+ }
62
+ function formatLocaleChoices(locales) {
63
+ return locales.map((locale) => `- code: ${locale.code}${locale.language ? `, language: ${locale.language}` : ""}${locale.file ? `, file: ${locale.file}` : ""}${locale.name ? `, name: ${locale.name}` : ""}`).join("\n");
64
+ }
65
+ const stripJson = (ref) => ref.endsWith(".json") ? ref.slice(0, -5) : ref;
66
+ /**
67
+ * How well one of a locale's refs matches the requested one. Higher wins.
68
+ * Ranking matters rather than first-match: for "de-DE-formal", the informal
69
+ * `de` locale matches by containment (its language is `de-DE`) while the
70
+ * formal one matches exactly once `.json` is stripped from its file name.
71
+ * First-match order returned `de`, pointing the caller at the wrong locale —
72
+ * following that hint would write formal German into the informal file (#301).
73
+ */
74
+ function suggestionScore(locale, normalized) {
75
+ let best = 0;
76
+ for (const ref of localeAcceptedRefs(locale)) {
77
+ const candidate = stripJson(ref);
78
+ if (candidate === normalized) return 3;
79
+ if (candidate.toLowerCase() === normalized.toLowerCase()) best = Math.max(best, 2);
80
+ else if (candidate.includes(normalized) || normalized.includes(candidate)) best = Math.max(best, 1);
81
+ }
82
+ return best;
83
+ }
84
+ function findLocaleSuggestion(config, localeRef) {
85
+ const normalized = stripJson(localeRef);
86
+ let suggestion;
87
+ let bestScore = 0;
88
+ for (const locale of config.locales) {
89
+ const score = suggestionScore(locale, normalized);
90
+ if (score > bestScore) {
91
+ bestScore = score;
92
+ suggestion = locale;
93
+ }
94
+ }
95
+ if (!suggestion) return "";
96
+ return ` Did you mean ${localeAcceptedRefs(suggestion).map((ref) => `"${ref}"`).join(" or ")}?`;
97
+ }
98
+ /** Fields a locale ref may match, in resolution precedence order. */
99
+ const LOCALE_MATCH_FIELDS = [
100
+ "code",
101
+ "language",
102
+ "file"
103
+ ];
104
+ /**
105
+ * Resolve a locale ref with deliberate precedence: an exact `code` outranks a
106
+ * `language` tag, which outranks a `file` name.
107
+ *
108
+ * Codes are unique by construction; language tags are not — two locales can
109
+ * both declare `de-DE` (an informal and a formal German, say). Without
110
+ * precedence, a locale's unique code could be shadowed by a different locale's
111
+ * language tag purely through config ordering. Within one field a ref that
112
+ * still matches several locales is reported as ambiguous rather than silently
113
+ * resolved to whichever came first, because that choice depends on array order
114
+ * and can change under the caller without warning (#301).
115
+ */
116
+ function resolveLocaleRef(config, localeRef) {
117
+ for (const field of LOCALE_MATCH_FIELDS) {
118
+ const matches = config.locales.filter((locale) => locale[field] === localeRef);
119
+ const [first] = matches;
120
+ if (!first) continue;
121
+ if (matches.length === 1) return { locale: first };
122
+ return {
123
+ locale: first,
124
+ ambiguity: {
125
+ ref: localeRef,
126
+ matchedBy: field,
127
+ candidates: matches.map((m) => m.code),
128
+ resolvedTo: first.code
129
+ }
130
+ };
131
+ }
132
+ return {};
133
+ }
134
+ function findLocaleImpl(config, localeRef) {
135
+ return resolveLocaleRef(config, localeRef).locale;
136
+ }
137
+ /**
138
+ * Resolve the reference locale for scan operations: the requested ref or the
139
+ * project default. Throws LOCALE_NOT_FOUND listing the available codes.
140
+ */
141
+ function resolveReferenceLocale(config, requested) {
142
+ const localeCode = requested ?? config.defaultLocale;
143
+ const localeDef = findLocaleImpl(config, localeCode);
144
+ if (!localeDef) throw new ToolError(`Locale not found: "${localeCode}". Available: ${config.locales.map((l) => l.code).join(", ")}`, "LOCALE_NOT_FOUND");
145
+ return {
146
+ localeCode,
147
+ localeDef
148
+ };
149
+ }
150
+ function findLocaleOrThrow(config, localeRef) {
151
+ const locale = findLocaleImpl(config, localeRef);
152
+ if (!locale) throw new ToolError(`Locale not found: "${localeRef}".${findLocaleSuggestion(config, localeRef)}\nAvailable locales:\n${formatLocaleChoices(config.locales)}`, "LOCALE_NOT_FOUND");
153
+ return locale;
154
+ }
155
+ //#endregion
156
+ //#region src/llm/providers.ts
157
+ /**
158
+ * A classified provider failure. `auth` errors are not retryable (the caller
159
+ * should abort the run), `rate-limit` errors should be retried with backoff,
160
+ * and `provider` covers everything else (server errors, network, …).
161
+ * `config` marks an unusable provider setup and is raised while building the
162
+ * TranslateFn, before any request exists — so it surfaces from the command
163
+ * and never reaches the retry loop.
164
+ */
165
+ var TranslateProviderError = class extends Error {
166
+ kind;
167
+ status;
168
+ constructor(message, kind, status) {
169
+ super(message);
170
+ this.name = "TranslateProviderError";
171
+ this.kind = kind;
172
+ this.status = status;
173
+ }
174
+ };
175
+ /** Defensively extract an HTTP status code from an unknown error shape. */
176
+ function extractStatus(error) {
177
+ if (error === null || typeof error !== "object") return void 0;
178
+ const e = error;
179
+ if (typeof e.status === "number") return e.status;
180
+ if (e.response !== null && typeof e.response === "object") {
181
+ const status = e.response.status;
182
+ if (typeof status === "number") return status;
183
+ }
184
+ }
185
+ /**
186
+ * Classify a provider error into a TranslateProviderError:
187
+ * 401/403 → auth, 429 → rate-limit, anything else → provider.
188
+ * Already-classified errors pass through unchanged.
189
+ */
190
+ function classifyProviderError(error) {
191
+ if (error instanceof TranslateProviderError) return error;
192
+ const status = extractStatus(error);
193
+ return new TranslateProviderError(error instanceof Error ? error.message : String(error), status === 401 || status === 403 ? "auth" : status === 429 ? "rate-limit" : "provider", status);
194
+ }
195
+ /** Environment variable carrying the provider base URL override. */
196
+ const BASE_URL_ENV = "I18N_BASE_URL";
197
+ /**
198
+ * Resolve the provider base URL from its three sources, highest precedence
199
+ * first: an explicit flag, the I18N_BASE_URL env var, then the project
200
+ * config's `providerBaseUrl`.
201
+ *
202
+ * Blank values count as unset. Shells produce them routinely — `--baseUrl
203
+ * "$UNSET"` or an exported-but-empty variable — and a blank must not shadow a
204
+ * real endpoint configured further down the chain. A blank in the config file
205
+ * is a different case: it cannot arise by accident, so the strict schema
206
+ * rejects it at load time rather than letting it reach this function.
207
+ */
208
+ function resolveProviderBaseUrl(sources) {
209
+ for (const value of [
210
+ sources.flag,
211
+ sources.env,
212
+ sources.config
213
+ ]) if (typeof value === "string" && value.trim().length > 0) return value.trim();
214
+ }
215
+ const ENV_KEY_MAP = {
216
+ openai: "OPENAI_API_KEY",
217
+ anthropic: "ANTHROPIC_API_KEY",
218
+ google: "GEMINI_API_KEY"
219
+ };
220
+ function resolveApiKey(provider, configKey) {
221
+ if (configKey) return configKey;
222
+ const envKey = ENV_KEY_MAP[provider];
223
+ const key = process.env[envKey];
224
+ if (!key) throw new TranslateProviderError(`No API key found for provider "${provider}". Set ${envKey} environment variable or pass apiKey in config.`, "config");
225
+ return key;
226
+ }
227
+ /** Default endpoints, each overridable through the base URL sources. */
228
+ const DEFAULT_BASE_URL = {
229
+ openai: "https://api.openai.com/v1",
230
+ anthropic: "https://api.anthropic.com",
231
+ google: "https://generativelanguage.googleapis.com"
232
+ };
233
+ /**
234
+ * Per-request timeout, keeping the ten-minute default the provider SDKs
235
+ * applied before this transport existed. It is deliberately generous: every
236
+ * request asks for the same large token budget, and a slow endpoint — a
237
+ * self-hosted model behind a base URL above all — can take minutes to produce
238
+ * it. The ceiling only exists to stop a hung socket from stalling a run.
239
+ */
240
+ const REQUEST_TIMEOUT_MS = 600 * 1e3;
241
+ /** Join a base URL with a path, tolerating a trailing slash on the base. */
242
+ function joinUrl(baseUrl, path) {
243
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
244
+ }
245
+ /** Cap an untrusted provider body so it stays readable inside an error. */
246
+ function summarize(raw) {
247
+ const text = raw.trim();
248
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text;
249
+ }
250
+ /**
251
+ * Pull the human-readable message out of a provider error body. All three
252
+ * providers nest it under `error.message`; anything else — an HTML error page
253
+ * from a proxy, say — is reported verbatim but capped.
254
+ */
255
+ function errorDetail(raw) {
256
+ try {
257
+ const body = JSON.parse(raw);
258
+ if (body !== null && typeof body === "object") {
259
+ const { error, message } = body;
260
+ const nested = error !== null && typeof error === "object" ? error.message : error;
261
+ if (typeof nested === "string" && nested.trim() !== "") return nested;
262
+ if (typeof message === "string" && message.trim() !== "") return message;
263
+ }
264
+ } catch {}
265
+ return summarize(raw);
266
+ }
267
+ /**
268
+ * POST a JSON body and return the parsed response, mapping every failure onto
269
+ * a classified TranslateProviderError: an HTTP status keeps its meaning
270
+ * (401/403 auth, 429 rate limit), while network, timeout and malformed-body
271
+ * failures land on `provider` and are retried by the caller.
272
+ */
273
+ async function postJson(request) {
274
+ let response;
275
+ let raw;
276
+ try {
277
+ response = await fetch(request.url, {
278
+ method: "POST",
279
+ headers: {
280
+ "content-type": "application/json",
281
+ ...request.headers
282
+ },
283
+ body: JSON.stringify(request.body),
284
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
285
+ });
286
+ raw = await response.text();
287
+ } catch (error) {
288
+ if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) throw new TranslateProviderError(`Provider "${request.provider}" did not respond within ${REQUEST_TIMEOUT_MS / 1e3}s.`, "provider");
289
+ throw classifyProviderError(error);
290
+ }
291
+ if (!response.ok) {
292
+ const failure = /* @__PURE__ */ new Error(`Provider "${request.provider}" request failed (HTTP ${response.status}): ${errorDetail(raw)}`);
293
+ failure.status = response.status;
294
+ throw classifyProviderError(failure);
295
+ }
296
+ try {
297
+ return JSON.parse(raw);
298
+ } catch {
299
+ throw new TranslateProviderError(`Provider "${request.provider}" returned a non-JSON response: ${summarize(raw)}`, "provider", response.status);
300
+ }
301
+ }
302
+ /** Assemble a TranslateResponse, omitting `truncated` unless it is true. */
303
+ function toTranslateResponse(text, model, fallbackModel, truncated) {
304
+ return {
305
+ text,
306
+ model: model || fallbackModel,
307
+ ...truncated ? { truncated: true } : {}
308
+ };
309
+ }
310
+ function createOpenAiTranslateFn(config) {
311
+ const apiKey = resolveApiKey("openai", config.apiKey);
312
+ const url = joinUrl(config.baseUrl ?? DEFAULT_BASE_URL.openai, "/chat/completions");
313
+ return async (opts) => {
314
+ const response = await postJson({
315
+ provider: "openai",
316
+ url,
317
+ headers: { authorization: `Bearer ${apiKey}` },
318
+ body: {
319
+ model: config.model,
320
+ messages: [{
321
+ role: "system",
322
+ content: opts.systemPrompt
323
+ }, {
324
+ role: "user",
325
+ content: opts.userMessage
326
+ }],
327
+ max_tokens: opts.maxTokens,
328
+ temperature: 0,
329
+ response_format: { type: "json_object" }
330
+ }
331
+ });
332
+ const choice = response.choices?.[0];
333
+ return toTranslateResponse(choice?.message?.content ?? "", response.model, config.model, choice?.finish_reason === "length");
334
+ };
335
+ }
336
+ function createAnthropicTranslateFn(config) {
337
+ const apiKey = resolveApiKey("anthropic", config.apiKey);
338
+ const url = joinUrl(config.baseUrl ?? DEFAULT_BASE_URL.anthropic, "/v1/messages");
339
+ return async (opts) => {
340
+ const response = await postJson({
341
+ provider: "anthropic",
342
+ url,
343
+ headers: {
344
+ "x-api-key": apiKey,
345
+ "anthropic-version": "2023-06-01"
346
+ },
347
+ body: {
348
+ model: config.model,
349
+ system: opts.systemPrompt,
350
+ messages: [{
351
+ role: "user",
352
+ content: opts.userMessage
353
+ }],
354
+ max_tokens: opts.maxTokens,
355
+ temperature: 0
356
+ }
357
+ });
358
+ return toTranslateResponse((response.content ?? []).filter((block) => block?.type === "text").map((block) => block.text ?? "").join(""), response.model, config.model, response.stop_reason === "max_tokens");
359
+ };
360
+ }
361
+ function createGoogleTranslateFn(config) {
362
+ const apiKey = resolveApiKey("google", config.apiKey);
363
+ const model = config.model.replace(/^models\//, "");
364
+ const url = joinUrl(config.baseUrl ?? DEFAULT_BASE_URL.google, `/v1beta/models/${model}:generateContent`);
365
+ return async (opts) => {
366
+ const response = await postJson({
367
+ provider: "google",
368
+ url,
369
+ headers: { "x-goog-api-key": apiKey },
370
+ body: {
371
+ contents: [{
372
+ role: "user",
373
+ parts: [{ text: opts.userMessage }]
374
+ }],
375
+ systemInstruction: { parts: [{ text: opts.systemPrompt }] },
376
+ generationConfig: {
377
+ maxOutputTokens: opts.maxTokens,
378
+ temperature: 0,
379
+ responseMimeType: "application/json"
380
+ }
381
+ }
382
+ });
383
+ const candidate = response.candidates?.[0];
384
+ return toTranslateResponse((candidate?.content?.parts ?? []).map((part) => part.text ?? "").join(""), response.modelVersion, config.model, candidate?.finishReason === "MAX_TOKENS");
385
+ };
386
+ }
387
+ /**
388
+ * Create a TranslateFn from an LLM provider config. Every provider is called
389
+ * over plain HTTP, so nothing beyond the CLI has to be installed.
390
+ * Throws if the API key is missing.
391
+ */
392
+ async function createTranslateFn(config) {
393
+ switch (config.provider) {
394
+ case "openai": return createOpenAiTranslateFn(config);
395
+ case "anthropic": return createAnthropicTranslateFn(config);
396
+ case "google": return createGoogleTranslateFn(config);
397
+ default: {
398
+ const _exhaustive = config.provider;
399
+ throw new Error(`Unknown provider: ${_exhaustive}`);
400
+ }
401
+ }
402
+ }
403
+ //#endregion
404
+ export { resolveProviderBaseUrl as a, findLocaleOrThrow as c, findWritableLayerOrThrow as d, localeRefInfo as f, resolveReferenceLocale as h, createTranslateFn as i, findLocaleSuggestion as l, resolveLocaleRef as m, TranslateProviderError as n, findLayerOrThrow as o, resolveLayersToScan as p, classifyProviderError as r, findLocaleImpl as s, BASE_URL_ENV as t, findReferenceLocaleOrThrow as u };
405
+
406
+ //# sourceMappingURL=providers-BbVPelvp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"providers-BbVPelvp.js","names":[],"sources":["../src/core/shared.ts","../src/llm/providers.ts"],"sourcesContent":["/**\n * Shared locale/layer lookup helpers used across the core operation modules.\n */\n\nimport type { I18nConfig, LocaleDefinition, LocaleDir } from '../config/types.js'\nimport { ToolError } from '../utils/errors.js'\n\nimport type { LocaleRefInfo } from './types.js'\n\n/**\n * Look up a locale directory by layer name and throw LAYER_NOT_FOUND with fuzzy\n * matching hints if the name is not found.\n */\nexport function findLayerOrThrow(config: I18nConfig, layer: string): LocaleDir {\n const localeDir = config.localeDirs.find(d => d.layer === layer)\n if (!localeDir) {\n const available = config.localeDirs.map(d => d.layer).join(', ')\n const layerRule = config.projectConfig?.layerRules?.find(r => r.layer === layer)\n const fuzzyHint = layerRule\n ? ` Note: \"${layer}\" matches a layerRules entry in .i18n-mcp.json but is not an internal layer name.`\n : ''\n throw new ToolError(\n `Layer not found: \"${layer}\". Available: ${available}.${fuzzyHint} Use discover to see all layers.`,\n 'LAYER_NOT_FOUND',\n )\n }\n return localeDir\n}\n\n/**\n * Look up a layer that will be written to: same as findLayerOrThrow, but also\n * rejects alias layers (writes must target the source layer).\n */\nexport function findWritableLayerOrThrow(config: I18nConfig, layer: string): LocaleDir {\n const localeDir = findLayerOrThrow(config, layer)\n if (localeDir.aliasOf) {\n throw new ToolError(`Layer \"${layer}\" is an alias of \"${localeDir.aliasOf}\". Modify the source layer \"${localeDir.aliasOf}\" instead.`, 'LAYER_IS_ALIAS')\n }\n return localeDir\n}\n\n/**\n * Resolve the reference locale (requested or project default) and throw\n * REFERENCE_LOCALE_NOT_FOUND when it does not exist.\n */\nexport function findReferenceLocaleOrThrow(config: I18nConfig, requested?: string): LocaleDefinition {\n const refCode = requested ?? config.defaultLocale\n const refLocale = findLocaleImpl(config, refCode)\n if (!refLocale) {\n throw new ToolError(`Reference locale not found: \"${refCode}\". Available: ${config.locales.map(l => l.code).join(', ')}. Pass a valid locale code as referenceLocale, or omit it to use the project default.`, 'REFERENCE_LOCALE_NOT_FOUND')\n }\n return refLocale\n}\n\n/**\n * The locale directories an operation should scan: one named layer, or every\n * non-alias layer. Alias layers are skipped because they point at another\n * layer's files, so scanning both would count the same keys twice.\n *\n * Throws rather than returning empty: an operation with nothing to scan has no\n * meaningful result, and a mistyped layer name should say so.\n */\nexport function resolveLayersToScan(config: I18nConfig, layer?: string): LocaleDir[] {\n const layers = layer\n ? config.localeDirs.filter(d => d.layer === layer)\n : config.localeDirs.filter(d => !d.aliasOf)\n\n if (layers.length === 0) {\n // Surfaces the better \"layer not found, here are the valid ones\" error.\n if (layer) findLayerOrThrow(config, layer)\n throw new ToolError(\n 'No locale directories found. Run discover to verify the project setup.',\n 'LAYER_NOT_FOUND',\n )\n }\n return layers\n}\n\nexport function localeRefInfo(locale: LocaleDefinition): LocaleRefInfo {\n return {\n code: locale.code,\n ...(locale.language ? { language: locale.language } : {}),\n ...(locale.file ? { file: locale.file } : {}),\n ...(locale.name ? { name: locale.name } : {}),\n }\n}\n\nfunction localeAcceptedRefs(locale: LocaleDefinition): string[] {\n // Deduped: code and language are frequently the same string (a generic\n // adapter derives both from the filename), and \"de\" or \"de\" or \"de.json\"\n // reads like a bug in the suggestion rather than one locale's three refs.\n return [...new Set([locale.code, locale.language, locale.file].filter(Boolean) as string[])]\n}\n\nfunction formatLocaleChoices(locales: LocaleDefinition[]): string {\n return locales\n .map(locale => `- code: ${locale.code}${locale.language ? `, language: ${locale.language}` : ''}${locale.file ? `, file: ${locale.file}` : ''}${locale.name ? `, name: ${locale.name}` : ''}`)\n .join('\\n')\n}\n\nconst stripJson = (ref: string) => (ref.endsWith('.json') ? ref.slice(0, -5) : ref)\n\n/**\n * How well one of a locale's refs matches the requested one. Higher wins.\n * Ranking matters rather than first-match: for \"de-DE-formal\", the informal\n * `de` locale matches by containment (its language is `de-DE`) while the\n * formal one matches exactly once `.json` is stripped from its file name.\n * First-match order returned `de`, pointing the caller at the wrong locale —\n * following that hint would write formal German into the informal file (#301).\n */\nfunction suggestionScore(locale: LocaleDefinition, normalized: string): number {\n let best = 0\n for (const ref of localeAcceptedRefs(locale)) {\n const candidate = stripJson(ref)\n if (candidate === normalized) return 3\n if (candidate.toLowerCase() === normalized.toLowerCase()) best = Math.max(best, 2)\n else if (candidate.includes(normalized) || normalized.includes(candidate)) best = Math.max(best, 1)\n }\n return best\n}\n\nexport function findLocaleSuggestion(config: I18nConfig, localeRef: string): string {\n const normalized = stripJson(localeRef)\n\n let suggestion: LocaleDefinition | undefined\n let bestScore = 0\n for (const locale of config.locales) {\n const score = suggestionScore(locale, normalized)\n if (score > bestScore) {\n bestScore = score\n suggestion = locale\n }\n }\n if (!suggestion) return ''\n\n const refs = localeAcceptedRefs(suggestion).map(ref => `\"${ref}\"`).join(' or ')\n return ` Did you mean ${refs}?`\n}\n\n/** Fields a locale ref may match, in resolution precedence order. */\nconst LOCALE_MATCH_FIELDS = ['code', 'language', 'file'] as const\nexport type LocaleMatchField = (typeof LOCALE_MATCH_FIELDS)[number]\n\nexport interface LocaleRefAmbiguity {\n ref: string\n /** The field that matched more than one locale. */\n matchedBy: LocaleMatchField\n /** Codes of every locale the ref matched, in config order. */\n candidates: string[]\n /** The one that was used — the first candidate. */\n resolvedTo: string\n}\n\nexport interface LocaleRefResolution {\n locale?: LocaleDefinition\n ambiguity?: LocaleRefAmbiguity\n}\n\n/**\n * Resolve a locale ref with deliberate precedence: an exact `code` outranks a\n * `language` tag, which outranks a `file` name.\n *\n * Codes are unique by construction; language tags are not — two locales can\n * both declare `de-DE` (an informal and a formal German, say). Without\n * precedence, a locale's unique code could be shadowed by a different locale's\n * language tag purely through config ordering. Within one field a ref that\n * still matches several locales is reported as ambiguous rather than silently\n * resolved to whichever came first, because that choice depends on array order\n * and can change under the caller without warning (#301).\n */\nexport function resolveLocaleRef(config: I18nConfig, localeRef: string): LocaleRefResolution {\n for (const field of LOCALE_MATCH_FIELDS) {\n const matches = config.locales.filter(locale => locale[field] === localeRef)\n const [first] = matches\n if (!first) continue\n if (matches.length === 1) return { locale: first }\n return {\n locale: first,\n ambiguity: {\n ref: localeRef,\n matchedBy: field,\n candidates: matches.map(m => m.code),\n resolvedTo: first.code,\n },\n }\n }\n return {}\n}\n\nexport function findLocaleImpl(config: I18nConfig, localeRef: string) {\n return resolveLocaleRef(config, localeRef).locale\n}\n\n/**\n * Resolve the reference locale for scan operations: the requested ref or the\n * project default. Throws LOCALE_NOT_FOUND listing the available codes.\n */\nexport function resolveReferenceLocale(\n config: I18nConfig,\n requested?: string,\n): { localeCode: string; localeDef: LocaleDefinition } {\n const localeCode = requested ?? config.defaultLocale\n const localeDef = findLocaleImpl(config, localeCode)\n if (!localeDef) {\n throw new ToolError(\n `Locale not found: \"${localeCode}\". Available: ${config.locales.map(l => l.code).join(', ')}`,\n 'LOCALE_NOT_FOUND',\n )\n }\n return { localeCode, localeDef }\n}\n\nexport function findLocaleOrThrow(config: I18nConfig, localeRef: string): LocaleDefinition {\n const locale = findLocaleImpl(config, localeRef)\n if (!locale) {\n throw new ToolError(\n `Locale not found: \"${localeRef}\".${findLocaleSuggestion(config, localeRef)}\\nAvailable locales:\\n${formatLocaleChoices(config.locales)}`,\n 'LOCALE_NOT_FOUND',\n )\n }\n return locale\n}\n","import type { TranslateFn, TranslateRequest, TranslateResponse } from '../core/types.js'\n\nexport type LlmProvider = 'openai' | 'anthropic' | 'google'\n\n// ─── Error classification ───────────────────────────────────────\n\n/** How a provider failure should be handled by the caller. */\nexport type TranslateProviderErrorKind = 'auth' | 'rate-limit' | 'provider' | 'config'\n\n/**\n * A classified provider failure. `auth` errors are not retryable (the caller\n * should abort the run), `rate-limit` errors should be retried with backoff,\n * and `provider` covers everything else (server errors, network, …).\n * `config` marks an unusable provider setup and is raised while building the\n * TranslateFn, before any request exists — so it surfaces from the command\n * and never reaches the retry loop.\n */\nexport class TranslateProviderError extends Error {\n public readonly kind: TranslateProviderErrorKind\n public readonly status?: number\n\n constructor(message: string, kind: TranslateProviderErrorKind, status?: number) {\n super(message)\n this.name = 'TranslateProviderError'\n this.kind = kind\n this.status = status\n }\n}\n\n/** Defensively extract an HTTP status code from an unknown error shape. */\nfunction extractStatus(error: unknown): number | undefined {\n if (error === null || typeof error !== 'object') return undefined\n const e = error as { status?: unknown, response?: unknown }\n if (typeof e.status === 'number') return e.status\n if (e.response !== null && typeof e.response === 'object') {\n const status = (e.response as { status?: unknown }).status\n if (typeof status === 'number') return status\n }\n return undefined\n}\n\n/**\n * Classify a provider error into a TranslateProviderError:\n * 401/403 → auth, 429 → rate-limit, anything else → provider.\n * Already-classified errors pass through unchanged.\n */\nexport function classifyProviderError(error: unknown): TranslateProviderError {\n if (error instanceof TranslateProviderError) return error\n const status = extractStatus(error)\n const message = error instanceof Error ? error.message : String(error)\n const kind: TranslateProviderErrorKind\n = status === 401 || status === 403 ? 'auth'\n : status === 429 ? 'rate-limit'\n : 'provider'\n return new TranslateProviderError(message, kind, status)\n}\n\nexport interface LlmProviderConfig {\n provider: LlmProvider\n model: string\n /** Override API key. Falls back to env vars */\n apiKey?: string\n /** Base URL override for proxies / compatible APIs */\n baseUrl?: string\n}\n\n/** Environment variable carrying the provider base URL override. */\nexport const BASE_URL_ENV = 'I18N_BASE_URL'\n\n/**\n * Resolve the provider base URL from its three sources, highest precedence\n * first: an explicit flag, the I18N_BASE_URL env var, then the project\n * config's `providerBaseUrl`.\n *\n * Blank values count as unset. Shells produce them routinely — `--baseUrl\n * \"$UNSET\"` or an exported-but-empty variable — and a blank must not shadow a\n * real endpoint configured further down the chain. A blank in the config file\n * is a different case: it cannot arise by accident, so the strict schema\n * rejects it at load time rather than letting it reach this function.\n */\nexport function resolveProviderBaseUrl(sources: {\n flag?: string\n env?: string\n config?: string\n}): string | undefined {\n for (const value of [sources.flag, sources.env, sources.config]) {\n if (typeof value === 'string' && value.trim().length > 0) return value.trim()\n }\n return undefined\n}\n\nconst ENV_KEY_MAP: Record<LlmProvider, string> = {\n openai: 'OPENAI_API_KEY',\n anthropic: 'ANTHROPIC_API_KEY',\n google: 'GEMINI_API_KEY',\n}\n\nfunction resolveApiKey(provider: LlmProvider, configKey?: string): string {\n if (configKey) return configKey\n const envKey = ENV_KEY_MAP[provider]\n const key = process.env[envKey]\n if (!key) {\n throw new TranslateProviderError(\n `No API key found for provider \"${provider}\". Set ${envKey} environment variable or pass apiKey in config.`,\n 'config',\n )\n }\n return key\n}\n\n// ─── HTTP transport ─────────────────────────────────────────────\n\n/** Default endpoints, each overridable through the base URL sources. */\nconst DEFAULT_BASE_URL: Record<LlmProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com',\n google: 'https://generativelanguage.googleapis.com',\n}\n\n/**\n * Per-request timeout, keeping the ten-minute default the provider SDKs\n * applied before this transport existed. It is deliberately generous: every\n * request asks for the same large token budget, and a slow endpoint — a\n * self-hosted model behind a base URL above all — can take minutes to produce\n * it. The ceiling only exists to stop a hung socket from stalling a run.\n */\nconst REQUEST_TIMEOUT_MS = 10 * 60 * 1000\n\n/** Join a base URL with a path, tolerating a trailing slash on the base. */\nfunction joinUrl(baseUrl: string, path: string): string {\n return `${baseUrl.replace(/\\/+$/, '')}${path}`\n}\n\n/** Cap an untrusted provider body so it stays readable inside an error. */\nfunction summarize(raw: string): string {\n const text = raw.trim()\n return text.length > 300 ? `${text.slice(0, 300)}…` : text\n}\n\n/**\n * Pull the human-readable message out of a provider error body. All three\n * providers nest it under `error.message`; anything else — an HTML error page\n * from a proxy, say — is reported verbatim but capped.\n */\nfunction errorDetail(raw: string): string {\n try {\n const body: unknown = JSON.parse(raw)\n if (body !== null && typeof body === 'object') {\n const { error, message } = body as { error?: unknown, message?: unknown }\n const nested = error !== null && typeof error === 'object'\n ? (error as { message?: unknown }).message\n : error\n if (typeof nested === 'string' && nested.trim() !== '') return nested\n if (typeof message === 'string' && message.trim() !== '') return message\n }\n } catch {\n // Not JSON — the raw body is the best detail available.\n }\n return summarize(raw)\n}\n\ninterface ProviderRequest {\n provider: LlmProvider\n url: string\n headers: Record<string, string>\n body: unknown\n}\n\n/**\n * POST a JSON body and return the parsed response, mapping every failure onto\n * a classified TranslateProviderError: an HTTP status keeps its meaning\n * (401/403 auth, 429 rate limit), while network, timeout and malformed-body\n * failures land on `provider` and are retried by the caller.\n */\nasync function postJson<T>(request: ProviderRequest): Promise<T> {\n let response: Response\n let raw: string\n try {\n response = await fetch(request.url, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...request.headers },\n body: JSON.stringify(request.body),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n raw = await response.text()\n } catch (error) {\n if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {\n throw new TranslateProviderError(\n `Provider \"${request.provider}\" did not respond within ${REQUEST_TIMEOUT_MS / 1000}s.`,\n 'provider',\n )\n }\n throw classifyProviderError(error)\n }\n\n if (!response.ok) {\n const failure = new Error(\n `Provider \"${request.provider}\" request failed (HTTP ${response.status}): ${errorDetail(raw)}`,\n ) as Error & { status: number }\n failure.status = response.status\n throw classifyProviderError(failure)\n }\n\n try {\n return JSON.parse(raw) as T\n } catch {\n throw new TranslateProviderError(\n `Provider \"${request.provider}\" returned a non-JSON response: ${summarize(raw)}`,\n 'provider',\n response.status,\n )\n }\n}\n\n/** Assemble a TranslateResponse, omitting `truncated` unless it is true. */\nfunction toTranslateResponse(text: string, model: string | undefined, fallbackModel: string, truncated: boolean): TranslateResponse {\n return { text, model: model || fallbackModel, ...(truncated ? { truncated: true } : {}) }\n}\n\n// ─── Providers ──────────────────────────────────────────────────\n\ninterface OpenAiChatResponse {\n model?: string\n choices?: Array<{\n message?: { content?: string | null }\n finish_reason?: string\n }>\n}\n\nfunction createOpenAiTranslateFn(config: LlmProviderConfig): TranslateFn {\n const apiKey = resolveApiKey('openai', config.apiKey)\n const url = joinUrl(config.baseUrl ?? DEFAULT_BASE_URL.openai, '/chat/completions')\n\n return async (opts: TranslateRequest): Promise<TranslateResponse> => {\n const response = await postJson<OpenAiChatResponse>({\n provider: 'openai',\n url,\n headers: { authorization: `Bearer ${apiKey}` },\n body: {\n model: config.model,\n messages: [\n { role: 'system', content: opts.systemPrompt },\n { role: 'user', content: opts.userMessage },\n ],\n max_tokens: opts.maxTokens,\n temperature: 0,\n response_format: { type: 'json_object' },\n },\n })\n\n const choice = response.choices?.[0]\n return toTranslateResponse(\n choice?.message?.content ?? '',\n response.model,\n config.model,\n choice?.finish_reason === 'length',\n )\n }\n}\n\ninterface AnthropicMessagesResponse {\n model?: string\n stop_reason?: string\n content?: Array<{ type?: string, text?: string }>\n}\n\nfunction createAnthropicTranslateFn(config: LlmProviderConfig): TranslateFn {\n const apiKey = resolveApiKey('anthropic', config.apiKey)\n const url = joinUrl(config.baseUrl ?? DEFAULT_BASE_URL.anthropic, '/v1/messages')\n\n return async (opts: TranslateRequest): Promise<TranslateResponse> => {\n const response = await postJson<AnthropicMessagesResponse>({\n provider: 'anthropic',\n url,\n headers: {\n 'x-api-key': apiKey,\n // Pinned: the Messages API requires a version and an unpinned one would\n // let a future breaking release reshape the response under us.\n 'anthropic-version': '2023-06-01',\n },\n body: {\n model: config.model,\n system: opts.systemPrompt,\n messages: [{ role: 'user', content: opts.userMessage }],\n max_tokens: opts.maxTokens,\n temperature: 0,\n },\n })\n\n // A reply arrives as a list of blocks; only the text ones carry the JSON.\n const text = (response.content ?? [])\n .filter(block => block?.type === 'text')\n .map(block => block.text ?? '')\n .join('')\n return toTranslateResponse(text, response.model, config.model, response.stop_reason === 'max_tokens')\n }\n}\n\ninterface GoogleGenerateContentResponse {\n modelVersion?: string\n candidates?: Array<{\n content?: { parts?: Array<{ text?: string }> }\n finishReason?: string\n }>\n}\n\nfunction createGoogleTranslateFn(config: LlmProviderConfig): TranslateFn {\n const apiKey = resolveApiKey('google', config.apiKey)\n // The REST path already carries the `models/` prefix, so accept a model name\n // written either way rather than producing `/models/models/gemini-…`.\n const model = config.model.replace(/^models\\//, '')\n const url = joinUrl(config.baseUrl ?? DEFAULT_BASE_URL.google, `/v1beta/models/${model}:generateContent`)\n\n return async (opts: TranslateRequest): Promise<TranslateResponse> => {\n const response = await postJson<GoogleGenerateContentResponse>({\n provider: 'google',\n url,\n headers: { 'x-goog-api-key': apiKey },\n body: {\n contents: [{ role: 'user', parts: [{ text: opts.userMessage }] }],\n systemInstruction: { parts: [{ text: opts.systemPrompt }] },\n generationConfig: {\n maxOutputTokens: opts.maxTokens,\n temperature: 0,\n responseMimeType: 'application/json',\n },\n },\n })\n\n const candidate = response.candidates?.[0]\n const text = (candidate?.content?.parts ?? []).map(part => part.text ?? '').join('')\n return toTranslateResponse(text, response.modelVersion, config.model, candidate?.finishReason === 'MAX_TOKENS')\n }\n}\n\n/**\n * Create a TranslateFn from an LLM provider config. Every provider is called\n * over plain HTTP, so nothing beyond the CLI has to be installed.\n * Throws if the API key is missing.\n */\nexport async function createTranslateFn(config: LlmProviderConfig): Promise<TranslateFn> {\n switch (config.provider) {\n case 'openai':\n return createOpenAiTranslateFn(config)\n case 'anthropic':\n return createAnthropicTranslateFn(config)\n case 'google':\n return createGoogleTranslateFn(config)\n default: {\n const _exhaustive: never = config.provider\n throw new Error(`Unknown provider: ${_exhaustive}`)\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ import { r as ToolError } from "./errors-coI1dhw1.js";
2
+ import { relative, resolve } from "node:path";
3
+ //#region src/core/report.ts
4
+ /**
5
+ * Report-file plumbing: path validation and resolution for tools that
6
+ * support writing their output to a JSON report file.
7
+ */
8
+ const DEFAULT_REPORT_DIR = ".i18n-reports";
9
+ function validateReportPath(baseDir, absPath) {
10
+ const normalizedBase = resolve(baseDir);
11
+ const rel = relative(normalizedBase, resolve(absPath));
12
+ if (rel.startsWith("..") || rel === "") throw new ToolError(`Report path "${absPath}" resolves outside the project directory. Path must stay within "${normalizedBase}".`, "INVALID_REPORT_PATH");
13
+ }
14
+ /**
15
+ * Resolve a caller-supplied outputFile against the project dir. Relative
16
+ * paths must anchor to `dir`, not the process cwd — otherwise
17
+ * `--output-file report.json --project-dir /x` run from elsewhere can never
18
+ * pass the in-project guard. Absolute paths pass through unchanged; either
19
+ * way the result must stay within the project dir.
20
+ */
21
+ function resolveOutputFile(dir, outputFile) {
22
+ if (!outputFile) return void 0;
23
+ const absPath = resolve(dir, outputFile);
24
+ validateReportPath(dir, absPath);
25
+ return absPath;
26
+ }
27
+ function resolveReportFilePath(config, dir, toolName) {
28
+ const reportOutput = config.projectConfig?.reportOutput;
29
+ if (!reportOutput) return void 0;
30
+ const absPath = resolve(dir, reportOutput === true ? DEFAULT_REPORT_DIR : reportOutput, `${toolName}.json`);
31
+ validateReportPath(dir, absPath);
32
+ return absPath;
33
+ }
34
+ //#endregion
35
+ export { resolveReportFilePath as n, validateReportPath as r, resolveOutputFile as t };
36
+
37
+ //# sourceMappingURL=report-By1-5JtO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report-By1-5JtO.js","names":[],"sources":["../src/core/report.ts"],"sourcesContent":["/**\n * Report-file plumbing: path validation and resolution for tools that\n * support writing their output to a JSON report file.\n */\n\nimport { resolve, relative } from 'node:path'\n\nimport type { I18nConfig } from '../config/types.js'\nimport { ToolError } from '../utils/errors.js'\n\nconst DEFAULT_REPORT_DIR = '.i18n-reports'\n\nexport function validateReportPath(baseDir: string, absPath: string): void {\n const normalizedBase = resolve(baseDir)\n const normalizedPath = resolve(absPath)\n const rel = relative(normalizedBase, normalizedPath)\n if (rel.startsWith('..') || rel === '') {\n throw new ToolError(\n `Report path \"${absPath}\" resolves outside the project directory. Path must stay within \"${normalizedBase}\".`,\n 'INVALID_REPORT_PATH',\n )\n }\n}\n\n/**\n * Resolve a caller-supplied outputFile against the project dir. Relative\n * paths must anchor to `dir`, not the process cwd — otherwise\n * `--output-file report.json --project-dir /x` run from elsewhere can never\n * pass the in-project guard. Absolute paths pass through unchanged; either\n * way the result must stay within the project dir.\n */\nexport function resolveOutputFile(\n dir: string,\n outputFile: string | undefined,\n): string | undefined {\n if (!outputFile) return undefined\n const absPath = resolve(dir, outputFile)\n validateReportPath(dir, absPath)\n return absPath\n}\n\nexport function resolveReportFilePath(\n config: I18nConfig,\n dir: string,\n toolName: string,\n): string | undefined {\n const reportOutput = config.projectConfig?.reportOutput\n if (!reportOutput) return undefined\n const relDir = reportOutput === true ? DEFAULT_REPORT_DIR : reportOutput\n const absPath = resolve(dir, relDir, `${toolName}.json`)\n validateReportPath(dir, absPath)\n return absPath\n}\n"],"mappings":";;;;;;;AAUA,MAAM,qBAAqB;AAE3B,SAAgB,mBAAmB,SAAiB,SAAuB;CACzE,MAAM,iBAAiB,QAAQ,QAAQ;CAEvC,MAAM,MAAM,SAAS,gBADE,QAAQ,QAAQ,CACa;AACpD,KAAI,IAAI,WAAW,KAAK,IAAI,QAAQ,GAClC,OAAM,IAAI,UACR,gBAAgB,QAAQ,mEAAmE,eAAe,KAC1G,sBACD;;;;;;;;;AAWL,SAAgB,kBACd,KACA,YACoB;AACpB,KAAI,CAAC,WAAY,QAAO,KAAA;CACxB,MAAM,UAAU,QAAQ,KAAK,WAAW;AACxC,oBAAmB,KAAK,QAAQ;AAChC,QAAO;;AAGT,SAAgB,sBACd,QACA,KACA,UACoB;CACpB,MAAM,eAAe,OAAO,eAAe;AAC3C,KAAI,CAAC,aAAc,QAAO,KAAA;CAE1B,MAAM,UAAU,QAAQ,KADT,iBAAiB,OAAO,qBAAqB,cACvB,GAAG,SAAS,OAAO;AACxD,oBAAmB,KAAK,QAAQ;AAChC,QAAO"}
@@ -0,0 +1,2 @@
1
+ import { n as resolveReportFilePath, r as validateReportPath, t as resolveOutputFile } from "./report-By1-5JtO.js";
2
+ export { resolveOutputFile, resolveReportFilePath, validateReportPath };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@the-i18n-kit/cli",
3
- "version": "5.0.0",
4
- "description": "Find missing translations, remove dead keys, rename across all locales at once — for Nuxt, Laravel, Vue, React/Next.js, or any project with JSON/PHP locale files",
3
+ "version": "7.0.0",
4
+ "description": "Find missing translations, remove dead keys, rename across all locales at once — for Nuxt, Laravel, Vue, React/Next.js, or any project with JSON, YAML or PHP locale files",
5
5
  "keywords": [
6
6
  "i18n",
7
7
  "internationalization",
@@ -41,12 +41,6 @@
41
41
  "files": [
42
42
  "dist"
43
43
  ],
44
- "scripts": {
45
- "build": "tsdown",
46
- "dev": "tsdown --watch",
47
- "test": "vitest run",
48
- "typecheck": "tsc --noEmit"
49
- },
50
44
  "dependencies": {
51
45
  "citty": "^0.2.2",
52
46
  "consola": "^3.4.2",
@@ -54,29 +48,18 @@
54
48
  "oxc-parser": "^0.144.0",
55
49
  "sort-keys": "^6.0.0",
56
50
  "tinyglobby": "^0.2.15",
51
+ "yaml": "^2.8.2",
57
52
  "zod": "^4.3.6"
58
53
  },
59
54
  "peerDependencies": {
60
- "@anthropic-ai/sdk": "^0.30.0",
61
- "@google/genai": "^2.0.0",
62
55
  "@nuxt/kit": "^3.0.0 || ^4.0.0",
63
- "openai": "^4.0.0 || ^5.0.0",
64
56
  "php-array-reader": "^2.1.3",
65
57
  "php-parser": "^3.2.0"
66
58
  },
67
59
  "peerDependenciesMeta": {
68
- "@anthropic-ai/sdk": {
69
- "optional": true
70
- },
71
- "@google/genai": {
72
- "optional": true
73
- },
74
60
  "@nuxt/kit": {
75
61
  "optional": true
76
62
  },
77
- "openai": {
78
- "optional": true
79
- },
80
63
  "php-array-reader": {
81
64
  "optional": true
82
65
  },
@@ -90,7 +73,12 @@
90
73
  "php-array-reader": "^2.1.3",
91
74
  "php-parser": "^3.7.0",
92
75
  "tsdown": "^0.12.0",
93
- "vitest": "^3.2.0",
94
- "yaml": "2.8.2"
76
+ "vitest": "^3.2.0"
77
+ },
78
+ "scripts": {
79
+ "build": "tsdown",
80
+ "dev": "tsdown --watch",
81
+ "test": "vitest run",
82
+ "typecheck": "tsc --noEmit"
95
83
  }
96
- }
84
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"_shared-DFyy3sh8.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 * CI gates this command evaluates. The factory reads the requesting flag off\n * args and evaluates every gate uniformly, so no command grows bespoke exit\n * logic. Declaring a flagged gate does not add its flag — pair each spec\n * with an entry in `args`. A spec without a flag is always evaluated.\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 // Retained on the definition, not only consumed here: the generated CLI\n // reference has to state which commands fail a build on findings, and a gate\n // with no flag leaves no trace in the arg descriptions to infer it from.\n return withGates(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 decision = resolveExitCode(result, requestedGates(opts.gates ?? [], args), isTotalFailure(result))\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, opts.gates ?? [])\n}\n\n/**\n * A command definition carrying the gates its factory evaluates. Read\n * structurally by the reference generator rather than by importing this type,\n * which would make the docs build depend on the CLI's internals.\n */\ninterface GatedCommandDef extends CommandDef {\n gates: GateSpec[]\n}\n\nfunction withGates(def: CommandDef, gates: GateSpec[]): GatedCommandDef {\n return Object.assign(def, { gates })\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 evaluates. `counter` is the field of `result.summary`\n * carrying the observed value.\n *\n * The two shapes are a union rather than one type with optional halves so the\n * invariants hold at compile time: a flagged gate always has a flag to read\n * its name and threshold from, and a flagless one always carries both itself.\n * Stated as options, `{ counter, threshold }` type-checks and then has no name\n * to report the gate under.\n */\nexport type GateSpec = FlaggedGateSpec | AlwaysOnGateSpec\n\ninterface GateSpecBase {\n counter: string\n /** 'above' trips when observed > threshold (default); 'below' when observed < threshold. */\n direction?: 'above' | 'below'\n}\n\n/**\n * Requested by a flag, and evaluated only when that flag is passed. Omitting\n * `threshold` takes it from the flag's own value, so a boolean flag pairs with\n * `threshold: 0` and a numeric one (`--fail-under 90`) omits it.\n */\nexport interface FlaggedGateSpec extends GateSpecBase {\n flag: string\n name?: never\n threshold?: number\n}\n\n/**\n * Always evaluated, for findings that are a defect rather than a threshold — a\n * key that renders raw in production is not something you opt into caring\n * about. It still reports as a gate: the run succeeded, and what it found is\n * what you are being told about.\n */\nexport interface AlwaysOnGateSpec extends GateSpecBase {\n flag?: never\n name: string\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 resolved = spec.flag === undefined\n ? { name: spec.name, threshold: spec.threshold }\n : { name: kebabCase(spec.flag), threshold: thresholdFromFlag(spec, args) }\n if (resolved.threshold === undefined || !Number.isFinite(resolved.threshold)) continue\n requested.push({\n name: resolved.name,\n counter: spec.counter,\n direction: spec.direction ?? 'above',\n threshold: resolved.threshold,\n })\n }\n return requested\n}\n\n/** The gate's threshold, or undefined when this invocation did not ask for it. */\nfunction thresholdFromFlag(spec: FlaggedGateSpec, args: Record<string, unknown>): number | undefined {\n const raw = args[spec.flag]\n if (raw === undefined || raw === null || raw === false || raw === '') return undefined\n return spec.threshold ?? Number(raw)\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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"}