@langapi/mcp-server 1.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 (67) hide show
  1. package/README.md +551 -0
  2. package/bin/langapi-mcp.js +11 -0
  3. package/dist/api/client.d.ts +24 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +145 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/api/types.d.ts +56 -0
  8. package/dist/api/types.d.ts.map +1 -0
  9. package/dist/api/types.js +6 -0
  10. package/dist/api/types.js.map +1 -0
  11. package/dist/config/env.d.ts +18 -0
  12. package/dist/config/env.d.ts.map +1 -0
  13. package/dist/config/env.js +29 -0
  14. package/dist/config/env.js.map +1 -0
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +25 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/locale-detection/index.d.ts +41 -0
  20. package/dist/locale-detection/index.d.ts.map +1 -0
  21. package/dist/locale-detection/index.js +188 -0
  22. package/dist/locale-detection/index.js.map +1 -0
  23. package/dist/locale-detection/patterns.d.ts +21 -0
  24. package/dist/locale-detection/patterns.d.ts.map +1 -0
  25. package/dist/locale-detection/patterns.js +135 -0
  26. package/dist/locale-detection/patterns.js.map +1 -0
  27. package/dist/server.d.ts +9 -0
  28. package/dist/server.d.ts.map +1 -0
  29. package/dist/server.js +24 -0
  30. package/dist/server.js.map +1 -0
  31. package/dist/tools/get-diff.d.ts +23 -0
  32. package/dist/tools/get-diff.d.ts.map +1 -0
  33. package/dist/tools/get-diff.js +88 -0
  34. package/dist/tools/get-diff.js.map +1 -0
  35. package/dist/tools/get-translation-status.d.ts +47 -0
  36. package/dist/tools/get-translation-status.d.ts.map +1 -0
  37. package/dist/tools/get-translation-status.js +176 -0
  38. package/dist/tools/get-translation-status.js.map +1 -0
  39. package/dist/tools/list-local-locales.d.ts +39 -0
  40. package/dist/tools/list-local-locales.d.ts.map +1 -0
  41. package/dist/tools/list-local-locales.js +52 -0
  42. package/dist/tools/list-local-locales.js.map +1 -0
  43. package/dist/tools/sync-translations.d.ts +35 -0
  44. package/dist/tools/sync-translations.d.ts.map +1 -0
  45. package/dist/tools/sync-translations.js +629 -0
  46. package/dist/tools/sync-translations.js.map +1 -0
  47. package/dist/utils/format-preserve.d.ts +25 -0
  48. package/dist/utils/format-preserve.d.ts.map +1 -0
  49. package/dist/utils/format-preserve.js +56 -0
  50. package/dist/utils/format-preserve.js.map +1 -0
  51. package/dist/utils/json-parser.d.ts +33 -0
  52. package/dist/utils/json-parser.d.ts.map +1 -0
  53. package/dist/utils/json-parser.js +92 -0
  54. package/dist/utils/json-parser.js.map +1 -0
  55. package/dist/utils/sync-cache.d.ts +40 -0
  56. package/dist/utils/sync-cache.d.ts.map +1 -0
  57. package/dist/utils/sync-cache.js +153 -0
  58. package/dist/utils/sync-cache.js.map +1 -0
  59. package/dist/utils/sync-cache.test.d.ts +2 -0
  60. package/dist/utils/sync-cache.test.d.ts.map +1 -0
  61. package/dist/utils/sync-cache.test.js +205 -0
  62. package/dist/utils/sync-cache.test.js.map +1 -0
  63. package/dist/utils/validation.d.ts +37 -0
  64. package/dist/utils/validation.d.ts.map +1 -0
  65. package/dist/utils/validation.js +67 -0
  66. package/dist/utils/validation.js.map +1 -0
  67. package/package.json +56 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * sync_translations MCP Tool
