@meterapp/glossa 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/bin/glossa.mjs +86 -4
- package/bin/glossa.test.mjs +21 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ npx @meterapp/glossa login
|
|
|
7
7
|
npx @meterapp/glossa push locales/en.json --project web
|
|
8
8
|
npx @meterapp/glossa pull --project web --locale es,fr,de
|
|
9
9
|
npx @meterapp/glossa tasks
|
|
10
|
+
npx @meterapp/glossa missing --locale es --out missing.json # what an agent should translate, with keys and file paths
|
|
11
|
+
npx @meterapp/glossa fill translations.json # write [{"unit","locale","text"}] back
|
|
10
12
|
npx @meterapp/glossa run_audit --locale es --question "Are brand names consistent?"
|
|
11
13
|
npx @meterapp/glossa ops # every op, live from the API
|
|
12
14
|
npx @meterapp/glossa mcp # stdio MCP bridge for hosts without HTTP MCP
|
|
@@ -19,6 +21,11 @@ npx @meterapp/glossa subscribe pro --interval year --open # Stripe Checkout li
|
|
|
19
21
|
the browser. With `--confirm` it subscribes on the card already saved for the
|
|
20
22
|
workspace instead, so pass it only after a human approved the plan and price.
|
|
21
23
|
|
|
24
|
+
For a project in `translation_mode` `agent`, pushes do not queue AI
|
|
25
|
+
translation: a coding agent reads `missing`, looks up each key in the code and
|
|
26
|
+
writes the translations with `fill`. Any op takes `--json @file.json` or
|
|
27
|
+
`--json -` (stdin) for large input.
|
|
28
|
+
|
|
22
29
|
`.glossa.json` at your repo root:
|
|
23
30
|
|
|
24
31
|
```json
|
package/bin/glossa.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { brotliCompressSync, brotliDecompressSync, constants as zlib } from "node:zlib";
|
|
11
11
|
|
|
12
|
-
const VERSION = "0.
|
|
12
|
+
const VERSION = "0.3.0";
|
|
13
13
|
const DEFAULT_HOST = "https://getglossa.dev";
|
|
14
14
|
const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "glossa", "config.json");
|
|
15
15
|
const PROJECT_CONFIG = ".glossa.json";
|
|
@@ -120,6 +120,14 @@ export function coerceFlags(flags, schema) {
|
|
|
120
120
|
return out;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** `--json` takes inline JSON, `@path` to read a file, or `-` to read stdin. */
|
|
124
|
+
export function readJsonArg(value, { readFile = (path) => readFileSync(path, "utf8") } = {}) {
|
|
125
|
+
const text = String(value);
|
|
126
|
+
if (text === "-") return JSON.parse(readFileSync(0, "utf8"));
|
|
127
|
+
if (text.startsWith("@")) return JSON.parse(readFile(text.slice(1)));
|
|
128
|
+
return JSON.parse(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
123
131
|
function print(value, flags) {
|
|
124
132
|
if (flags.pretty === false || (!isTTY && !flags.pretty)) process.stdout.write(JSON.stringify(value) + "\n");
|
|
125
133
|
else process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
@@ -325,7 +333,8 @@ async function push(positional, flags) {
|
|
|
325
333
|
`${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
|
|
326
334
|
`${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
|
|
327
335
|
(result.keys_split ? ` · ${result.keys_split} keys split (their translations differed)` : "") +
|
|
328
|
-
(result.translate_job_id ? dim(` · translating (job ${result.translate_job_id.slice(0, 8)})`) : "")
|
|
336
|
+
(result.translate_job_id ? dim(` · translating (job ${result.translate_job_id.slice(0, 8)})`) : "") +
|
|
337
|
+
(result.translation_mode === "agent" && result.units.created > 0 ? dim(" · waiting for your agent: glossa missing") : "")
|
|
329
338
|
);
|
|
330
339
|
}
|
|
331
340
|
for (const warning of result.warnings ?? []) console.log(dim(` ! ${warning}`));
|
|
@@ -354,6 +363,70 @@ async function pull(positional, flags) {
|
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
365
|
|
|
366
|
+
// ------------------------------------------------- agent-written translations
|
|
367
|
+
/** Splits `fill` input (an array, or {translations: […]}) into submit_translations batches of 200. */
|
|
368
|
+
export function fillBatches(parsed, size = 200) {
|
|
369
|
+
const items = Array.isArray(parsed) ? parsed : parsed?.translations;
|
|
370
|
+
if (!Array.isArray(items) || !items.length) throw new Error('expected a JSON array of {"unit", "locale", "text"} (or {"translations": […]}).');
|
|
371
|
+
for (const [i, item] of items.entries()) {
|
|
372
|
+
if (!item?.unit || !item?.locale || typeof item?.text !== "string") throw new Error(`item ${i} needs unit, locale and text.`);
|
|
373
|
+
}
|
|
374
|
+
const batches = [];
|
|
375
|
+
for (let i = 0; i < items.length; i += size) batches.push(items.slice(i, i + size));
|
|
376
|
+
return batches;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The worklist an agent translates from: missing strings with keys, paths and glossary. */
|
|
380
|
+
async function missing(flags) {
|
|
381
|
+
const { config } = projectConfig();
|
|
382
|
+
const project = flags.project ?? config.project;
|
|
383
|
+
const input = {
|
|
384
|
+
...(project ? { project } : {}),
|
|
385
|
+
...(flags.locale ? { locales: String(flags.locale).split(",").map((s) => s.trim()).filter(Boolean) } : {}),
|
|
386
|
+
...(flags.limit ? { limit: Number(flags.limit) } : {}),
|
|
387
|
+
...(flags.offset ? { offset: Number(flags.offset) } : {}),
|
|
388
|
+
};
|
|
389
|
+
const result = await call("list_missing_translations", input);
|
|
390
|
+
if (flags.out) {
|
|
391
|
+
writeFileSync(resolve(String(flags.out)), JSON.stringify(result, null, 2) + "\n");
|
|
392
|
+
console.error(`${green("wrote")} ${flags.out} ${dim(`(${result.units.length} of ${result.total_units} strings, ${result.total_missing} translations missing)`)}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (flags.json || !isTTY) return print(result, flags);
|
|
396
|
+
console.log(bold(`${result.total_units} strings missing ${result.total_missing} translations${project ? ` in ${project}` : ""}`));
|
|
397
|
+
for (const unit of result.units) {
|
|
398
|
+
const where = unit.keys.map((k) => `${k.key}${k.path ? dim(` (${k.path})`) : ""}`).join(", ");
|
|
399
|
+
console.log(`${dim(unit.unit_id.slice(0, 8))} ${JSON.stringify(unit.source_text)} → ${unit.missing_locales.join(", ")}`);
|
|
400
|
+
if (where) console.log(dim(` ${where}`));
|
|
401
|
+
}
|
|
402
|
+
if (result.total_units > result.units.length) console.log(dim(`… ${result.total_units - result.units.length} more (--limit, --offset)`));
|
|
403
|
+
console.log(dim(`\nFull context: glossa missing --out missing.json · write back: glossa fill translations.json`));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function fill(positional, flags) {
|
|
407
|
+
const source = positional[0] ?? (flags.file ? String(flags.file) : null);
|
|
408
|
+
if (!source) fail('pass a JSON file of [{"unit", "locale", "text"}] (or - for stdin): glossa fill translations.json');
|
|
409
|
+
let batches;
|
|
410
|
+
try {
|
|
411
|
+
batches = fillBatches(source === "-" ? readJsonArg("-") : readJsonArg(`@${source}`));
|
|
412
|
+
} catch (error) {
|
|
413
|
+
fail(error.message);
|
|
414
|
+
}
|
|
415
|
+
const options = { ...(flags.status ? { status: String(flags.status) } : {}), ...(flags.overwrite ? { overwrite: String(flags.overwrite) } : {}) };
|
|
416
|
+
const totals = { written: 0, skipped: [], rejected: [], tasks_created: 0 };
|
|
417
|
+
for (const batch of batches) {
|
|
418
|
+
const result = await call("submit_translations", { translations: batch, ...options });
|
|
419
|
+
totals.written += result.written;
|
|
420
|
+
totals.skipped.push(...result.skipped);
|
|
421
|
+
totals.rejected.push(...result.rejected);
|
|
422
|
+
totals.tasks_created += result.tasks_created ?? 0;
|
|
423
|
+
}
|
|
424
|
+
if (flags.json || !isTTY) return print(totals, flags);
|
|
425
|
+
console.log(green(`✓ wrote ${totals.written} translations`) + dim(` · ${totals.skipped.length} skipped · ${totals.rejected.length} rejected · ${totals.tasks_created} review tasks`));
|
|
426
|
+
for (const r of [...totals.rejected, ...totals.skipped]) console.log(dim(` ${r.unit.slice(0, 8)} ${r.locale}: ${r.reason}`));
|
|
427
|
+
if (totals.rejected.length) process.exitCode = 1;
|
|
428
|
+
}
|
|
429
|
+
|
|
357
430
|
async function tasks(flags) {
|
|
358
431
|
const { tasks: list, total } = await call("list_tasks", coerceFlags(flags, { properties: { limit: { type: "integer" }, kind: { type: "string" }, status: { type: "string" } } }));
|
|
359
432
|
if (flags.json || !isTTY) return print({ total, tasks: list }, flags);
|
|
@@ -404,6 +477,11 @@ ${bold("Usage")}
|
|
|
404
477
|
glossa logout forget the saved key
|
|
405
478
|
glossa push [files…] [--project p] [--locale l]
|
|
406
479
|
glossa pull [paths…] [--project p] [--locale es,fr] [--out dir] [--approved-only]
|
|
480
|
+
glossa missing [--project p] [--locale es,fr] [--out file]
|
|
481
|
+
strings still missing a translation, with
|
|
482
|
+
their keys and file paths, for an agent
|
|
483
|
+
glossa fill <file.json|-> write translations: [{"unit","locale","text"}]
|
|
484
|
+
[--status reviewed|needs_review] [--overwrite missing|machine|all]
|
|
407
485
|
glossa tasks [--kind k] [--limit n] the review queue
|
|
408
486
|
glossa mcp stdio MCP bridge to the hosted server
|
|
409
487
|
glossa pricing plans and prices (no key needed)
|
|
@@ -411,7 +489,7 @@ ${bold("Usage")}
|
|
|
411
489
|
glossa subscribe pro|team [--interval year] [--open]
|
|
412
490
|
Stripe Checkout link for a human; --confirm
|
|
413
491
|
charges the saved card (only once approved)
|
|
414
|
-
glossa <op> [--field value…] [--json '{…}'] run any op (glossa ops lists them)
|
|
492
|
+
glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
|
|
415
493
|
glossa ops list every op
|
|
416
494
|
|
|
417
495
|
${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.json"], "locales": ["es", "fr"]}
|
|
@@ -441,6 +519,10 @@ async function main() {
|
|
|
441
519
|
return push(positional, flags);
|
|
442
520
|
case "pull":
|
|
443
521
|
return pull(positional, flags);
|
|
522
|
+
case "missing":
|
|
523
|
+
return missing(flags);
|
|
524
|
+
case "fill":
|
|
525
|
+
return fill(positional, flags);
|
|
444
526
|
case "tasks":
|
|
445
527
|
return tasks(flags);
|
|
446
528
|
case "mcp":
|
|
@@ -466,7 +548,7 @@ async function main() {
|
|
|
466
548
|
console.log(`${bold(op.name)} — ${op.description}\n\n${JSON.stringify(op.input_schema, null, 2)}`);
|
|
467
549
|
return;
|
|
468
550
|
}
|
|
469
|
-
const input = { ...(flags.json ?
|
|
551
|
+
const input = { ...(flags.json ? readJsonArg(flags.json) : {}), ...coerceFlags(flags, op.input_schema) };
|
|
470
552
|
return print(await call(op.name, input), flags);
|
|
471
553
|
}
|
|
472
554
|
}
|
package/bin/glossa.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, subscribeInput } from "./glossa.mjs";
|
|
2
|
+
import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, readJsonArg, fillBatches, subscribeInput } from "./glossa.mjs";
|
|
3
3
|
|
|
4
4
|
describe("cli args", () => {
|
|
5
5
|
it("parses flags and positionals", () => {
|
|
@@ -22,6 +22,26 @@ describe("subscribe", () => {
|
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
describe("--json", () => {
|
|
26
|
+
it("reads inline JSON or @file", () => {
|
|
27
|
+
expect(readJsonArg('{"a":1}')).toEqual({ a: 1 });
|
|
28
|
+
expect(readJsonArg("@t.json", { readFile: (path) => (path === "t.json" ? '{"b":2}' : "") })).toEqual({ b: 2 });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("fill", () => {
|
|
33
|
+
const item = { unit: "u1", locale: "es", text: "Hola" };
|
|
34
|
+
it("accepts an array or {translations} and batches by 200", () => {
|
|
35
|
+
expect(fillBatches([item])).toEqual([[item]]);
|
|
36
|
+
expect(fillBatches({ translations: [item, item] }, 1)).toEqual([[item], [item]]);
|
|
37
|
+
expect(fillBatches(Array(450).fill(item)).map((b) => b.length)).toEqual([200, 200, 50]);
|
|
38
|
+
});
|
|
39
|
+
it("rejects empty input and items without unit, locale or text", () => {
|
|
40
|
+
expect(() => fillBatches([])).toThrow(/expected a JSON array/);
|
|
41
|
+
expect(() => fillBatches([{ unit: "u1", locale: "es" }])).toThrow(/item 0/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
25
45
|
describe("deviceLogin", () => {
|
|
26
46
|
const start = { device_code: "dc_secret", user_code: "ABCD-EFGH", verification_uri: "https://x.test/activate", verification_uri_complete: "https://x.test/activate?code=ABCD-EFGH", expires_in: 900, interval: 3 };
|
|
27
47
|
const reply = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterapp/glossa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Glossa CLI: push and pull translation files, run the AI review queue, and every other Glossa op from your terminal or CI. Includes a stdio MCP bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "glossa": "bin/glossa.mjs" },
|