@git8n/cli 0.0.0-stage → 0.1.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 (4) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +27 -2
  3. package/dist/git8n.js +1769 -0
  4. package/package.json +48 -3
package/dist/git8n.js ADDED
@@ -0,0 +1,1769 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../mcp/src/cli/node.ts
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { promisify } from "node:util";
8
+
9
+ // ../mcp/src/server.ts
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+
13
+ // ../mcp/src/api.ts
14
+ var ApiError = class extends Error {
15
+ status;
16
+ // Written out rather than as a parameter property: Node runs this file with
17
+ // `--experimental-strip-types`, which removes types and rewrites nothing, and
18
+ // a parameter property is syntax it refuses.
19
+ constructor(message, status) {
20
+ super(message);
21
+ this.name = "ApiError";
22
+ this.status = status;
23
+ }
24
+ };
25
+ var ask = async (config, path, body, method) => {
26
+ const response = await (config.fetch ?? fetch)(`${config.url}${path}`, {
27
+ method: method ?? (body === void 0 ? "GET" : "POST"),
28
+ signal: AbortSignal.timeout(60 * 1e3),
29
+ ...config.token === void 0 ? { credentials: "include" } : {},
30
+ headers: {
31
+ ...config.token === void 0 ? {} : { Authorization: `Bearer ${config.token}` },
32
+ Accept: "application/json",
33
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
34
+ },
35
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
36
+ });
37
+ if (!response.ok) {
38
+ const said = await response.text().catch(() => "");
39
+ throw new ApiError(said || `${path} answered ${response.status}`, response.status);
40
+ }
41
+ return (await response.json()).data;
42
+ };
43
+ var repositories = (config) => ask(config, "/i18n/repositories");
44
+ var rightsOf = (config) => ask(config, "/i18n/me");
45
+ var catalogue = (config, repository, language2) => ask(
46
+ config,
47
+ `/i18n/catalogue?repository=${encodeURIComponent(repository)}&language=${encodeURIComponent(language2)}`
48
+ );
49
+ var waiting = (config, repository, language2) => ask(
50
+ config,
51
+ `/i18n/proposals?repository=${encodeURIComponent(repository)}&language=${encodeURIComponent(language2)}`
52
+ );
53
+ var propose = (config, batch) => ask(config, "/i18n/proposals", batch);
54
+ var progress = (config, repository) => ask(config, `/i18n/progress?repository=${encodeURIComponent(repository)}`);
55
+ var overview = (config, query = {}) => {
56
+ const params = new URLSearchParams();
57
+ if (query.page) params.set("page", String(query.page));
58
+ if (query.perPage) params.set("perPage", String(query.perPage));
59
+ if (query.q) params.set("q", query.q);
60
+ if (query.sort) params.set("sort", query.sort);
61
+ if (query.order) params.set("order", query.order);
62
+ if (query.progress) params.set("progress", query.progress);
63
+ const search = params.toString();
64
+ return ask(config, `/i18n/overview${search ? `?${search}` : ""}`);
65
+ };
66
+
67
+ // ../mcp/src/tools.ts
68
+ import { z } from "zod";
69
+ var fileName = z.string().regex(/^[A-Za-z0-9_-]+$/, "A file is a name such as common, not a path");
70
+ var language = z.string().regex(/^[A-Za-z0-9_-]+$/, "A language is a tag such as fr or pt-BR");
71
+ var PAGE = 200;
72
+ var languageSchema = {
73
+ repository: z.string().min(1).describe("The repository to read, from i18n_repositories."),
74
+ language: language.describe("The language to read, which the repository must translate into.")
75
+ };
76
+ var listSchema = {
77
+ ...languageSchema,
78
+ state: z.enum(["missing", "review", "translated", "all"]).default("missing").describe(
79
+ "Which keys to answer. `missing` is what there is work on. `review` is a translation whose source changed: `was` holds the old source."
80
+ ),
81
+ file: fileName.optional().describe("Only the keys of this file, for example `common`."),
82
+ offset: z.number().int().min(0).default(0).describe("Where to start, for a catalogue past one answer.")
83
+ };
84
+ var readSchema = {
85
+ ...languageSchema,
86
+ file: fileName,
87
+ key: z.string().min(1).describe("The dotted path of the key, as i18next reads it.")
88
+ };
89
+ var proposeSchema = {
90
+ ...languageSchema,
91
+ readAt: z.string().regex(/^[0-9a-f]{40}$/, "The `commit` of the listing this batch answers").describe(
92
+ "The `commit` the keys were read at, taken from i18n_keys or i18n_key. Every page of one listing answers the same one. It is what lets a publication refuse a key whose source moved since \u2014 never invent one, and never mix the values of two listings in one call."
93
+ ),
94
+ values: z.array(
95
+ z.object({
96
+ file: fileName,
97
+ key: z.string().min(1),
98
+ value: z.string(),
99
+ empty: z.boolean().optional().describe(
100
+ 'true leaves the key empty on purpose in this language, with value "". Use it only when the source asks for no text in this language, never to skip a key. A key sent without it loses its mark.'
101
+ )
102
+ })
103
+ ).min(1).max(2e3).describe(
104
+ "The whole batch. One call is one commit, so three hundred keys are three hundred keys, not three hundred commits."
105
+ )
106
+ };
107
+ var answer = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
108
+ var HELD_MS = 5 * 60 * 1e3;
109
+ var createCatalogues = () => {
110
+ const held = /* @__PURE__ */ new Map();
111
+ const reading = /* @__PURE__ */ new Map();
112
+ return {
113
+ read: async (config, repository, language2, now) => {
114
+ const key = `${repository}:${language2}`;
115
+ const hit = held.get(key);
116
+ if (hit && now - hit.at < HELD_MS) return hit.value;
117
+ const shared = reading.get(key);
118
+ if (shared) return shared;
119
+ const started = catalogue(config, repository, language2).then((value) => {
120
+ held.set(key, { at: now, value });
121
+ return value;
122
+ }).finally(() => {
123
+ if (reading.get(key) === started) reading.delete(key);
124
+ });
125
+ reading.set(key, started);
126
+ return started;
127
+ },
128
+ clear: () => {
129
+ held.clear();
130
+ reading.clear();
131
+ }
132
+ };
133
+ };
134
+ var disabledIn = (read) => Object.entries(read.files ?? {}).filter(([, state]) => state === "disabled").map(([file]) => file).sort();
135
+ var matching = (entries, state, file) => entries.filter((entry) => (state === "all" || entry.state === state) && (file === void 0 || entry.file === file));
136
+ var listKeys = async (config, input, held, now = Date.now()) => {
137
+ const read = await held.read(config, input.repository, input.language, now);
138
+ const found = matching(read.entries, input.state, input.file);
139
+ const page = found.slice(input.offset, input.offset + PAGE);
140
+ return answer({
141
+ // The revision the values were read at. A proposal carries it back.
142
+ commit: read.commit,
143
+ source: read.source,
144
+ language: read.language,
145
+ total: found.length,
146
+ offset: input.offset,
147
+ returned: page.length,
148
+ // Said plainly rather than left to be worked out from three numbers.
149
+ more: input.offset + page.length < found.length,
150
+ // An agent that sees no key of a file must not read « fully translated ».
151
+ // A proposal on one of these files is refused.
152
+ filesWithNoVersion: disabledIn(read),
153
+ entries: page
154
+ });
155
+ };
156
+ var readKey = async (config, input, held, now = Date.now()) => {
157
+ const read = await held.read(config, input.repository, input.language, now);
158
+ const found = read.entries.find((entry) => entry.file === input.file && entry.key === input.key);
159
+ if (disabledIn(read).includes(input.file)) {
160
+ return answer({
161
+ found: false,
162
+ reason: `${input.file} has no ${input.language} version, and takes no proposal.`
163
+ });
164
+ }
165
+ if (!found) {
166
+ return answer({
167
+ found: false,
168
+ reason: `${input.file}.${input.key} is not a key of the source of ${input.language}.`
169
+ });
170
+ }
171
+ return answer({ found: true, commit: read.commit, source: read.source, entry: found });
172
+ };
173
+ var proposeBatch = async (config, input) => answer(await propose(config, input));
174
+ var listRepositories = async (config) => answer(await repositories(config));
175
+ var whoAmI = async (config) => answer(await rightsOf(config));
176
+ var listProposals = async (config, input) => answer(await waiting(config, input.repository, input.language));
177
+
178
+ // ../mcp/src/server.ts
179
+ var answering = (run2) => run2().catch((error) => {
180
+ const said = error instanceof ApiError ? `${error.status} \u2014 ${error.message}` : String(error);
181
+ return { isError: true, content: [{ type: "text", text: said }] };
182
+ });
183
+ var buildServer = (config) => {
184
+ const server = new McpServer({ name: "git8n", version: "0.1.0" });
185
+ const held = createCatalogues();
186
+ server.registerTool(
187
+ "i18n_repositories",
188
+ {
189
+ title: "The repositories to translate",
190
+ description: "Which repositories the editor translates, their source language and their targets.",
191
+ inputSchema: {}
192
+ },
193
+ () => answering(() => listRepositories(config))
194
+ );
195
+ server.registerTool(
196
+ "i18n_me",
197
+ {
198
+ title: "What this token may do",
199
+ description: "The repositories where this account is a member, and in each one its role, the languages it may propose in, and whether it writes the source. A translator writes proposals and does not publish. A reviewer or an owner also publishes.",
200
+ inputSchema: {}
201
+ },
202
+ () => answering(() => whoAmI(config))
203
+ );
204
+ server.registerTool(
205
+ "i18n_keys",
206
+ {
207
+ title: "The keys of a language",
208
+ description: "Every key of the source beside what one language says about it, filtered by state and by file. The answer carries the `commit` the values were read at; a proposal takes it back.",
209
+ inputSchema: listSchema
210
+ },
211
+ (input) => answering(() => listKeys(config, input, held))
212
+ );
213
+ server.registerTool(
214
+ "i18n_key",
215
+ {
216
+ title: "One key, with its context",
217
+ description: "One key: what the source says, what the target says, and its state. The file and the dotted path are the context \u2014 `analysis.requests_export_hint` says what the string is for, and a translation without it is a wrong translation.",
218
+ inputSchema: readSchema
219
+ },
220
+ (input) => answering(() => readKey(config, input, held))
221
+ );
222
+ server.registerTool(
223
+ "i18n_propose",
224
+ {
225
+ title: "Propose a batch of translations",
226
+ description: "Writes a whole batch as one commit on the branch of its language. Three hundred keys are one commit, not three hundred. Nothing is published: a reviewer does that.",
227
+ inputSchema: proposeSchema
228
+ },
229
+ (input) => answering(() => proposeBatch(config, input))
230
+ );
231
+ server.registerTool(
232
+ "i18n_proposals",
233
+ {
234
+ title: "What is waiting on a language",
235
+ description: "The batches the branch of a language holds and the base does not, with who wrote each one.",
236
+ inputSchema: languageSchema
237
+ },
238
+ (input) => answering(() => listProposals(config, input))
239
+ );
240
+ return server;
241
+ };
242
+ var serveOverStdio = (config) => buildServer(config).connect(new StdioServerTransport());
243
+
244
+ // ../mcp/src/cli/run.ts
245
+ import { isAbsolute, join as join3 } from "node:path";
246
+ import { parseArgs } from "node:util";
247
+
248
+ // ../mcp/src/cli/check.ts
249
+ import { createHash } from "node:crypto";
250
+ import { join } from "node:path";
251
+ var RULES = {
252
+ "invalid-json": { severity: "error", title: "The file is not a JSON object." },
253
+ "invalid-manifest": { severity: "error", title: "The manifest is not valid." },
254
+ "not-a-string": { severity: "error", title: "A value is not a string or an object." },
255
+ placeholders: { severity: "error", title: "The translation does not keep the placeholders of the source." },
256
+ "missing-key": { severity: "warning", title: "A key of the source has no translation." },
257
+ "empty-value": { severity: "warning", title: "A translation is empty, and the manifest does not mark it." },
258
+ "stale-empty": { severity: "warning", title: "A key is empty on purpose, but its source changed since." },
259
+ "empty-source": { severity: "warning", title: "A text of the source is empty." },
260
+ "extra-key": { severity: "warning", title: "A key is not in the source." },
261
+ "extra-file": { severity: "warning", title: "A file has no version in the source." },
262
+ "skipped-file": { severity: "warning", title: "The editor leaves out a file with this name." }
263
+ };
264
+ var CheckSetupError = class extends Error {
265
+ constructor(message) {
266
+ super(message);
267
+ this.name = "CheckSetupError";
268
+ }
269
+ };
270
+ var LOCALES_GUESSES = ["locales", "src/locales", "public/locales", "src/i18n/locales", "i18n"];
271
+ var FILE_NAME = /^[A-Za-z0-9_-]+$/;
272
+ var sourceHashOf = (text) => createHash("sha256").update(text, "utf8").digest("hex").slice(0, 12);
273
+ var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
274
+ var pathsOf = (node, prefix = "") => Object.entries(node).flatMap(([key, value]) => {
275
+ const path = prefix ? `${prefix}.${key}` : key;
276
+ return isObject(value) ? pathsOf(value, path) : [[path, value]];
277
+ });
278
+ var groupAt = (text, open) => {
279
+ let depth = 0;
280
+ for (let at = open; at < text.length; at += 1) {
281
+ if (text[at] === "{") depth += 1;
282
+ if (text[at] === "}") depth -= 1;
283
+ if (depth === 0) return { inner: text.slice(open + 1, at), end: at + 1 };
284
+ }
285
+ return null;
286
+ };
287
+ var ICU_CHOICE = /\{\s*[A-Za-z_][\w.]*\s*,\s*(?:plural|select|selectordinal)\s*,/;
288
+ var I18NEXT_GROUP = /^\{\s*([^{},]+?)\s*(?:,[^{}]*)?\}$/;
289
+ var ICU_ARGUMENT = /^\s*([A-Za-z_][\w.]*)\s*(?:,\s*(plural|select|selectordinal)\s*,([\s\S]*))?(?:,[^{}]*)?$/;
290
+ var addIcuArguments = (text, found) => {
291
+ for (let at = text.indexOf("{"); at !== -1; at = text.indexOf("{", at)) {
292
+ const group = groupAt(text, at);
293
+ if (!group) return;
294
+ const argument = ICU_ARGUMENT.exec(group.inner);
295
+ const interpolation = I18NEXT_GROUP.exec(group.inner);
296
+ if (interpolation) {
297
+ found.add(`{{${interpolation[1]}}}`);
298
+ } else if (argument) {
299
+ found.add(`{${argument[1]}}`);
300
+ if (argument[2]) {
301
+ const branches = argument[3].replace(/^\s*offset:\s*\d+/, "");
302
+ for (let open = branches.indexOf("{"); open !== -1; open = branches.indexOf("{", open)) {
303
+ const branch = groupAt(branches, open);
304
+ if (!branch) break;
305
+ addIcuArguments(branch.inner, found);
306
+ open = branch.end;
307
+ }
308
+ }
309
+ }
310
+ at = group.end;
311
+ }
312
+ };
313
+ var placeholdersOf = (text) => {
314
+ const found = /* @__PURE__ */ new Set();
315
+ let rest = text;
316
+ if (!ICU_CHOICE.test(rest)) {
317
+ rest = rest.replace(/\{\{\s*([^{},]+?)\s*(?:,[^{}]*)?\}\}/g, (_, name) => {
318
+ found.add(`{{${name}}}`);
319
+ return " ";
320
+ });
321
+ }
322
+ rest = rest.replace(/\$t\([^)]*\)/g, (match) => {
323
+ found.add(match.replace(/\s+/g, ""));
324
+ return " ";
325
+ });
326
+ addIcuArguments(rest, found);
327
+ for (const match of rest.matchAll(/<\/?([A-Za-z0-9][\w-]*)\s*\/?>/g)) found.add(match[0].replace(/\s+/g, ""));
328
+ for (const match of rest.matchAll(/%(?:\d+\$)?[sdif]/g)) found.add(match[0]);
329
+ return [...found].sort();
330
+ };
331
+ var parseManifest = (text) => {
332
+ const raw = JSON.parse(text);
333
+ if (!isObject(raw)) throw new Error("it is not a JSON object");
334
+ const states = /* @__PURE__ */ new Map();
335
+ const marks = /* @__PURE__ */ new Map();
336
+ const issues = [];
337
+ if (raw.source !== void 0 && (typeof raw.source !== "string" || raw.source === "")) {
338
+ throw new Error('"source" is not a language');
339
+ }
340
+ for (const [file, byLanguage] of Object.entries(isObject(raw.files) ? raw.files : {})) {
341
+ for (const [language2, state] of Object.entries(isObject(byLanguage) ? byLanguage : {})) {
342
+ if (state !== "available" && state !== "partial" && state !== "disabled") {
343
+ issues.push(`files.${file}.${language2} is not available, partial or disabled`);
344
+ continue;
345
+ }
346
+ states.set(`${language2}
347
+ ${file}`, state);
348
+ }
349
+ }
350
+ for (const [language2, byName] of Object.entries(isObject(raw.empty) ? raw.empty : {})) {
351
+ for (const [name, mark] of Object.entries(isObject(byName) ? byName : {})) {
352
+ if (isObject(mark) && typeof mark.source === "string") marks.set(`${language2}
353
+ ${name}`, mark.source);
354
+ }
355
+ }
356
+ return { source: raw.source, states, marks, issues };
357
+ };
358
+ var PLURAL_FORMS = ["zero", "one", "two", "few", "many", "other"];
359
+ var pluralOf = (key) => {
360
+ const match = /^(.+)_(zero|one|two|few|many|other)$/.exec(key);
361
+ return match ? { base: match[1], form: match[2] } : null;
362
+ };
363
+ var pluralFormsOf = (language2, fallback) => {
364
+ try {
365
+ const forms = new Intl.PluralRules(language2).resolvedOptions().pluralCategories;
366
+ return PLURAL_FORMS.filter((form) => forms.includes(form));
367
+ } catch {
368
+ return fallback;
369
+ }
370
+ };
371
+ var COUNT = /* @__PURE__ */ new Set(["{{count}}", "{count}"]);
372
+ var sameList = (a, b) => a.length === b.length && a.every((item, index) => item === b[index]);
373
+ var describeDifference = (source, target) => {
374
+ const lost = source.filter((item) => !target.includes(item));
375
+ const added = target.filter((item) => !source.includes(item));
376
+ return [lost.length > 0 ? `lost ${lost.join(" ")}` : "", added.length > 0 ? `added ${added.join(" ")}` : ""].filter(Boolean).join(", ");
377
+ };
378
+ var checkLocales = async (options, reader) => {
379
+ const problems = [];
380
+ const report = (problem) => problems.push({ ...problem, severity: RULES[problem.rule].severity });
381
+ const summarize = (counts) => ({
382
+ ...counts,
383
+ errors: problems.filter((problem) => problem.severity === "error").length,
384
+ warnings: problems.filter((problem) => problem.severity === "warning").length,
385
+ problems
386
+ });
387
+ let locales = options.locales;
388
+ let folders = null;
389
+ if (locales === null) {
390
+ for (const guess of LOCALES_GUESSES) {
391
+ folders = await reader.readDir(guess);
392
+ if (folders !== null) {
393
+ locales = guess;
394
+ break;
395
+ }
396
+ }
397
+ if (locales === null) {
398
+ throw new CheckSetupError(`No folder of locales: pass --locales. Tried ${LOCALES_GUESSES.join(", ")}.`);
399
+ }
400
+ } else {
401
+ folders = await reader.readDir(locales);
402
+ if (folders === null) throw new CheckSetupError(`${locales} is not a folder.`);
403
+ }
404
+ const manifestPath = options.manifest ?? join(locales, "manifest.json");
405
+ const manifestText = await reader.readFile(manifestPath);
406
+ let manifest = { states: /* @__PURE__ */ new Map(), marks: /* @__PURE__ */ new Map() };
407
+ if (manifestText === null && options.manifest !== null) throw new CheckSetupError(`${manifestPath} does not exist.`);
408
+ if (manifestText !== null) {
409
+ const invalid = (reason) => report({
410
+ rule: "invalid-manifest",
411
+ path: manifestPath,
412
+ language: "",
413
+ key: null,
414
+ message: `${manifestPath} is not a manifest: ${reason}.`
415
+ });
416
+ try {
417
+ const { issues, ...read2 } = parseManifest(manifestText);
418
+ manifest = read2;
419
+ issues.forEach(invalid);
420
+ } catch (error) {
421
+ invalid(error.message);
422
+ }
423
+ }
424
+ const source = options.source ?? manifest.source;
425
+ if (!source && problems.length > 0) return summarize({ locales, source: "", languages: [], files: 0, keys: 0 });
426
+ if (!source) throw new CheckSetupError('No source language: pass --source, or name "source" in the manifest.');
427
+ if (options.source && manifest.source && options.source !== manifest.source) {
428
+ throw new CheckSetupError(`--source is ${options.source}, and the manifest names ${manifest.source}.`);
429
+ }
430
+ const languageFolders = (folders ?? []).filter((entry) => entry.directory).map((entry) => entry.name);
431
+ if (!languageFolders.includes(source)) throw new CheckSetupError(`${join(locales, source)} is not a folder.`);
432
+ const languages = options.languages ?? languageFolders.filter((name) => name !== source).sort();
433
+ const missingFolders = languages.filter((language2) => !languageFolders.includes(language2));
434
+ if (missingFolders.length > 0) {
435
+ throw new CheckSetupError(`No folder for ${missingFolders.join(", ")} in ${locales}.`);
436
+ }
437
+ const pathOf = (language2, file) => join(locales, language2, `${file}.json`);
438
+ const filesOf = async (language2) => {
439
+ const names = [];
440
+ for (const entry of await reader.readDir(join(locales, language2)) ?? []) {
441
+ if (entry.directory || !entry.name.endsWith(".json")) continue;
442
+ const file = entry.name.slice(0, -".json".length);
443
+ if (FILE_NAME.test(file)) names.push(file);
444
+ else {
445
+ report({
446
+ rule: "skipped-file",
447
+ path: join(locales, language2, entry.name),
448
+ language: language2,
449
+ key: null,
450
+ message: `${entry.name}: a file name has only letters, digits, - and _.`
451
+ });
452
+ }
453
+ }
454
+ return names.sort();
455
+ };
456
+ const read = async (language2, file) => {
457
+ const path = pathOf(language2, file);
458
+ const text = await reader.readFile(path);
459
+ if (text === null) return /* @__PURE__ */ new Map();
460
+ let parsed;
461
+ try {
462
+ parsed = JSON.parse(text);
463
+ } catch (error) {
464
+ report({ rule: "invalid-json", path, language: language2, key: null, message: error.message });
465
+ return null;
466
+ }
467
+ if (!isObject(parsed)) {
468
+ report({ rule: "invalid-json", path, language: language2, key: null, message: "The top of the file is not an object." });
469
+ return null;
470
+ }
471
+ const strings = /* @__PURE__ */ new Map();
472
+ for (const [key, value] of pathsOf(parsed)) {
473
+ if (typeof value === "string") strings.set(key, value);
474
+ else {
475
+ report({
476
+ rule: "not-a-string",
477
+ path,
478
+ language: language2,
479
+ key: `${file}:${key}`,
480
+ message: `${file}:${key} holds ${value === null ? "null" : Array.isArray(value) ? "an array" : typeof value}.`
481
+ });
482
+ }
483
+ }
484
+ return strings;
485
+ };
486
+ const sourceFiles = await filesOf(source);
487
+ const sources = /* @__PURE__ */ new Map();
488
+ let files = sourceFiles.length;
489
+ let keys = 0;
490
+ for (const file of sourceFiles) {
491
+ const strings = await read(source, file);
492
+ if (strings === null) continue;
493
+ sources.set(file, strings);
494
+ keys += strings.size;
495
+ for (const [key, text] of strings) {
496
+ if (text === "") {
497
+ report({
498
+ rule: "empty-source",
499
+ path: pathOf(source, file),
500
+ language: source,
501
+ key: `${file}:${key}`,
502
+ message: `${file}:${key} is empty in the source.`
503
+ });
504
+ }
505
+ }
506
+ }
507
+ for (const language2 of languages) {
508
+ const targetFiles = await filesOf(language2);
509
+ files += targetFiles.length;
510
+ for (const file of targetFiles.filter((name) => !sourceFiles.includes(name))) {
511
+ report({
512
+ rule: "extra-file",
513
+ path: pathOf(language2, file),
514
+ language: language2,
515
+ key: null,
516
+ message: `${file}.json has no version in ${source}.`
517
+ });
518
+ }
519
+ for (const file of sourceFiles) {
520
+ const sourceStrings = sources.get(file);
521
+ if (!sourceStrings || manifest.states.get(`${language2}
522
+ ${file}`) === "disabled") continue;
523
+ const path = pathOf(language2, file);
524
+ const target = targetFiles.includes(file) ? await read(language2, file) : /* @__PURE__ */ new Map();
525
+ if (target === null) continue;
526
+ const plurals = /* @__PURE__ */ new Map();
527
+ for (const [key, text] of sourceStrings) {
528
+ const plural2 = pluralOf(key);
529
+ if (plural2) plurals.set(plural2.base, (plurals.get(plural2.base) ?? /* @__PURE__ */ new Map()).set(plural2.form, text));
530
+ }
531
+ const sourceHasOneForm = pluralFormsOf(source, ["one", "other"]).length === 1;
532
+ for (const [base, forms] of plurals) {
533
+ if (!forms.has("other") || forms.size === 1 && !sourceHasOneForm) plurals.delete(base);
534
+ }
535
+ const checkKey = (key, text, form) => {
536
+ const id = `${file}:${key}`;
537
+ const value = target.get(key);
538
+ if (value === void 0) {
539
+ report({ rule: "missing-key", path, language: language2, key: id, message: `${id} has no ${language2} text.` });
540
+ } else if (value === "") {
541
+ const mark = manifest.marks.get(`${language2}
542
+ ${id}`);
543
+ if (mark === void 0) {
544
+ report({ rule: "empty-value", path, language: language2, key: id, message: `${id} is empty in ${language2}.` });
545
+ } else if (mark !== sourceHashOf(text)) {
546
+ report({
547
+ rule: "stale-empty",
548
+ path,
549
+ language: language2,
550
+ key: id,
551
+ message: `${id} is empty on purpose in ${language2}, but its ${source} text changed since.`
552
+ });
553
+ }
554
+ } else {
555
+ const implicitCount = form !== null && form !== "other";
556
+ const expected = placeholdersOf(text).filter((item) => !implicitCount || !COUNT.has(item));
557
+ const found = placeholdersOf(value).filter((item) => !implicitCount || !COUNT.has(item));
558
+ if (!sameList(expected, found)) {
559
+ report({
560
+ rule: "placeholders",
561
+ path,
562
+ language: language2,
563
+ key: id,
564
+ message: `${id}: ${describeDifference(expected, found)}.`
565
+ });
566
+ }
567
+ }
568
+ };
569
+ const allowed = new Set(sourceStrings.keys());
570
+ for (const [key, text] of sourceStrings) {
571
+ if (!plurals.has(pluralOf(key)?.base ?? "")) checkKey(key, text, null);
572
+ }
573
+ for (const [base, forms] of plurals) {
574
+ for (const form of pluralFormsOf(language2, [...forms.keys()])) {
575
+ allowed.add(`${base}_${form}`);
576
+ checkKey(`${base}_${form}`, forms.get(form) ?? forms.get("other"), form);
577
+ }
578
+ }
579
+ for (const key of target.keys()) {
580
+ if (!allowed.has(key)) {
581
+ report({
582
+ rule: "extra-key",
583
+ path,
584
+ language: language2,
585
+ key: `${file}:${key}`,
586
+ message: `${file}:${key} is not in ${source}.`
587
+ });
588
+ }
589
+ }
590
+ }
591
+ }
592
+ return summarize({ locales, source, languages, files, keys });
593
+ };
594
+ var REPORTERS = ["pretty", "json", "github"];
595
+ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
596
+ var summaryOf = (report) => `${plural(report.errors, "error")}, ${plural(report.warnings, "warning")} in ${plural(report.files, "file")} (${report.source} \u2192 ${report.languages.join(", ") || "no language"}, ${plural(report.keys, "key")}).`;
597
+ var prettyReport = (report) => {
598
+ const lines = [];
599
+ const byPath = /* @__PURE__ */ new Map();
600
+ for (const problem of report.problems) byPath.set(problem.path, [...byPath.get(problem.path) ?? [], problem]);
601
+ for (const [path, problems] of [...byPath].sort(([a], [b]) => a.localeCompare(b))) {
602
+ lines.push(path);
603
+ for (const problem of problems) {
604
+ lines.push(` ${problem.severity === "error" ? "error " : "warning"} ${problem.message} ${problem.rule}`);
605
+ }
606
+ lines.push("");
607
+ }
608
+ lines.push(summaryOf(report));
609
+ return `${lines.join("\n")}
610
+ `;
611
+ };
612
+ var escapeData = (text) => text.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
613
+ var escapeProperty = (text) => escapeData(text).replace(/:/g, "%3A").replace(/,/g, "%2C");
614
+ var githubReport = (report) => {
615
+ const lines = report.problems.map(
616
+ (problem) => `::${problem.severity} file=${escapeProperty(problem.path)},title=${escapeProperty(`i18n ${problem.rule}`)}::` + escapeData(problem.message)
617
+ );
618
+ lines.push(summaryOf(report));
619
+ return `${lines.join("\n")}
620
+ `;
621
+ };
622
+ var failed = (report, maxWarnings) => report.errors > 0 || maxWarnings !== null && report.warnings > maxWarnings;
623
+
624
+ // ../mcp/src/cli/credentials.ts
625
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
626
+ import { dirname, join as join2 } from "node:path";
627
+
628
+ // ../mcp/src/cli/errors.ts
629
+ var EXIT = {
630
+ ok: 0,
631
+ /** The API refused, or a sign-in did not end. */
632
+ failed: 1,
633
+ /** A command, an option, a configuration or an input is wrong. */
634
+ usage: 2,
635
+ /** No account is signed in, or the API refused the token. */
636
+ signedOut: 3
637
+ };
638
+ var ERRORS = {
639
+ G8N1001: { exit: EXIT.usage, title: "The command or an option is wrong." },
640
+ G8N1002: { exit: EXIT.usage, title: "No address of the API: pass --api or set GIT8N_API_URL." },
641
+ G8N1003: { exit: EXIT.usage, title: "The input of apply is not valid." },
642
+ G8N1004: { exit: EXIT.usage, title: "The file of the credentials is not valid." },
643
+ G8N1005: { exit: EXIT.usage, title: "check cannot find the locales, the manifest or the source language." },
644
+ G8N2001: { exit: EXIT.signedOut, title: "No account is signed in, or the API refused the token." },
645
+ G8N2002: { exit: EXIT.failed, title: "The device code expired before somebody approved it." },
646
+ G8N2003: { exit: EXIT.failed, title: "The person refused the device code." },
647
+ G8N3001: { exit: EXIT.usage, title: "No repository matches." },
648
+ G8N3002: { exit: EXIT.usage, title: "The repository is not clear: pass --repo." },
649
+ G8N3003: { exit: EXIT.usage, title: "The language is not clear, or the account cannot open it: pass --lang." },
650
+ G8N3004: {
651
+ exit: EXIT.failed,
652
+ title: "A key is not a key of the source, or its file has no version in the language."
653
+ },
654
+ G8N4001: { exit: EXIT.failed, title: "The API refused the request." },
655
+ G8N4002: { exit: EXIT.failed, title: "The API failed, or did not answer." }
656
+ };
657
+ var CliError = class extends Error {
658
+ code;
659
+ exit;
660
+ /** More lines for a person: the repositories to choose from, the keys that are not there. */
661
+ details;
662
+ constructor(code, message, details = []) {
663
+ super(message);
664
+ this.name = "CliError";
665
+ this.code = code;
666
+ this.exit = ERRORS[code].exit;
667
+ this.details = details;
668
+ }
669
+ };
670
+ var messageOf = (said) => {
671
+ try {
672
+ const parsed = JSON.parse(said);
673
+ if (typeof parsed.message === "string") return parsed.message;
674
+ } catch {
675
+ }
676
+ return said;
677
+ };
678
+ var asCliError = (error) => {
679
+ if (error instanceof CliError) return error;
680
+ if (error instanceof ApiError) {
681
+ const message = `${error.status} \u2014 ${messageOf(error.message)}`;
682
+ if (error.status === 401) return new CliError("G8N2001", `${message}. Run git8n login.`);
683
+ return new CliError(error.status < 500 ? "G8N4001" : "G8N4002", message);
684
+ }
685
+ if (error instanceof Error) {
686
+ const cause = error.cause instanceof Error ? `: ${error.cause.code ?? error.cause.message}` : "";
687
+ return new CliError("G8N4002", `${error.message}${cause}`);
688
+ }
689
+ return new CliError("G8N4002", String(error));
690
+ };
691
+
692
+ // ../mcp/src/cli/credentials.ts
693
+ var credentialsPath = (env, home) => join2(env.XDG_CONFIG_HOME || join2(home, ".config"), "git8n", "credentials.json");
694
+ var readCredentials = async (path) => {
695
+ let text;
696
+ try {
697
+ text = await readFile(path, "utf8");
698
+ } catch (error) {
699
+ if (error.code === "ENOENT") return { servers: {} };
700
+ throw new CliError("G8N1004", `${path} cannot be read: ${error.message}`);
701
+ }
702
+ try {
703
+ const parsed = JSON.parse(text);
704
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.servers !== "object") throw new Error();
705
+ return parsed;
706
+ } catch {
707
+ throw new CliError("G8N1004", `${path} is not valid. Delete it, then run git8n login again.`);
708
+ }
709
+ };
710
+ var writeCredentials = async (path, credentials) => {
711
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
712
+ const next = `${path}.${process.pid}.tmp`;
713
+ await writeFile(next, `${JSON.stringify(credentials, null, 2)}
714
+ `, { mode: 384 });
715
+ await chmod(next, 384);
716
+ await rename(next, path);
717
+ };
718
+
719
+ // ../mcp/src/cli/keys.ts
720
+ var idOf = (entry) => `${entry.file}:${entry.key}`;
721
+ var parseId = (id) => {
722
+ const colon = id.indexOf(":");
723
+ if (colon <= 0 || colon === id.length - 1) {
724
+ throw new CliError("G8N1001", `${id} is not a key. Name a key as <file>:<key>, for example common:hello.title.`);
725
+ }
726
+ return { file: id.slice(0, colon), key: id.slice(colon + 1) };
727
+ };
728
+ var disabledFilesOf = (read) => Object.entries(read.files ?? {}).filter(([, state]) => state === "disabled").map(([file]) => file).sort();
729
+ var parentOf = (key) => key.slice(0, Math.max(key.lastIndexOf("."), 0));
730
+ var neighboursOf = (read, entry, most = 5) => read.entries.filter(
731
+ (other) => other.file === entry.file && other.key !== entry.key && parentOf(other.key) === parentOf(entry.key)
732
+ ).slice(0, most);
733
+ var pageOf = (rows, cursor, limit) => {
734
+ const page = rows.slice(cursor, cursor + limit);
735
+ return {
736
+ total: rows.length,
737
+ cursor,
738
+ nextCursor: cursor + page.length < rows.length ? cursor + page.length : null,
739
+ rows: page
740
+ };
741
+ };
742
+ var matchesText = (entry, text) => {
743
+ const wanted = text.toLowerCase();
744
+ return [idOf(entry), entry.source, entry.value ?? ""].some((field) => field.toLowerCase().includes(wanted));
745
+ };
746
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
747
+ var flatten = (file, value, path, into) => {
748
+ if (typeof value === "string") {
749
+ if (!path) throw new CliError("G8N1003", `${file} holds a text and no key.`);
750
+ into.set(`${file}:${path}`, value);
751
+ return;
752
+ }
753
+ if (!isRecord(value)) {
754
+ throw new CliError("G8N1003", `${file}:${path} is not a text. A value is a string, or an object of keys.`);
755
+ }
756
+ for (const [name, inner] of Object.entries(value)) flatten(file, inner, path ? `${path}.${name}` : name, into);
757
+ };
758
+ var textsOf = (input, file) => {
759
+ let parsed;
760
+ try {
761
+ parsed = JSON.parse(input);
762
+ } catch (error) {
763
+ throw new CliError("G8N1003", `The input is not JSON: ${error.message}`);
764
+ }
765
+ if (!isRecord(parsed)) throw new CliError("G8N1003", "The input must be a JSON object of keys and texts.");
766
+ const texts = /* @__PURE__ */ new Map();
767
+ if (file) {
768
+ flatten(file, parsed, "", texts);
769
+ } else {
770
+ for (const [name, value] of Object.entries(parsed)) {
771
+ if (name.includes(":")) {
772
+ if (typeof value !== "string") throw new CliError("G8N1003", `${name} is not a text.`);
773
+ const { file: inFile, key } = parseId(name);
774
+ texts.set(`${inFile}:${key}`, value);
775
+ } else if (isRecord(value)) {
776
+ flatten(name, value, "", texts);
777
+ } else {
778
+ throw new CliError(
779
+ "G8N1003",
780
+ `${name} names no file. Write <file>:<key>, put the keys under their file, or pass --file.`
781
+ );
782
+ }
783
+ }
784
+ }
785
+ const blank = [...texts].filter(([, text]) => text.trim() === "").map(([id]) => id);
786
+ if (blank.length > 0) {
787
+ throw new CliError(
788
+ "G8N1003",
789
+ "An empty text is not a translation. To leave a key empty on purpose, mark it \xAB vide expr\xE8s \xBB in the editor.",
790
+ blank.slice(0, 10)
791
+ );
792
+ }
793
+ return texts;
794
+ };
795
+ var MOST_KEYS = 2e3;
796
+ var planOf = (read, texts, readAt) => {
797
+ const disabled = new Set(disabledFilesOf(read));
798
+ const byId = new Map(read.entries.map((entry) => [idOf(entry), entry]));
799
+ const closed = [...texts.keys()].filter((id) => disabled.has(parseId(id).file));
800
+ const unknown = [...texts.keys()].filter((id) => !byId.has(id) && !disabled.has(parseId(id).file));
801
+ if (closed.length > 0) {
802
+ throw new CliError(
803
+ "G8N3004",
804
+ `These keys are in a file that has no ${read.language} version, and take no proposal.`,
805
+ closed.slice(0, 10)
806
+ );
807
+ }
808
+ if (unknown.length > 0) {
809
+ throw new CliError(
810
+ "G8N3004",
811
+ `${unknown.length} key(s) of the input are not keys of the source (${read.source}).`,
812
+ unknown.slice(0, 10)
813
+ );
814
+ }
815
+ const changes = [];
816
+ const unchanged = [];
817
+ const stillToReview = [];
818
+ for (const [id, to] of texts) {
819
+ const entry = byId.get(id);
820
+ if (entry.value !== to) changes.push({ id, file: entry.file, key: entry.key, from: entry.value, to });
821
+ else if (entry.state === "review") stillToReview.push(id);
822
+ else unchanged.push(id);
823
+ }
824
+ if (changes.length > MOST_KEYS) {
825
+ throw new CliError(
826
+ "G8N1003",
827
+ `${changes.length} keys are more than ${MOST_KEYS} in one proposal. Split the input.`
828
+ );
829
+ }
830
+ return { readAt, changes, unchanged, stillToReview };
831
+ };
832
+ var valuesOf = (plan) => plan.changes.map(({ file, key, to }) => ({ file, key, value: to }));
833
+
834
+ // ../mcp/src/cli/login.ts
835
+ var CLIENT_ID = "git8n-cli";
836
+ var GRANT = "urn:ietf:params:oauth:grant-type:device_code";
837
+ var post = (io, url, body) => io.fetch(url, {
838
+ method: "POST",
839
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
840
+ body: JSON.stringify(body),
841
+ signal: AbortSignal.timeout(30 * 1e3)
842
+ });
843
+ var askDeviceCode = async (io, api) => {
844
+ const response = await post(io, `${api}/api/auth/device/code`, { client_id: CLIENT_ID });
845
+ if (!response.ok) {
846
+ throw new CliError(
847
+ response.status < 500 ? "G8N4001" : "G8N4002",
848
+ `${response.status} \u2014 the API gave no device code: ${await response.text().catch(() => "")}`
849
+ );
850
+ }
851
+ return await response.json();
852
+ };
853
+ var waitForApproval = async (io, api, code) => {
854
+ const deadline = io.now() + code.expires_in * 1e3;
855
+ let interval = Math.max(code.interval, 1) * 1e3;
856
+ for (; ; ) {
857
+ if (io.now() >= deadline) throw new CliError("G8N2002", "The code expired. Run git8n login again.");
858
+ await io.sleep(interval);
859
+ const response = await post(io, `${api}/api/auth/device/token`, {
860
+ grant_type: GRANT,
861
+ device_code: code.device_code,
862
+ client_id: CLIENT_ID
863
+ });
864
+ const answered = await response.json().catch(() => ({}));
865
+ if (response.ok && answered.access_token) return answered.access_token;
866
+ switch (answered.error) {
867
+ case "authorization_pending":
868
+ continue;
869
+ case "slow_down":
870
+ interval += 5e3;
871
+ continue;
872
+ case "expired_token":
873
+ throw new CliError("G8N2002", "The code expired. Run git8n login again.");
874
+ case "access_denied":
875
+ throw new CliError("G8N2003", "The code was refused in the editor.");
876
+ default:
877
+ throw new CliError(
878
+ response.status < 500 ? "G8N4001" : "G8N4002",
879
+ `${response.status} \u2014 the API refused the device code: ${answered.error ?? "no reason"}`
880
+ );
881
+ }
882
+ }
883
+ };
884
+ var personOf = async (io, api, token) => {
885
+ const response = await io.fetch(`${api}/api/auth/get-session`, {
886
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
887
+ signal: AbortSignal.timeout(30 * 1e3)
888
+ }).catch(() => null);
889
+ if (!response?.ok) return null;
890
+ const session = await response.json().catch(() => null);
891
+ return session?.user ? { name: session.user.name, email: session.user.email } : null;
892
+ };
893
+ var signOut = async (io, api, token) => {
894
+ const response = await io.fetch(`${api}/api/auth/sign-out`, {
895
+ method: "POST",
896
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
897
+ body: "{}",
898
+ signal: AbortSignal.timeout(30 * 1e3)
899
+ }).catch(() => null);
900
+ return response?.ok ?? false;
901
+ };
902
+
903
+ // ../mcp/src/cli/prompt.ts
904
+ var promptKeysOf = (read, entries) => entries.map((entry) => ({
905
+ id: idOf(entry),
906
+ source: entry.source,
907
+ ...entry.state === "review" ? { was: entry.was, value: entry.value } : {},
908
+ neighbours: neighboursOf(read, entry, 3).map((other) => ({
909
+ id: idOf(other),
910
+ source: other.source,
911
+ value: other.value
912
+ }))
913
+ }));
914
+ var quoted = (text) => text ? `\xAB ${text} \xBB` : "(none)";
915
+ var translatePrompt = (args) => {
916
+ const { repository, read, keys } = args;
917
+ const lines = [
918
+ `# Translate ${repository} from ${read.source} to ${read.language}`,
919
+ "",
920
+ `You translate the texts of the interface of ${repository}. The source language is ${read.source}. The target language is ${read.language}.`,
921
+ "",
922
+ "Rules:",
923
+ "- Keep each placeholder as it is: {{name}}, {count}, <0>\u2026</0>, %s, $t(\u2026).",
924
+ "- Keep the tone and about the length of the source. A button stays short.",
925
+ "- The id of a key and its neighbours say where the text shows. Use them.",
926
+ "- Translate only the keys of this list.",
927
+ "",
928
+ `Revision of the source: ${read.commit}`,
929
+ "",
930
+ `## Keys (${keys.length} of ${args.total})`
931
+ ];
932
+ for (const key of keys) {
933
+ lines.push("", `### ${key.id}`, `Source: ${quoted(key.source)}`);
934
+ if (key.was !== void 0) {
935
+ lines.push(`The source was: ${quoted(key.was)}`, `The translation now: ${quoted(key.value)}`);
936
+ }
937
+ if (key.neighbours.length > 0) {
938
+ lines.push("Neighbours:");
939
+ for (const other of key.neighbours) {
940
+ lines.push(`- ${other.id}: ${quoted(other.source)} \u2192 ${quoted(other.value)}`);
941
+ }
942
+ }
943
+ }
944
+ lines.push(
945
+ "",
946
+ "## Answer",
947
+ "",
948
+ "Answer with one JSON object only: one entry for each key above, the id as the name and the translation as the value.",
949
+ "",
950
+ "```json",
951
+ JSON.stringify(Object.fromEntries(keys.slice(0, 2).map((key) => [key.id, "\u2026"])), null, 2),
952
+ "```",
953
+ "",
954
+ `Then send it with: git8n apply - --repo ${repository} --lang ${read.language} --read-at ${read.commit}`
955
+ );
956
+ if (args.nextCursor !== null) lines.push("", `More keys wait: add --cursor ${args.nextCursor} for the next ones.`);
957
+ return `${lines.join("\n")}
958
+ `;
959
+ };
960
+
961
+ // ../mcp/src/cli/repository.ts
962
+ var nameOf = (repository) => `${repository.owner}/${repository.name}`;
963
+ var githubSlugsOf = (remotes) => {
964
+ const slugs = remotes.flatMap((remote) => {
965
+ const found = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(remote.trim());
966
+ return found ? [`${found[1]}/${found[2]}`.toLowerCase()] : [];
967
+ });
968
+ return [...new Set(slugs)];
969
+ };
970
+ var listOf = (repositories2) => repositories2.length === 0 ? ["This account opens no repository."] : ["The repositories of this account:", ...repositories2.map((repository) => ` ${nameOf(repository)}`)];
971
+ var pickRepository = (repositories2, wanted, slugs) => {
972
+ if (wanted) {
973
+ const found = repositories2.find(
974
+ (repository) => repository.id === wanted || nameOf(repository).toLowerCase() === wanted.toLowerCase()
975
+ );
976
+ if (!found) throw new CliError("G8N3001", `No repository of this account is ${wanted}.`, listOf(repositories2));
977
+ return found;
978
+ }
979
+ const fromGit = repositories2.filter((repository) => slugs.includes(nameOf(repository).toLowerCase()));
980
+ if (fromGit.length === 1) return fromGit[0];
981
+ if (fromGit.length === 0 && slugs.length > 0) {
982
+ throw new CliError(
983
+ "G8N3001",
984
+ `This account opens no repository of this folder (${slugs.join(", ")}). Pass --repo <owner/name>.`,
985
+ listOf(repositories2)
986
+ );
987
+ }
988
+ if (repositories2.length === 1) return repositories2[0];
989
+ if (repositories2.length === 0) throw new CliError("G8N3001", "This account opens no repository.");
990
+ throw new CliError("G8N3002", "Name the repository with --repo <owner/name>.", listOf(repositories2));
991
+ };
992
+ var languagesOpenTo = (me, repository) => {
993
+ if (me.admin) return repository.targets;
994
+ const membership = me.memberships.find((held) => held.repositoryId === repository.id);
995
+ return repository.targets.filter((language2) => membership?.languages.includes(language2));
996
+ };
997
+ var pickLanguage = (me, repository, wanted) => {
998
+ const open = languagesOpenTo(me, repository);
999
+ if (wanted) {
1000
+ if (!repository.targets.includes(wanted)) {
1001
+ throw new CliError("G8N3003", `${nameOf(repository)} does not translate into ${wanted}.`, [
1002
+ `Its target languages: ${repository.targets.join(", ") || "none"}.`
1003
+ ]);
1004
+ }
1005
+ if (!open.includes(wanted)) {
1006
+ throw new CliError("G8N3003", `This account cannot open ${wanted} in ${nameOf(repository)}.`, [
1007
+ `The languages this account can open there: ${open.join(", ") || "none"}.`
1008
+ ]);
1009
+ }
1010
+ return wanted;
1011
+ }
1012
+ if (open.length === 1) return open[0];
1013
+ throw new CliError("G8N3003", "Name the language with --lang <language>.", [
1014
+ `The languages this account can open in ${nameOf(repository)}: ${open.join(", ") || "none"}.`
1015
+ ]);
1016
+ };
1017
+
1018
+ // ../mcp/src/cli/run.ts
1019
+ var ACCESS_TOKEN_PREFIX = "g8n_";
1020
+ var GLOBAL = ["api", "json", "help", "version"];
1021
+ var COMMANDS = {
1022
+ login: ["no-open"],
1023
+ logout: [],
1024
+ whoami: [],
1025
+ status: ["repo"],
1026
+ keys: ["repo", "lang", "state", "missing", "file", "values", "limit", "cursor"],
1027
+ get: ["repo", "lang"],
1028
+ search: ["repo", "lang", "file", "limit", "cursor"],
1029
+ apply: ["repo", "lang", "file", "dry-run", "read-at"],
1030
+ proposals: ["repo", "lang"],
1031
+ prompt: ["repo", "lang", "to", "state", "file", "limit", "cursor"],
1032
+ mcp: [],
1033
+ check: ["locales", "manifest", "source", "lang", "reporter", "max-warnings"]
1034
+ };
1035
+ var OPTIONS = {
1036
+ api: { type: "string" },
1037
+ json: { type: "boolean" },
1038
+ help: { type: "boolean", short: "h" },
1039
+ version: { type: "boolean", short: "v" },
1040
+ "no-open": { type: "boolean" },
1041
+ repo: { type: "string" },
1042
+ lang: { type: "string" },
1043
+ to: { type: "string" },
1044
+ state: { type: "string" },
1045
+ missing: { type: "boolean" },
1046
+ file: { type: "string" },
1047
+ values: { type: "boolean" },
1048
+ limit: { type: "string" },
1049
+ cursor: { type: "string" },
1050
+ "dry-run": { type: "boolean" },
1051
+ "read-at": { type: "string" },
1052
+ locales: { type: "string" },
1053
+ manifest: { type: "string" },
1054
+ source: { type: "string" },
1055
+ reporter: { type: "string" },
1056
+ "max-warnings": { type: "string" }
1057
+ };
1058
+ var HELP = `git8n \u2014 the translations of a repository, from a terminal
1059
+
1060
+ Usage: git8n <command> [options]
1061
+
1062
+ Account
1063
+ login Sign in with a code that you approve in the editor
1064
+ logout Sign out, and forget the token of this computer
1065
+ whoami The account, and what it may do in each repository
1066
+
1067
+ Read
1068
+ status The progress of each language
1069
+ keys The keys of a language (--missing, --state, --file, --values)
1070
+ get <file:key> One key, with its neighbours
1071
+ search <text> The keys whose id, source or translation holds a text
1072
+ proposals The batches that wait on a language
1073
+
1074
+ Write
1075
+ apply <file|-> Propose a JSON object of translations as one commit (--dry-run)
1076
+
1077
+ Agents
1078
+ prompt translate A prompt that asks an LLM for the missing translations
1079
+ mcp The MCP server, on stdin and stdout
1080
+
1081
+ CI, with no server
1082
+ check Check the locale files of this folder (--locales, --reporter, --max-warnings)
1083
+
1084
+ Options
1085
+ --api <url> The address of the editor. Default: the last login
1086
+ --repo <owner/name> The repository. Default: the GitHub remote of this folder
1087
+ --lang <language> The target language. Default: the only one the account can open
1088
+ --json Answer one JSON document on stdout
1089
+ -h, --help This help
1090
+ -v, --version The version
1091
+
1092
+ Environment
1093
+ GIT8N_API_URL The address of the editor
1094
+ GIT8N_API_TOKEN An access token of the page \xAB Jetons \xBB, in place of login
1095
+ GIT8N_REPO The repository, in place of --repo
1096
+
1097
+ Exit status: 0 done, 1 refused or failed, 2 wrong command or input, 3 not signed in.
1098
+ Documentation: <api>/docs/reference/cli
1099
+ `;
1100
+ var integerOf = (name, value, fallback, least, most) => {
1101
+ if (value === void 0) return fallback;
1102
+ const parsed = Number(value);
1103
+ if (!Number.isInteger(parsed) || parsed < least || parsed > most) {
1104
+ throw new CliError("G8N1001", `--${name} takes a whole number from ${least} to ${most}.`);
1105
+ }
1106
+ return parsed;
1107
+ };
1108
+ var STATES = ["missing", "review", "translated", "all"];
1109
+ var stateOf = (options, fallback) => {
1110
+ if (options.missing) return "missing";
1111
+ const state = options.state ?? fallback;
1112
+ if (!STATES.includes(state)) {
1113
+ throw new CliError("G8N1001", `--state takes one of ${STATES.join(", ")}.`);
1114
+ }
1115
+ return state;
1116
+ };
1117
+ async function run(argv, io) {
1118
+ let json = argv.includes("--json");
1119
+ try {
1120
+ let parsed;
1121
+ try {
1122
+ parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
1123
+ } catch (error) {
1124
+ throw new CliError("G8N1001", `${error.message}. Run git8n --help.`);
1125
+ }
1126
+ const options = parsed.values;
1127
+ const [command, ...rest] = parsed.positionals;
1128
+ json = options.json === true;
1129
+ if (options.version) {
1130
+ io.stdout(json ? `${JSON.stringify({ data: { version: io.version } })}
1131
+ ` : `${io.version}
1132
+ `);
1133
+ return EXIT.ok;
1134
+ }
1135
+ if (options.help || !command) {
1136
+ io.stdout(HELP);
1137
+ return command || options.help ? EXIT.ok : EXIT.usage;
1138
+ }
1139
+ const allowed = COMMANDS[command];
1140
+ if (!allowed) throw new CliError("G8N1001", `${command} is not a command. Run git8n --help.`);
1141
+ const extra = Object.keys(options).filter(
1142
+ (name) => !GLOBAL.includes(name) && !allowed.includes(name)
1143
+ );
1144
+ if (extra.length > 0) {
1145
+ throw new CliError("G8N1001", `${command} takes no ${extra.map((name) => `--${name}`).join(", ")}.`);
1146
+ }
1147
+ return await new Session(io, options, json).run(command, rest);
1148
+ } catch (error) {
1149
+ const failure = asCliError(error);
1150
+ if (json) {
1151
+ io.stdout(
1152
+ `${JSON.stringify({ error: { code: failure.code, message: failure.message, details: failure.details } }, null, 2)}
1153
+ `
1154
+ );
1155
+ } else {
1156
+ io.stderr(`error ${failure.code}: ${failure.message}
1157
+ `);
1158
+ for (const line of failure.details) io.stderr(` ${line}
1159
+ `);
1160
+ }
1161
+ return failure.exit;
1162
+ }
1163
+ }
1164
+ var Session = class {
1165
+ io;
1166
+ options;
1167
+ json;
1168
+ path;
1169
+ credentialsRead;
1170
+ tokenUsed;
1171
+ // No parameter properties: Node runs this file with type stripping, which refuses them.
1172
+ constructor(io, options, json) {
1173
+ this.io = io;
1174
+ this.options = options;
1175
+ this.json = json;
1176
+ this.path = credentialsPath(io.env, io.home);
1177
+ }
1178
+ async run(command, positionals) {
1179
+ try {
1180
+ return await this.dispatch(command, positionals);
1181
+ } catch (error) {
1182
+ throw this.refusedToken(error);
1183
+ }
1184
+ }
1185
+ /** A 401 says how to get a token that works: a new access token, or a new login. */
1186
+ refusedToken(error) {
1187
+ if (!(error instanceof ApiError) || error.status !== 401 || this.tokenUsed === void 0) return error;
1188
+ if (this.io.env.GIT8N_API_TOKEN) {
1189
+ return new CliError(
1190
+ "G8N2001",
1191
+ "The API refused the token of GIT8N_API_TOKEN: it expired, or somebody revoked it. Make a new one on the page \xAB Jetons d\u2019acc\xE8s \xBB."
1192
+ );
1193
+ }
1194
+ if (this.tokenUsed.startsWith(ACCESS_TOKEN_PREFIX)) {
1195
+ return new CliError(
1196
+ "G8N2001",
1197
+ "The API refused the access token of this computer: it expired, or somebody revoked it. Make a new one on the page \xAB Jetons d\u2019acc\xE8s \xBB."
1198
+ );
1199
+ }
1200
+ return new CliError("G8N2001", "The session of this computer ended. Run git8n login.");
1201
+ }
1202
+ async dispatch(command, positionals) {
1203
+ const expectArgs = (count, usage) => {
1204
+ if (positionals.length !== count) throw new CliError("G8N1001", `Usage: git8n ${usage}`);
1205
+ };
1206
+ switch (command) {
1207
+ case "login":
1208
+ expectArgs(0, "login [--api <url>]");
1209
+ return this.login();
1210
+ case "logout":
1211
+ expectArgs(0, "logout [--api <url>]");
1212
+ return this.logout();
1213
+ case "whoami":
1214
+ expectArgs(0, "whoami");
1215
+ return this.whoami();
1216
+ case "status":
1217
+ expectArgs(0, "status [--repo <owner/name>]");
1218
+ return this.status();
1219
+ case "keys":
1220
+ expectArgs(0, "keys [--lang <language>] [--missing]");
1221
+ return this.keys();
1222
+ case "get":
1223
+ expectArgs(1, "get <file:key> [--lang <language>]");
1224
+ return this.get(positionals[0]);
1225
+ case "search":
1226
+ expectArgs(1, "search <text> [--lang <language>]");
1227
+ return this.search(positionals[0]);
1228
+ case "apply":
1229
+ expectArgs(1, "apply <file.json|-> [--lang <language>] [--dry-run]");
1230
+ return this.apply(positionals[0]);
1231
+ case "proposals":
1232
+ expectArgs(0, "proposals [--lang <language>]");
1233
+ return this.proposals();
1234
+ case "prompt":
1235
+ if (positionals[0] !== "translate" || positionals.length !== 1) {
1236
+ throw new CliError("G8N1001", "Usage: git8n prompt translate --to <language>");
1237
+ }
1238
+ return this.promptTranslate();
1239
+ case "mcp":
1240
+ expectArgs(0, "mcp");
1241
+ return this.mcp();
1242
+ case "check":
1243
+ expectArgs(0, "check [--locales <folder>] [--reporter pretty|json|github] [--max-warnings <n>]");
1244
+ return this.check();
1245
+ default:
1246
+ throw new CliError("G8N1001", `${command} is not a command.`);
1247
+ }
1248
+ }
1249
+ answer(data, human) {
1250
+ this.io.stdout(this.json ? `${JSON.stringify({ data }, null, 2)}
1251
+ ` : human());
1252
+ return EXIT.ok;
1253
+ }
1254
+ credentials() {
1255
+ this.credentialsRead ??= readCredentials(this.path);
1256
+ return this.credentialsRead;
1257
+ }
1258
+ async api() {
1259
+ const named = this.options.api ?? this.io.env.GIT8N_API_URL ?? (await this.credentials()).default;
1260
+ if (!named) throw new CliError("G8N1002", "Pass --api <url>, for example --api https://git8n.example.com.");
1261
+ return named.replace(/\/+$/, "");
1262
+ }
1263
+ async config() {
1264
+ const url = await this.api();
1265
+ const token = this.io.env.GIT8N_API_TOKEN || (await this.credentials()).servers[url]?.token;
1266
+ if (!token) throw new CliError("G8N2001", `No account is signed in to ${url}. Run git8n login.`);
1267
+ this.tokenUsed = token;
1268
+ return { url, token, fetch: this.io.fetch };
1269
+ }
1270
+ async repository(config) {
1271
+ const wanted = this.options.repo ?? this.io.env.GIT8N_REPO;
1272
+ return pickRepository(await repositories(config), wanted, githubSlugsOf(await this.io.gitRemotes(this.io.cwd)));
1273
+ }
1274
+ /** The repository, the language and its catalogue, for the commands that read keys. */
1275
+ async read(language2) {
1276
+ const config = await this.config();
1277
+ const [repository, me] = await Promise.all([this.repository(config), rightsOf(config)]);
1278
+ const chosen = pickLanguage(me, repository, language2 ?? this.options.lang);
1279
+ const read = await catalogue(config, repository.id, chosen);
1280
+ return { config, repository, read };
1281
+ }
1282
+ rowOf(entry, withValues) {
1283
+ return {
1284
+ id: idOf(entry),
1285
+ file: entry.file,
1286
+ key: entry.key,
1287
+ state: entry.state,
1288
+ ...entry.empty ? { empty: true } : {},
1289
+ ...withValues ? { source: entry.source, value: entry.value, ...entry.was === void 0 ? {} : { was: entry.was } } : {}
1290
+ };
1291
+ }
1292
+ humanRows(entries, withValues) {
1293
+ const width = Math.max(0, ...entries.map((entry) => idOf(entry).length));
1294
+ return entries.map((entry) => {
1295
+ const head = `${idOf(entry).padEnd(width)} ${entry.state}${entry.empty ? " (empty on purpose)" : ""}`;
1296
+ if (!withValues) return `${head}
1297
+ `;
1298
+ return [
1299
+ head,
1300
+ ` source: ${entry.source}`,
1301
+ ...entry.was === void 0 ? [] : [` was: ${entry.was}`],
1302
+ ` value: ${entry.value ?? "\u2014"}`,
1303
+ ""
1304
+ ].join("\n");
1305
+ }).join("");
1306
+ }
1307
+ pagedFooter(page) {
1308
+ if (this.json) return;
1309
+ const first = page.rows.length === 0 ? 0 : page.cursor + 1;
1310
+ this.io.stderr(
1311
+ `${first}\u2013${page.cursor + page.rows.length} of ${page.total}${page.nextCursor === null ? "" : `. Next: --cursor ${page.nextCursor}`}
1312
+ `
1313
+ );
1314
+ }
1315
+ async login() {
1316
+ const api = await this.api();
1317
+ const credentials = await this.credentials();
1318
+ const code = await askDeviceCode(this.io, api);
1319
+ const address = code.verification_uri_complete ?? `${code.verification_uri}?user_code=${code.user_code}`;
1320
+ this.io.stderr(`Open ${code.verification_uri} and enter the code ${code.user_code}
1321
+ `);
1322
+ if (!this.options["no-open"] && await this.io.openUrl(address)) {
1323
+ this.io.stderr("The browser is open. Approve the code there.\n");
1324
+ }
1325
+ this.io.stderr("Waiting for the approval\u2026\n");
1326
+ const token = await waitForApproval(this.io, api, code);
1327
+ const person = await personOf(this.io, api, token);
1328
+ await writeCredentials(this.path, {
1329
+ default: api,
1330
+ servers: { ...credentials.servers, [api]: { token, ...person ?? {} } }
1331
+ });
1332
+ if (this.io.env.GIT8N_API_TOKEN) {
1333
+ this.io.stderr("GIT8N_API_TOKEN is set: the commands use it, not this sign-in.\n");
1334
+ }
1335
+ return this.answer(
1336
+ { api, account: person, credentials: this.path },
1337
+ () => `Signed in to ${api}${person ? ` as ${person.name} <${person.email}>` : ""}.
1338
+ `
1339
+ );
1340
+ }
1341
+ async logout() {
1342
+ const api = await this.api();
1343
+ const credentials = await this.credentials();
1344
+ const held = credentials.servers[api];
1345
+ if (!held) {
1346
+ return this.answer({ api, signedOut: false, revoked: false }, () => `No account is signed in to ${api}.
1347
+ `);
1348
+ }
1349
+ const revoked = held.token.startsWith(ACCESS_TOKEN_PREFIX) ? false : await signOut(this.io, api, held.token);
1350
+ const { [api]: _gone, ...servers } = credentials.servers;
1351
+ await writeCredentials(this.path, {
1352
+ ...credentials.default === api ? {} : { default: credentials.default },
1353
+ servers
1354
+ });
1355
+ return this.answer(
1356
+ { api, signedOut: true, revoked },
1357
+ () => `Signed out of ${api}.${revoked ? "" : " The API did not end the session: it ends after 14 days without use."}
1358
+ `
1359
+ );
1360
+ }
1361
+ async whoami() {
1362
+ const config = await this.config();
1363
+ const me = await rightsOf(config);
1364
+ const [all, person] = await Promise.all([
1365
+ me.admin || me.memberships.length > 0 ? repositories(config) : Promise.resolve([]),
1366
+ config.token.startsWith(ACCESS_TOKEN_PREFIX) ? Promise.resolve(null) : personOf(this.io, config.url, config.token)
1367
+ ]);
1368
+ const byId = new Map(all.map((repository) => [repository.id, repository]));
1369
+ const memberships = me.memberships.map((membership) => ({
1370
+ repository: byId.has(membership.repositoryId) ? nameOf(byId.get(membership.repositoryId)) : membership.repositoryId,
1371
+ repositoryId: membership.repositoryId,
1372
+ role: membership.role,
1373
+ languages: membership.languages,
1374
+ writesSource: membership.writesSource
1375
+ }));
1376
+ const via = this.io.env.GIT8N_API_TOKEN ? "GIT8N_API_TOKEN" : "login";
1377
+ return this.answer(
1378
+ { api: config.url, account: person, via, admin: me.admin, memberships },
1379
+ () => [
1380
+ `${config.url}${person ? ` \u2014 ${person.name} <${person.email}>` : ""} (token from ${via})`,
1381
+ ...me.admin ? ["Admin: sets the repositories up."] : [],
1382
+ ...memberships.length === 0 ? ["Member of no repository."] : [],
1383
+ ...memberships.map(
1384
+ (held) => `${held.repository} ${held.role} ${held.languages.join(", ") || "\u2014"}${held.writesSource ? " writes the source" : ""}`
1385
+ ),
1386
+ ""
1387
+ ].join("\n")
1388
+ );
1389
+ }
1390
+ async status() {
1391
+ const config = await this.config();
1392
+ const wanted = this.options.repo ?? this.io.env.GIT8N_REPO;
1393
+ const all = await repositories(config);
1394
+ const slugs = githubSlugsOf(await this.io.gitRemotes(this.io.cwd));
1395
+ const named = wanted !== void 0 || all.some((repository) => slugs.includes(nameOf(repository).toLowerCase()));
1396
+ if (named) {
1397
+ const repository = pickRepository(all, wanted, slugs);
1398
+ const languages = await progress(config, repository.id);
1399
+ return this.answer(
1400
+ {
1401
+ repository: {
1402
+ id: repository.id,
1403
+ name: nameOf(repository),
1404
+ branch: repository.branch,
1405
+ source: repository.source,
1406
+ targets: repository.targets
1407
+ },
1408
+ languages
1409
+ },
1410
+ () => [
1411
+ `${nameOf(repository)} (${repository.branch}) \u2014 source ${repository.source}`,
1412
+ ...languages.map(
1413
+ (row) => ` ${row.language.padEnd(6)} ${String(row.published).padStart(5)}/${row.total} published ${row.proposed} proposed ${row.missing} missing ${row.review} to review`
1414
+ ),
1415
+ ""
1416
+ ].join("\n")
1417
+ );
1418
+ }
1419
+ const rows = [];
1420
+ for (let page = 1; ; page += 1) {
1421
+ const answered = await overview(config, { page, perPage: 50, sort: "name" });
1422
+ rows.push(...answered.rows);
1423
+ if (!answered.hasMore) break;
1424
+ }
1425
+ const summary = rows.map((row) => ({
1426
+ id: row.id,
1427
+ name: `${row.owner}/${row.name}`,
1428
+ source: row.source,
1429
+ targets: row.targets,
1430
+ languages: row.progress,
1431
+ ...row.progressError ? { error: row.progressError } : {}
1432
+ }));
1433
+ return this.answer(
1434
+ { repositories: summary },
1435
+ () => summary.length === 0 ? "This account opens no repository.\n" : summary.map(
1436
+ (row) => [
1437
+ `${row.name} \u2014 source ${row.source}`,
1438
+ ...(row.languages ?? []).map(
1439
+ (language2) => ` ${language2.language.padEnd(6)} ${String(language2.published).padStart(5)}/${language2.total} published ${language2.proposed} proposed ${language2.missing} missing`
1440
+ ),
1441
+ ...row.error ? [` ${row.error}`] : [],
1442
+ ""
1443
+ ].join("\n")
1444
+ ).join("")
1445
+ );
1446
+ }
1447
+ async keys() {
1448
+ const state = stateOf(this.options, "all");
1449
+ const limit = integerOf("limit", this.options.limit, 100, 1, 2e3);
1450
+ const cursor = integerOf("cursor", this.options.cursor, 0, 0, Number.MAX_SAFE_INTEGER);
1451
+ const { repository, read } = await this.read();
1452
+ const found = read.entries.filter(
1453
+ (entry) => (state === "all" || entry.state === state) && (this.options.file === void 0 || entry.file === this.options.file)
1454
+ );
1455
+ const page = pageOf(found, cursor, limit);
1456
+ const withValues = this.options.values === true;
1457
+ this.pagedFooter(page);
1458
+ return this.answer(
1459
+ {
1460
+ repository: nameOf(repository),
1461
+ language: read.language,
1462
+ source: read.source,
1463
+ commit: read.commit,
1464
+ filesWithNoVersion: disabledFilesOf(read),
1465
+ total: page.total,
1466
+ cursor: page.cursor,
1467
+ nextCursor: page.nextCursor,
1468
+ entries: page.rows.map((entry) => this.rowOf(entry, withValues))
1469
+ },
1470
+ () => this.humanRows(page.rows, withValues)
1471
+ );
1472
+ }
1473
+ async get(id) {
1474
+ const wanted = parseId(id);
1475
+ const { repository, read } = await this.read();
1476
+ if (disabledFilesOf(read).includes(wanted.file)) {
1477
+ throw new CliError("G8N3004", `${wanted.file} has no ${read.language} version.`);
1478
+ }
1479
+ const entry = read.entries.find((held) => held.file === wanted.file && held.key === wanted.key);
1480
+ if (!entry) throw new CliError("G8N3004", `${id} is not a key of the source (${read.source}).`);
1481
+ const neighbours = neighboursOf(read, entry);
1482
+ return this.answer(
1483
+ {
1484
+ repository: nameOf(repository),
1485
+ language: read.language,
1486
+ source: read.source,
1487
+ commit: read.commit,
1488
+ entry: this.rowOf(entry, true),
1489
+ neighbours: neighbours.map((other) => this.rowOf(other, true))
1490
+ },
1491
+ () => [
1492
+ this.humanRows([entry], true),
1493
+ ...neighbours.length === 0 ? [] : ["Neighbours:\n", this.humanRows(neighbours, true)]
1494
+ ].join("")
1495
+ );
1496
+ }
1497
+ async search(text) {
1498
+ const limit = integerOf("limit", this.options.limit, 100, 1, 2e3);
1499
+ const cursor = integerOf("cursor", this.options.cursor, 0, 0, Number.MAX_SAFE_INTEGER);
1500
+ const { repository, read } = await this.read();
1501
+ const found = read.entries.filter(
1502
+ (entry) => matchesText(entry, text) && (this.options.file === void 0 || entry.file === this.options.file)
1503
+ );
1504
+ const page = pageOf(found, cursor, limit);
1505
+ this.pagedFooter(page);
1506
+ return this.answer(
1507
+ {
1508
+ repository: nameOf(repository),
1509
+ language: read.language,
1510
+ commit: read.commit,
1511
+ total: page.total,
1512
+ cursor: page.cursor,
1513
+ nextCursor: page.nextCursor,
1514
+ entries: page.rows.map((entry) => this.rowOf(entry, true))
1515
+ },
1516
+ () => this.humanRows(page.rows, true)
1517
+ );
1518
+ }
1519
+ async apply(input) {
1520
+ const readAt = this.options["read-at"];
1521
+ if (readAt !== void 0 && !/^[0-9a-f]{40}$/.test(readAt)) {
1522
+ throw new CliError("G8N1001", "--read-at takes the full sha of a commit, as keys --json answers it.");
1523
+ }
1524
+ const texts = textsOf(input === "-" ? await this.io.readStdin() : await this.io.readFile(input), this.options.file);
1525
+ if (texts.size === 0) throw new CliError("G8N1003", "The input holds no key.");
1526
+ const { config, repository, read } = await this.read();
1527
+ const plan = planOf(read, texts, readAt ?? read.commit);
1528
+ const base = { repository: nameOf(repository), language: read.language, ...plan };
1529
+ if (this.options["dry-run"] || plan.changes.length === 0) {
1530
+ return this.answer(
1531
+ { ...base, proposed: null },
1532
+ () => [
1533
+ ...plan.changes.map((change) => `${change.id}
1534
+ ${change.from ?? "\u2014"}
1535
+ \u2192 ${change.to}`),
1536
+ `${plan.changes.length} to propose, ${plan.unchanged.length} unchanged, read at ${plan.readAt.slice(0, 7)}.`,
1537
+ ...this.stillToReview(plan.stillToReview),
1538
+ plan.changes.length === 0 ? "Nothing to propose." : "Dry run: nothing was sent.",
1539
+ ""
1540
+ ].join("\n")
1541
+ );
1542
+ }
1543
+ const proposed = await propose(config, {
1544
+ repository: repository.id,
1545
+ language: read.language,
1546
+ readAt: plan.readAt,
1547
+ values: valuesOf(plan)
1548
+ });
1549
+ return this.answer(
1550
+ { ...base, proposed },
1551
+ () => [
1552
+ `Proposed ${proposed.keys} key(s) in ${read.language} as ${proposed.commit.slice(0, 7)} on ${proposed.branch}. A reviewer publishes them.`,
1553
+ ...this.stillToReview(plan.stillToReview),
1554
+ ""
1555
+ ].join("\n")
1556
+ );
1557
+ }
1558
+ stillToReview(ids) {
1559
+ if (ids.length === 0) return [];
1560
+ return [
1561
+ `${ids.length} key(s) to review keep the same text: a proposal does not change their state. A reviewer keeps them in the editor.`,
1562
+ ...ids.slice(0, 10).map((id) => ` ${id}`)
1563
+ ];
1564
+ }
1565
+ async proposals() {
1566
+ const config = await this.config();
1567
+ const [repository, me] = await Promise.all([this.repository(config), rightsOf(config)]);
1568
+ const language2 = pickLanguage(me, repository, this.options.lang);
1569
+ const batches = await waiting(config, repository.id, language2);
1570
+ return this.answer(
1571
+ { repository: nameOf(repository), language: language2, batches },
1572
+ () => batches.length === 0 ? `Nothing waits on ${language2}.
1573
+ ` : batches.map(
1574
+ (batch) => `${batch.commit.slice(0, 7)} ${batch.at.slice(0, 10)} ${batch.author} ${batch.message.split("\n")[0]}
1575
+ `
1576
+ ).join("")
1577
+ );
1578
+ }
1579
+ async promptTranslate() {
1580
+ const state = stateOf(this.options, "missing");
1581
+ if (state === "translated" || state === "all") {
1582
+ throw new CliError("G8N1001", "prompt translate takes --state missing or --state review.");
1583
+ }
1584
+ if (this.options.to && this.options.lang && this.options.to !== this.options.lang) {
1585
+ throw new CliError("G8N1001", "--to and --lang name two languages. Pass one.");
1586
+ }
1587
+ const limit = integerOf("limit", this.options.limit, 50, 1, 500);
1588
+ const cursor = integerOf("cursor", this.options.cursor, 0, 0, Number.MAX_SAFE_INTEGER);
1589
+ const { repository, read } = await this.read(this.options.to);
1590
+ const found = read.entries.filter(
1591
+ (entry) => entry.state === state && (this.options.file === void 0 || entry.file === this.options.file)
1592
+ );
1593
+ const page = pageOf(found, cursor, limit);
1594
+ const keys = promptKeysOf(read, page.rows);
1595
+ const prompt = translatePrompt({
1596
+ repository: nameOf(repository),
1597
+ read,
1598
+ keys,
1599
+ total: page.total,
1600
+ nextCursor: page.nextCursor
1601
+ });
1602
+ if (!this.json && page.total === 0) this.io.stderr(`No key is ${state} in ${read.language}.
1603
+ `);
1604
+ return this.answer(
1605
+ {
1606
+ repository: nameOf(repository),
1607
+ language: read.language,
1608
+ source: read.source,
1609
+ commit: read.commit,
1610
+ total: page.total,
1611
+ cursor: page.cursor,
1612
+ nextCursor: page.nextCursor,
1613
+ keys,
1614
+ prompt
1615
+ },
1616
+ () => prompt
1617
+ );
1618
+ }
1619
+ /** Reads the locale files of the working copy, and answers 1 when they fail the check. No API, no login. */
1620
+ async check() {
1621
+ const reporter = this.options.reporter ?? (this.json ? "json" : "pretty");
1622
+ if (!REPORTERS.includes(reporter))
1623
+ throw new CliError("G8N1001", `--reporter takes one of ${REPORTERS.join(", ")}.`);
1624
+ const maxWarnings = this.options["max-warnings"] === void 0 ? null : integerOf("max-warnings", this.options["max-warnings"], 0, 0, Number.MAX_SAFE_INTEGER);
1625
+ const { cwd } = this.io;
1626
+ const inCwd = (path) => isAbsolute(path) ? path : join3(cwd, path);
1627
+ const languages = this.options.lang?.split(",").map((language2) => language2.trim()).filter(Boolean);
1628
+ let report;
1629
+ try {
1630
+ report = await checkLocales(
1631
+ {
1632
+ locales: this.options.locales ?? null,
1633
+ manifest: this.options.manifest ?? null,
1634
+ source: this.options.source ?? null,
1635
+ languages: languages && languages.length > 0 ? languages : null
1636
+ },
1637
+ {
1638
+ readDir: (path) => this.io.readDir(inCwd(path)),
1639
+ readFile: (path) => this.io.readFile(inCwd(path)).catch(() => null)
1640
+ }
1641
+ );
1642
+ } catch (error) {
1643
+ if (error instanceof CheckSetupError) throw new CliError("G8N1005", error.message);
1644
+ throw error;
1645
+ }
1646
+ const output = {
1647
+ pretty: () => prettyReport(report),
1648
+ github: () => githubReport(report),
1649
+ json: () => `${JSON.stringify({ data: { ...report, maxWarnings, failed: failed(report, maxWarnings) } }, null, 2)}
1650
+ `
1651
+ }[reporter];
1652
+ this.io.stdout(output());
1653
+ return failed(report, maxWarnings) ? EXIT.failed : EXIT.ok;
1654
+ }
1655
+ async mcp() {
1656
+ if (this.json) throw new CliError("G8N1001", "mcp speaks the MCP protocol, and takes no --json.");
1657
+ await this.io.serveMcp(await this.config());
1658
+ return EXIT.ok;
1659
+ }
1660
+ };
1661
+
1662
+ // ../mcp/src/cli/node.ts
1663
+ var readStdin = async () => {
1664
+ const chunks = [];
1665
+ for await (const chunk of process.stdin) chunks.push(chunk);
1666
+ return Buffer.concat(chunks).toString("utf8");
1667
+ };
1668
+ var openUrl = (url) => new Promise((resolve) => {
1669
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1670
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
1671
+ child.on("error", () => resolve(false));
1672
+ child.on("spawn", () => {
1673
+ child.unref();
1674
+ resolve(true);
1675
+ });
1676
+ });
1677
+ var gitRemotes = async (cwd) => {
1678
+ try {
1679
+ const { stdout } = await promisify(execFile)("git", ["remote", "-v"], { cwd });
1680
+ return stdout.split("\n").flatMap((line) => line.split(/\s+/)[1] ?? []);
1681
+ } catch {
1682
+ return [];
1683
+ }
1684
+ };
1685
+ var main = async (version) => {
1686
+ process.exitCode = await run(process.argv.slice(2), {
1687
+ env: process.env,
1688
+ home: homedir(),
1689
+ cwd: process.cwd(),
1690
+ stdout: (text) => process.stdout.write(text),
1691
+ stderr: (text) => process.stderr.write(text),
1692
+ readStdin,
1693
+ readFile: (path) => readFile2(path, "utf8"),
1694
+ readDir: async (path) => {
1695
+ try {
1696
+ return (await readdir(path, { withFileTypes: true })).map((entry) => ({
1697
+ name: entry.name,
1698
+ directory: entry.isDirectory()
1699
+ }));
1700
+ } catch {
1701
+ return null;
1702
+ }
1703
+ },
1704
+ fetch: (input, init) => fetch(input, init),
1705
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1706
+ now: () => Date.now(),
1707
+ openUrl,
1708
+ gitRemotes,
1709
+ serveMcp: serveOverStdio,
1710
+ version
1711
+ });
1712
+ };
1713
+
1714
+ // package.json
1715
+ var package_default = {
1716
+ name: "@git8n/cli",
1717
+ version: "0.1.0",
1718
+ description: "The CLI and the MCP server of git8n: read, check and propose the translations of a GitHub repository.",
1719
+ keywords: [
1720
+ "cli",
1721
+ "github",
1722
+ "i18n",
1723
+ "i18next",
1724
+ "localization",
1725
+ "mcp",
1726
+ "translation"
1727
+ ],
1728
+ homepage: "https://git8n.sakuga.dev/docs/",
1729
+ bugs: "https://github.com/sakuga-software/git8n/issues",
1730
+ license: "AGPL-3.0-only",
1731
+ repository: {
1732
+ type: "git",
1733
+ url: "git+https://github.com/sakuga-software/git8n.git",
1734
+ directory: "packages/cli"
1735
+ },
1736
+ bin: {
1737
+ git8n: "./dist/git8n.js"
1738
+ },
1739
+ files: [
1740
+ "dist"
1741
+ ],
1742
+ type: "module",
1743
+ publishConfig: {
1744
+ access: "public"
1745
+ },
1746
+ scripts: {
1747
+ build: "esbuild src/git8n.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/git8n.js --external:@modelcontextprotocol/sdk --external:zod",
1748
+ typecheck: "tsc --noEmit",
1749
+ test: "pnpm run build && vitest run",
1750
+ prepack: "pnpm run build"
1751
+ },
1752
+ dependencies: {
1753
+ "@modelcontextprotocol/sdk": "^1.30.1",
1754
+ zod: "catalog:"
1755
+ },
1756
+ devDependencies: {
1757
+ "@git8n/mcp": "workspace:*",
1758
+ "@types/node": "catalog:",
1759
+ esbuild: "^0.28.2",
1760
+ typescript: "catalog:",
1761
+ vitest: "catalog:"
1762
+ },
1763
+ engines: {
1764
+ node: ">=22"
1765
+ }
1766
+ };
1767
+
1768
+ // src/git8n.ts
1769
+ await main(package_default.version);