@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 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.2.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 ? JSON.parse(String(flags.json)) : {}), ...coerceFlags(flags, op.input_schema) };
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
  }
@@ -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.2.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" },