3
+ * Sync translations via LangAPI /v1/sync endpoint
4
+ */
5
+ import { z } from "zod";
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ declare const SyncTranslationsSchema: z.ZodObject<{
8
+ source_lang: z.ZodString;
9
+ target_langs: z.ZodArray<z.ZodString, "many">;
10
+ dry_run: z.ZodDefault<z.ZodBoolean>;
11
+ project_path: z.ZodOptional<z.ZodString>;
12
+ write_to_files: z.ZodDefault<z.ZodBoolean>;
13
+ skip_keys: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString, "many">>>;
14
+ }, "strip", z.ZodTypeAny, {
15
+ source_lang: string;
16
+ target_langs: string[];
17
+ dry_run: boolean;
18
+ write_to_files: boolean;
19
+ project_path?: string | undefined;
20
+ skip_keys?: Record<string, string[]> | undefined;
21
+ }, {
22
+ source_lang: string;
23
+ target_langs: string[];
24
+ project_path?: string | undefined;
25
+ dry_run?: boolean | undefined;
26
+ write_to_files?: boolean | undefined;
27
+ skip_keys?: Record<string, string[]> | undefined;
28
+ }>;
29
+ export type SyncTranslationsInput = z.infer<typeof SyncTranslationsSchema>;
30
+ /**
31
+ * Register the sync_translations tool with the MCP server
32
+ */
33
+ export declare function registerSyncTranslations(server: McpServer): void;
34
+ export {};
35
+ //# sourceMappingURL=sync-translations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-translations.d.ts","sourceRoot":"","sources":["../../src/tools/sync-translations.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA0BzE,QAAA,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;EAqB1B,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAwJ3E;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAylBhE"}
@@ -0,0 +1,629 @@
1
+ /**
2
+ * sync_translations MCP Tool
3
+ * Sync translations via LangAPI /v1/sync endpoint
4
+ */
5
+ import { z } from "zod";
6
+ import { readFile, writeFile, mkdir } from "fs/promises";
7
+ import { dirname, resolve } from "path";
8
+ import { detectLocales } from "../locale-detection/index.js";
9
+ import { flattenJson, unflattenJson, parseJsonSafe, } from "../utils/json-parser.js";
10
+ import { parseJsonWithFormat, stringifyWithFormat, } from "../utils/format-preserve.js";
11
+ import { LangAPIClient } from "../api/client.js";
12
+ import { isApiKeyConfigured } from "../config/env.js";
13
+ import { languageCodeSchema, languageCodesArraySchema, isPathWithinProject, } from "../utils/validation.js";
14
+ import { readSyncCache, writeSyncCache, detectLocalDelta, } from "../utils/sync-cache.js";
15
+ // Input schema
16
+ const SyncTranslationsSchema = z.object({
17
+ source_lang: languageCodeSchema.describe("Source language code (e.g., 'en', 'pt-BR')"),
18
+ target_langs: languageCodesArraySchema.describe("Target language codes to sync"),
19
+ dry_run: z
20
+ .boolean()
21
+ .default(true)
22
+ .describe("If true, only preview changes without syncing. Default: true (safe mode)"),
23
+ project_path: z
24
+ .string()
25
+ .optional()
26
+ .describe("Root path of the project. Defaults to current working directory."),
27
+ write_to_files: z
28
+ .boolean()
29
+ .default(true)
30
+ .describe("If true, write translated content back to local files"),
31
+ skip_keys: z
32
+ .record(z.string(), z.array(z.string()))
33
+ .optional()
34
+ .describe("Keys to skip per language, e.g., { 'fr': ['subtitle', 'brand'] }"),
35
+ });
36
+ /**
37
+ * Get keys to skip for a specific language
38
+ */
39
+ function getSkipKeysForLang(skipKeys, lang) {
40
+ if (!skipKeys)
41
+ return new Set();
42
+ const keys = skipKeys[lang] || [];
43
+ return new Set(keys);
44
+ }
45
+ /**
46
+ * Deep merge two objects, with source values overriding target values
47
+ */
48
+ function deepMerge(target, source) {
49
+ const result = { ...target };
50
+ for (const key of Object.keys(source)) {
51
+ const sourceValue = source[key];
52
+ const targetValue = result[key];
53
+ if (sourceValue !== null &&
54
+ typeof sourceValue === "object" &&
55
+ !Array.isArray(sourceValue) &&
56
+ targetValue !== null &&
57
+ typeof targetValue === "object" &&
58
+ !Array.isArray(targetValue)) {
59
+ // Both are objects, merge recursively
60
+ result[key] = deepMerge(targetValue, sourceValue);
61
+ }
62
+ else {
63
+ // Source value overrides target
64
+ result[key] = sourceValue;
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+ /**
70
+ * Remove keys from a nested object using flattened key notation (e.g., "greeting.hello")
71
+ */
72
+ function removeKeysFromObject(obj, keysToRemove) {
73
+ const result = JSON.parse(JSON.stringify(obj)); // Deep clone
74
+ for (const key of keysToRemove) {
75
+ const parts = key.split(".");
76
+ let current = result;
77
+ // Navigate to the parent of the key to remove
78
+ for (let i = 0; i < parts.length - 1; i++) {
79
+ if (current[parts[i]] && typeof current[parts[i]] === "object") {
80
+ current = current[parts[i]];
81
+ }
82
+ else {
83
+ // Path doesn't exist, nothing to remove
84
+ break;
85
+ }
86
+ }
87
+ // Delete the final key
88
+ delete current[parts[parts.length - 1]];
89
+ }
90
+ return result;
91
+ }
92
+ /**
93
+ * Remove any keys from target that don't exist in source (to keep target in sync with source structure)
94
+ */
95
+ function removeExtraKeys(targetObj, sourceKeys) {
96
+ // Flatten the target to get all its keys
97
+ const targetFlat = flattenJson(targetObj);
98
+ // Find keys in target that don't exist in source
99
+ const extraKeys = [];
100
+ for (const item of targetFlat) {
101
+ if (!sourceKeys.has(item.key)) {
102
+ extraKeys.push(item.key);
103
+ }
104
+ }
105
+ if (extraKeys.length === 0) {
106
+ return targetObj;
107
+ }
108
+ console.error(`[SYNC] Removing extra keys not in source: ${extraKeys.join(", ")}`);
109
+ return removeKeysFromObject(targetObj, extraKeys);
110
+ }
111
+ /**
112
+ * Register the sync_translations tool with the MCP server
113
+ */
114
+ export function registerSyncTranslations(server) {
115
+ server.tool("sync_translations", "Sync translations by calling the LangAPI /v1/sync endpoint. Default is dry_run=true (preview mode) for safety. Set dry_run=false to actually perform the sync.", SyncTranslationsSchema.shape, async (args) => {
116
+ const input = SyncTranslationsSchema.parse(args);
117
+ const projectPath = input.project_path || process.cwd();
118
+ // Check if API key is configured
119
+ if (!isApiKeyConfigured()) {
120
+ const output = {
121
+ success: false,
122
+ error: {
123
+ code: "NO_API_KEY",
124
+ message: "No API key configured. Set the LANGAPI_API_KEY environment variable.",
125
+ },
126
+ };
127
+ return {
128
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
129
+ };
130
+ }
131
+ // Detect locales
132
+ const detection = await detectLocales(projectPath, false);
133
+ // Find source locale
134
+ const sourceLocale = detection.locales.find((l) => l.lang === input.source_lang);
135
+ if (!sourceLocale) {
136
+ const output = {
137
+ success: false,
138
+ error: {
139
+ code: "SOURCE_NOT_FOUND",
140
+ message: `Source language '${input.source_lang}' not found in project`,
141
+ },
142
+ };
143
+ return {
144
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
145
+ };
146
+ }
147
+ // Read source files and merge content
148
+ const sourceContent = {};
149
+ let sourceFormat = { indent: " ", trailingNewline: true };
150
+ for (const file of sourceLocale.files) {
151
+ const content = await readFile(file.path, "utf-8");
152
+ const parsed = parseJsonWithFormat(content);
153
+ if (parsed) {
154
+ Object.assign(sourceContent, parsed.data);
155
+ sourceFormat = parsed.format; // Use last file's format
156
+ }
157
+ }
158
+ // Flatten source content for API
159
+ const flatContent = flattenJson(sourceContent);
160
+ const sourceKeys = new Set(flatContent.map((item) => item.key));
161
+ console.error(`[SYNC] projectPath: ${projectPath}`);
162
+ console.error(`[SYNC] flatContent has ${flatContent.length} keys`);
163
+ // Read cache and detect local delta
164
+ const cachedContent = await readSyncCache(projectPath, input.source_lang);
165
+ console.error(`[SYNC] cachedContent: ${cachedContent ? Object.keys(cachedContent).length + ' keys' : 'null'}`);
166
+ const localDelta = detectLocalDelta(flatContent, cachedContent);
167
+ console.error(`[SYNC] localDelta: ${localDelta.newKeys.length} new, ${localDelta.changedKeys.length} changed, ${localDelta.unchangedKeys.length} unchanged`);
168
+ // Detect missing target languages (files that don't exist yet)
169
+ const missingLanguages = [];
170
+ const existingLanguages = [];
171
+ for (const targetLang of input.target_langs) {
172
+ const targetLocale = detection.locales.find((l) => l.lang === targetLang);
173
+ if (!targetLocale || targetLocale.files.length === 0) {
174
+ missingLanguages.push(targetLang);
175
+ }
176
+ else {
177
+ existingLanguages.push(targetLang);
178
+ }
179
+ }
180
+ console.error(`[SYNC] Missing languages: ${missingLanguages.join(", ") || "none"}`);
181
+ console.error(`[SYNC] Existing languages: ${existingLanguages.join(", ") || "none"}`);
182
+ // If no content to sync AND no missing languages, return early with accurate delta
183
+ if (localDelta.contentToSync.length === 0 && missingLanguages.length === 0) {
184
+ if (input.dry_run) {
185
+ // Check for extra keys in target files even in dry_run mode
186
+ let totalExtraKeys = 0;
187
+ const extraKeysByLang = {};
188
+ for (const targetLang of input.target_langs) {
189
+ const targetLocale = detection.locales.find((l) => l.lang === targetLang);
190
+ if (!targetLocale || targetLocale.files.length === 0)
191
+ continue;
192
+ try {
193
+ const content = await readFile(targetLocale.files[0].path, "utf-8");
194
+ const parsed = parseJsonSafe(content);
195
+ if (parsed) {
196
+ const targetFlat = flattenJson(parsed);
197
+ const extraKeys = targetFlat.filter((t) => !sourceKeys.has(t.key)).map((t) => t.key);
198
+ if (extraKeys.length > 0) {
199
+ extraKeysByLang[targetLang] = extraKeys;
200
+ totalExtraKeys += extraKeys.length;
201
+ }
202
+ }
203
+ }
204
+ catch {
205
+ // File doesn't exist or can't be read
206
+ }
207
+ }
208
+ const output = {
209
+ success: true,
210
+ dry_run: true,
211
+ delta: {
212
+ new_keys: [],
213
+ changed_keys: [],
214
+ total_keys_to_sync: 0,
215
+ },
216
+ cost: {
217
+ words_to_translate: 0,
218
+ credits_required: 0,
219
+ current_balance: 0,
220
+ balance_after_sync: 0,
221
+ },
222
+ message: totalExtraKeys > 0
223
+ ? `No translations needed, but ${totalExtraKeys} extra keys found in target files will be removed. Run with dry_run=false to clean up. Extra keys: ${JSON.stringify(extraKeysByLang)}`
224
+ : "No changes detected. All keys are already synced.",
225
+ };
226
+ return {
227
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
228
+ };
229
+ }
230
+ // For non-dry_run with no source changes, still check for extra keys in target files
231
+ const results = [];
232
+ if (input.write_to_files) {
233
+ for (const targetLang of input.target_langs) {
234
+ const targetLocale = detection.locales.find((l) => l.lang === targetLang);
235
+ if (!targetLocale || targetLocale.files.length === 0) {
236
+ results.push({ language: targetLang, translated_count: 0, file_written: null });
237
+ continue;
238
+ }
239
+ const filePath = targetLocale.files[0].path;
240
+ const resolvedPath = resolve(filePath);
241
+ try {
242
+ const existingContent = await readFile(resolvedPath, "utf-8");
243
+ const parsed = parseJsonSafe(existingContent);
244
+ if (!parsed) {
245
+ results.push({ language: targetLang, translated_count: 0, file_written: null });
246
+ continue;
247
+ }
248
+ // Check for and remove extra keys
249
+ const cleaned = removeExtraKeys(parsed, sourceKeys);
250
+ const cleanedStr = stringifyWithFormat(cleaned, sourceFormat);
251
+ const originalStr = stringifyWithFormat(parsed, sourceFormat);
252
+ if (cleanedStr !== originalStr) {
253
+ // Extra keys were removed, write the cleaned file
254
+ await writeFile(resolvedPath, cleanedStr, "utf-8");
255
+ const keysRemoved = flattenJson(parsed).length - flattenJson(cleaned).length;
256
+ console.error(`[SYNC] Removed ${keysRemoved} extra keys from ${targetLang}`);
257
+ results.push({ language: targetLang, translated_count: 0, file_written: resolvedPath, keys_removed: keysRemoved });
258
+ }
259
+ else {
260
+ results.push({ language: targetLang, translated_count: 0, file_written: null });
261
+ }
262
+ }
263
+ catch {
264
+ results.push({ language: targetLang, translated_count: 0, file_written: null });
265
+ }
266
+ }
267
+ }
268
+ else {
269
+ for (const targetLang of input.target_langs) {
270
+ results.push({ language: targetLang, translated_count: 0, file_written: null });
271
+ }
272
+ }
273
+ await writeSyncCache(projectPath, input.source_lang, flatContent);
274
+ const filesCleanedCount = results.filter((r) => r.keys_removed && r.keys_removed > 0).length;
275
+ const totalKeysRemoved = results.reduce((sum, r) => sum + (r.keys_removed || 0), 0);
276
+ const output = {
277
+ success: true,
278
+ dry_run: false,
279
+ results: results.map(({ language, translated_count, file_written }) => ({
280
+ language,
281
+ translated_count,
282
+ file_written,
283
+ })),
284
+ cost: {
285
+ credits_used: 0,
286
+ balance_after_sync: 0,
287
+ },
288
+ message: filesCleanedCount > 0
289
+ ? `No translations needed. Removed ${totalKeysRemoved} extra keys from ${filesCleanedCount} file(s).`
290
+ : "No changes detected. All keys are already synced.",
291
+ };
292
+ return {
293
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
294
+ };
295
+ }
296
+ // Create API client
297
+ const client = LangAPIClient.create();
298
+ // Track skipped keys per language for reporting
299
+ const skippedKeysReport = {};
300
+ // Check if we need per-language filtering
301
+ const hasSkipKeys = input.skip_keys && Object.keys(input.skip_keys).length > 0;
302
+ const hasMissingLanguages = missingLanguages.length > 0;
303
+ // If we have missing languages OR skip_keys, process per-language
304
+ if (hasMissingLanguages || hasSkipKeys) {
305
+ // Call API per language with filtered content
306
+ let totalCreditsRequired = 0;
307
+ let totalWordsToTranslate = 0;
308
+ let currentBalance = 0;
309
+ const allResults = [];
310
+ for (const targetLang of input.target_langs) {
311
+ // Determine base content: ALL keys for missing languages, only changes for existing
312
+ const isMissingLang = missingLanguages.includes(targetLang);
313
+ const baseContent = isMissingLang ? flatContent : localDelta.contentToSync;
314
+ // Apply skip_keys filter on top of base content
315
+ const skipSet = getSkipKeysForLang(input.skip_keys, targetLang);
316
+ const filteredContent = baseContent.filter((item) => !skipSet.has(item.key));
317
+ // Track skipped keys for this language
318
+ if (skipSet.size > 0) {
319
+ const skippedInContent = baseContent
320
+ .filter((item) => skipSet.has(item.key))
321
+ .map((item) => item.key);
322
+ if (skippedInContent.length > 0) {
323
+ skippedKeysReport[targetLang] = skippedInContent;
324
+ }
325
+ }
326
+ // Skip API call if no content after filtering
327
+ if (filteredContent.length === 0) {
328
+ allResults.push({ language: targetLang, translatedCount: 0, translations: [] });
329
+ continue;
330
+ }
331
+ console.error(`[SYNC] Translating ${filteredContent.length} keys for ${targetLang} (${isMissingLang ? 'new language' : 'existing language'})`);
332
+ const response = await client.sync({
333
+ source_lang: input.source_lang,
334
+ target_langs: [targetLang],
335
+ content: filteredContent,
336
+ dry_run: input.dry_run,
337
+ });
338
+ if (!response.success) {
339
+ const output = {
340
+ success: false,
341
+ error: {
342
+ code: response.error.code,
343
+ message: response.error.message,
344
+ current_balance: response.error.currentBalance,
345
+ required_credits: response.error.requiredCredits,
346
+ top_up_url: response.error.topUpUrl,
347
+ },
348
+ };
349
+ return {
350
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
351
+ };
352
+ }
353
+ if ("delta" in response && response.cost) {
354
+ // Dry run response
355
+ totalCreditsRequired += response.cost.creditsRequired || 0;
356
+ totalWordsToTranslate += response.cost.wordsToTranslate || 0;
357
+ currentBalance = response.cost.currentBalance || 0;
358
+ }
359
+ else if ("results" in response && response.cost) {
360
+ // Execute response
361
+ totalCreditsRequired += response.cost.creditsUsed || 0;
362
+ currentBalance = response.cost.balanceAfterSync || 0;
363
+ for (const result of response.results) {
364
+ allResults.push(result);
365
+ }
366
+ }
367
+ }
368
+ // Handle dry run response with skip_keys
369
+ if (input.dry_run) {
370
+ const skippedMsg = Object.keys(skippedKeysReport).length > 0
371
+ ? ` Skipped keys: ${JSON.stringify(skippedKeysReport)}`
372
+ : "";
373
+ const output = {
374
+ success: true,
375
+ dry_run: true,
376
+ delta: {
377
+ new_keys: localDelta.newKeys,
378
+ changed_keys: localDelta.changedKeys,
379
+ total_keys_to_sync: localDelta.contentToSync.length,
380
+ },
381
+ cost: {
382
+ words_to_translate: totalWordsToTranslate,
383
+ credits_required: totalCreditsRequired,
384
+ current_balance: currentBalance,
385
+ balance_after_sync: currentBalance - totalCreditsRequired,
386
+ },
387
+ message: `Preview: ${localDelta.contentToSync.length} keys to sync, ${totalCreditsRequired} credits required.${skippedMsg} Run with dry_run=false to execute.`,
388
+ };
389
+ return {
390
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
391
+ };
392
+ }
393
+ // For execute mode, continue to file writing with allResults
394
+ // We'll handle this in the results section below by setting response
395
+ const response = {
396
+ success: true,
397
+ results: allResults,
398
+ cost: { creditsUsed: totalCreditsRequired, balanceAfterSync: currentBalance - totalCreditsRequired },
399
+ };
400
+ // Continue to "Handle execute response" section below
401
+ if ("results" in response) {
402
+ const results = [];
403
+ // Write translated content to files if requested
404
+ if (input.write_to_files) {
405
+ for (const result of response.results) {
406
+ const lang = result.language;
407
+ // Find existing target locale to match directory structure
408
+ const targetLocale = detection.locales.find((l) => l.lang === lang);
409
+ let filePath;
410
+ if (targetLocale && targetLocale.files.length > 0) {
411
+ filePath = targetLocale.files[0].path;
412
+ }
413
+ else {
414
+ const sourceFile = sourceLocale.files[0];
415
+ filePath = sourceFile.path.replace(`/${input.source_lang}`, `/${lang}`);
416
+ filePath = filePath.replace(`${input.source_lang}.json`, `${lang}.json`);
417
+ }
418
+ const resolvedPath = resolve(filePath);
419
+ if (!isPathWithinProject(resolvedPath, projectPath)) {
420
+ results.push({
421
+ language: lang,
422
+ translated_count: result.translatedCount,
423
+ skipped_keys: skippedKeysReport[lang],
424
+ file_written: null,
425
+ });
426
+ continue;
427
+ }
428
+ // Read existing target file content (if exists)
429
+ let existingContent = {};
430
+ try {
431
+ const existingFileContent = await readFile(resolvedPath, "utf-8");
432
+ const parsed = parseJsonSafe(existingFileContent);
433
+ if (parsed) {
434
+ existingContent = parsed;
435
+ }
436
+ }
437
+ catch {
438
+ // File doesn't exist yet, start with empty object
439
+ }
440
+ // Unflatten the new translations
441
+ const newTranslations = unflattenJson(result.translations);
442
+ // Deep merge: new translations override existing ones
443
+ let mergedContent = deepMerge(existingContent, newTranslations);
444
+ // Remove any keys in target that don't exist in source
445
+ mergedContent = removeExtraKeys(mergedContent, sourceKeys);
446
+ await mkdir(dirname(resolvedPath), { recursive: true });
447
+ const fileContent = stringifyWithFormat(mergedContent, sourceFormat);
448
+ await writeFile(resolvedPath, fileContent, "utf-8");
449
+ results.push({
450
+ language: lang,
451
+ translated_count: result.translatedCount,
452
+ skipped_keys: skippedKeysReport[lang],
453
+ file_written: resolvedPath,
454
+ });
455
+ }
456
+ }
457
+ else {
458
+ for (const result of response.results) {
459
+ results.push({
460
+ language: result.language,
461
+ translated_count: result.translatedCount,
462
+ skipped_keys: skippedKeysReport[result.language],
463
+ file_written: null,
464
+ });
465
+ }
466
+ }
467
+ await writeSyncCache(projectPath, input.source_lang, flatContent);
468
+ const skippedMsg = Object.keys(skippedKeysReport).length > 0
469
+ ? ` Skipped: ${JSON.stringify(skippedKeysReport)}`
470
+ : "";
471
+ const output = {
472
+ success: true,
473
+ dry_run: false,
474
+ results,
475
+ cost: {
476
+ credits_used: response.cost.creditsUsed,
477
+ balance_after_sync: response.cost.balanceAfterSync,
478
+ },
479
+ message: `Sync complete. ${response.results.reduce((sum, r) => sum + r.translatedCount, 0)} keys translated.${skippedMsg}`,
480
+ };
481
+ return {
482
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
483
+ };
484
+ }
485
+ }
486
+ // No skip_keys - use batch approach
487
+ const response = await client.sync({
488
+ source_lang: input.source_lang,
489
+ target_langs: input.target_langs,
490
+ content: localDelta.contentToSync,
491
+ dry_run: input.dry_run,
492
+ });
493
+ // Handle error response
494
+ if (!response.success) {
495
+ const output = {
496
+ success: false,
497
+ error: {
498
+ code: response.error.code,
499
+ message: response.error.message,
500
+ current_balance: response.error.currentBalance,
501
+ required_credits: response.error.requiredCredits,
502
+ top_up_url: response.error.topUpUrl,
503
+ },
504
+ };
505
+ return {
506
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
507
+ };
508
+ }
509
+ // Handle dry run response - use local delta for accurate new/changed detection
510
+ if (input.dry_run && "delta" in response) {
511
+ const output = {
512
+ success: true,
513
+ dry_run: true,
514
+ delta: {
515
+ new_keys: localDelta.newKeys,
516
+ changed_keys: localDelta.changedKeys,
517
+ total_keys_to_sync: localDelta.contentToSync.length,
518
+ },
519
+ cost: {
520
+ words_to_translate: response.cost.wordsToTranslate,
521
+ credits_required: response.cost.creditsRequired,
522
+ current_balance: response.cost.currentBalance,
523
+ balance_after_sync: response.cost.balanceAfterSync,
524
+ },
525
+ message: `Preview: ${localDelta.contentToSync.length} keys to sync (${localDelta.newKeys.length} new, ${localDelta.changedKeys.length} changed), ${response.cost.creditsRequired} credits required. Run with dry_run=false to execute.`,
526
+ };
527
+ return {
528
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
529
+ };
530
+ }
531
+ // Handle execute response
532
+ if ("results" in response) {
533
+ const results = [];
534
+ // Write translated content to files if requested
535
+ if (input.write_to_files) {
536
+ for (const result of response.results) {
537
+ const lang = result.language;
538
+ // Find existing target locale to match directory structure
539
+ const targetLocale = detection.locales.find((l) => l.lang === lang);
540
+ let filePath;
541
+ if (targetLocale && targetLocale.files.length > 0) {
542
+ // Use existing file path
543
+ filePath = targetLocale.files[0].path;
544
+ }
545
+ else {
546
+ // Create new file based on source structure
547
+ const sourceFile = sourceLocale.files[0];
548
+ filePath = sourceFile.path.replace(`/${input.source_lang}`, `/${lang}`);
549
+ filePath = filePath.replace(`${input.source_lang}.json`, `${lang}.json`);
550
+ }
551
+ // Validate path is within project directory (prevent path traversal)
552
+ const resolvedPath = resolve(filePath);
553
+ if (!isPathWithinProject(resolvedPath, projectPath)) {
554
+ results.push({
555
+ language: lang,
556
+ translated_count: result.translatedCount,
557
+ file_written: null, // Skipped: path outside project
558
+ });
559
+ continue;
560
+ }
561
+ // Read existing target file content (if exists)
562
+ let existingContent = {};
563
+ try {
564
+ const existingFileContent = await readFile(resolvedPath, "utf-8");
565
+ const parsed = parseJsonSafe(existingFileContent);
566
+ if (parsed) {
567
+ existingContent = parsed;
568
+ }
569
+ }
570
+ catch {
571
+ // File doesn't exist yet, start with empty object
572
+ }
573
+ // Unflatten the new translations
574
+ const newTranslations = unflattenJson(result.translations);
575
+ // Deep merge: new translations override existing ones
576
+ let mergedContent = deepMerge(existingContent, newTranslations);
577
+ // Remove any keys in target that don't exist in source
578
+ mergedContent = removeExtraKeys(mergedContent, sourceKeys);
579
+ // Ensure directory exists
580
+ await mkdir(dirname(resolvedPath), { recursive: true });
581
+ // Write file with format preservation
582
+ const fileContent = stringifyWithFormat(mergedContent, sourceFormat);
583
+ await writeFile(resolvedPath, fileContent, "utf-8");
584
+ results.push({
585
+ language: lang,
586
+ translated_count: result.translatedCount,
587
+ file_written: resolvedPath,
588
+ });
589
+ }
590
+ }
591
+ else {
592
+ for (const result of response.results) {
593
+ results.push({
594
+ language: result.language,
595
+ translated_count: result.translatedCount,
596
+ file_written: null,
597
+ });
598
+ }
599
+ }
600
+ // Update cache with current source content after successful sync
601
+ await writeSyncCache(projectPath, input.source_lang, flatContent);
602
+ const output = {
603
+ success: true,
604
+ dry_run: false,
605
+ results,
606
+ cost: {
607
+ credits_used: response.cost.creditsUsed,
608
+ balance_after_sync: response.cost.balanceAfterSync,
609
+ },
610
+ message: `Sync complete. ${response.results.reduce((sum, r) => sum + r.translatedCount, 0)} keys translated across ${response.results.length} languages. ${response.cost.creditsUsed} credits used.`,
611
+ };
612
+ return {
613
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
614
+ };
615
+ }
616
+ // Fallback error
617
+ const output = {
618
+ success: false,
619
+ error: {
620
+ code: "UNEXPECTED_RESPONSE",
621
+ message: "Unexpected response from API",
622
+ },
623
+ };
624
+ return {
625
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
626
+ };
627
+ });
628
+ }
629
+ //# sourceMappingURL=sync-translations.js.map