@meterapp/glossa 0.2.0 → 0.4.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,18 +7,26 @@ 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
13
- npx @meterapp/glossa pricing # plans and prices, no key needed
14
- npx @meterapp/glossa billing # this workspace's plan and usage
15
- npx @meterapp/glossa subscribe pro --interval year --open # Stripe Checkout link for a human
15
+ npx @meterapp/glossa pricing [--json] # plans, prices and limits, no key needed
16
+ npx @meterapp/glossa account # plan, usage against every limit, next plan
17
+ npx @meterapp/glossa upgrade --plan pro --yearly --open # Stripe Checkout link for a human, even before login
18
+ npx @meterapp/glossa billing portal # invoices, card, plan change, cancel
16
19
  ```
17
20
 
18
- `subscribe` prints a Stripe Checkout link and charges nothing; a human pays in
21
+ `upgrade` (alias `subscribe`) prints a Stripe Checkout link and charges nothing; a human pays in
19
22
  the browser. With `--confirm` it subscribes on the card already saved for the
20
23
  workspace instead, so pass it only after a human approved the plan and price.
21
24
 
25
+ For a project in `translation_mode` `agent`, pushes do not queue AI
26
+ translation: a coding agent reads `missing`, looks up each key in the code and
27
+ writes the translations with `fill`. Any op takes `--json @file.json` or
28
+ `--json -` (stdin) for large input.
29
+
22
30
  `.glossa.json` at your repo root:
23
31
 
24
32
  ```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.4.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";
@@ -53,12 +53,13 @@ function fail(message, code = 1) {
53
53
  }
54
54
 
55
55
  // ------------------------------------------------------------------- http
56
- async function call(op, input = {}, { method = "POST" } = {}) {
56
+ /** `anonymous: true` calls a public op without a key when none is saved (pricing, checkout before signup). */
57
+ async function call(op, input = {}, { method = "POST", anonymous = false } = {}) {
57
58
  const key = apiKey();
58
- if (!key) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
59
+ if (!key && !anonymous) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
59
60
  const response = await fetch(`${host()}/api/v1/${op}`, {
60
61
  method,
61
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
62
+ headers: { ...(key ? { Authorization: `Bearer ${key}` } : {}), "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
62
63
  body: JSON.stringify(input),
63
64
  });
64
65
  const text = await response.text();
@@ -120,6 +121,14 @@ export function coerceFlags(flags, schema) {
120
121
  return out;
121
122
  }
122
123
 
124
+ /** `--json` takes inline JSON, `@path` to read a file, or `-` to read stdin. */
125
+ export function readJsonArg(value, { readFile = (path) => readFileSync(path, "utf8") } = {}) {
126
+ const text = String(value);
127
+ if (text === "-") return JSON.parse(readFileSync(0, "utf8"));
128
+ if (text.startsWith("@")) return JSON.parse(readFile(text.slice(1)));
129
+ return JSON.parse(text);
130
+ }
131
+
123
132
  function print(value, flags) {
124
133
  if (flags.pretty === false || (!isTTY && !flags.pretty)) process.stdout.write(JSON.stringify(value) + "\n");
125
134
  else process.stdout.write(JSON.stringify(value, null, 2) + "\n");
@@ -131,7 +140,7 @@ const money = (cents) => (cents === null || cents === undefined ? "custom" : `$$
131
140
  /** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
132
141
  export function subscribeInput(positional, flags) {
133
142
  const plan = String(positional[0] ?? flags.plan ?? "").toLowerCase();
134
- if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa subscribe pro` or `glossa subscribe team` (see `glossa pricing`).');
143
+ if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa upgrade --plan pro` or `--plan team` (see `glossa pricing`).');
135
144
  const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
136
145
  return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
137
146
  }
@@ -141,28 +150,56 @@ function openUrl(url) {
141
150
  spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
142
151
  }
143
152
 
153
+ /** One line per plan from the shared pricing document (GET /api/v1/pricing). */
154
+ export function pricingLines(doc) {
155
+ return doc.plans.map((plan) => {
156
+ const price = plan.price.monthly === null ? "custom" : plan.price.monthly === 0 ? "free" : `$${plan.price.monthly.toLocaleString("en-US")}/mo · $${plan.price.yearly.toLocaleString("en-US")}/yr`;
157
+ const included = plan.included.ai_translations_per_month === null ? "volume" : plan.included.ai_translations_per_month.toLocaleString("en-US");
158
+ const members = plan.limits.members === null ? "unlimited" : plan.limits.members;
159
+ const projects = plan.limits.projects === null ? "unlimited" : plan.limits.projects;
160
+ return { name: plan.name, text: `${price.padEnd(22)} ${included} AI translations/mo, ${members} members, ${projects} projects` };
161
+ });
162
+ }
163
+
144
164
  async function pricing(flags) {
145
- const response = await fetch(`${host()}/api/v1/get_pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
165
+ const response = await fetch(`${host()}/api/v1/pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
146
166
  if (!response.ok) fail(`could not read pricing (HTTP ${response.status}).`);
147
- const catalog = await response.json();
148
- if (flags.json || !isTTY) return print(catalog, flags);
149
- for (const plan of catalog.plans) {
150
- const price = plan.monthly_cents === null ? "custom" : plan.monthly_cents === 0 ? "free" : `${money(plan.monthly_cents)}/mo · ${money(plan.yearly_cents)}/yr`;
151
- const allowance = plan.ai_translations_per_month === null ? "volume" : plan.ai_translations_per_month.toLocaleString("en-US");
152
- console.log(`${bold(plan.name.padEnd(11))} ${price.padEnd(22)} ${allowance} AI translations/mo, ${plan.members} members`);
153
- }
154
- console.log(dim(`\n${catalog.unit}\nBuy: glossa subscribe pro [--interval year] [--open]`));
167
+ const doc = await response.json();
168
+ if (flags.json || !isTTY) return print(doc, flags);
169
+ for (const line of pricingLines(doc)) console.log(`${bold(line.name.padEnd(11))} ${line.text}`);
170
+ console.log(dim(`\n${doc.unit}\nBuy (no account needed first): glossa upgrade --plan pro [--yearly] [--open]`));
155
171
  }
156
172
 
157
- async function billing(flags) {
173
+ const usageLine = (label, usage) => ` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`;
174
+
175
+ async function billing(positional, flags) {
176
+ if (positional[0] === "portal") {
177
+ const result = await call("get_billing_link");
178
+ if (flags.json || !isTTY) print(result, flags);
179
+ else console.log(`${result.message}\n${result.url}`);
180
+ if (flags.open) openUrl(result.url);
181
+ return;
182
+ }
158
183
  const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
159
184
  if (flags.json || !isTTY) return print(state, flags);
160
185
  const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
161
186
  console.log(`${bold(state.plan_name)} ${dim(state.status === "none" ? "" : state.status)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
162
- for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) {
163
- console.log(` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`);
187
+ for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
188
+ console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · invoices and card: glossa billing portal`));
189
+ }
190
+
191
+ async function account(flags) {
192
+ const state = await call("get_account");
193
+ if (flags.json || !isTTY) return print(state, flags);
194
+ const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
195
+ console.log(`${bold(state.workspace.name)} ${dim(`(${state.workspace.slug}, ${state.role})`)}`);
196
+ console.log(`${bold(state.plan_name)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
197
+ for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
198
+ if (state.next_tier) {
199
+ const adds = state.next_tier.adds;
200
+ const more = adds.ai_translations_per_month ? `${adds.ai_translations_per_month.toLocaleString("en-US")} AI translations/mo` : adds.ai_translations_per_month === null ? "volume pricing" : "";
201
+ console.log(dim(`\nNext: ${state.next_tier.name}${state.next_tier.price.monthly ? ` ($${state.next_tier.price.monthly}/mo)` : ""}${more ? `, ${more}` : ""} → ${state.next_tier.checkout_url}`));
164
202
  }
165
- console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
166
203
  }
167
204
 
168
205
  /**
@@ -176,13 +213,17 @@ async function subscribe(positional, flags) {
176
213
  } catch (error) {
177
214
  fail(error.message);
178
215
  }
179
- const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input);
216
+ if (input.confirm && !apiKey()) fail("--confirm charges a saved card and needs `glossa login` first. Without it you get a checkout link.");
217
+ const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input, { anonymous: true });
180
218
  const url = result.url ?? result.checkout_url ?? null;
219
+ // Paid before signing in: remember the session so `glossa login` attaches it to the workspace.
220
+ if (!apiKey() && result.session_id) saveUserConfig({ ...userConfig(), pending_checkout: result.session_id });
181
221
  if (flags.json || !isTTY) {
182
222
  print(result, flags);
183
223
  } else if (url) {
184
224
  console.log(`${bold(`${input.plan} · ${money(result.amount_cents)}/${input.interval}`)}: ${result.reason ?? "complete the purchase in Stripe Checkout (nothing is charged until you do)."}`);
185
225
  console.log(url);
226
+ if (!apiKey()) console.log(dim("After paying, run `glossa login`: the plan attaches to your workspace."));
186
227
  } else {
187
228
  console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
188
229
  }
@@ -268,6 +309,26 @@ async function login(flags) {
268
309
  }
269
310
  const workspace = await call("get_workspace");
270
311
  console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
312
+ await claimPending();
313
+ }
314
+
315
+ /** Attaches a checkout paid before login (saved by `glossa upgrade`) to the workspace just signed in to. */
316
+ async function claimPending() {
317
+ const config = userConfig();
318
+ if (!config.pending_checkout) return;
319
+ try {
320
+ const result = await call("claim_checkout", { session_id: config.pending_checkout });
321
+ console.log(green(`✓ ${result.billing.plan_name} is now this workspace's plan.`));
322
+ delete config.pending_checkout;
323
+ saveUserConfig(config);
324
+ } catch (error) {
325
+ if (error.body?.code === "checkout_incomplete") return console.log(dim("A checkout link is still open; finish paying, then run `glossa login` again (or `glossa claim_checkout --session-id …`)."));
326
+ if (["already_claimed", "not_found"].includes(error.body?.code)) {
327
+ delete config.pending_checkout;
328
+ saveUserConfig(config);
329
+ }
330
+ console.log(dim(`Could not attach the earlier checkout: ${error.message}`));
331
+ }
271
332
  }
272
333
 
273
334
  function logout() {
@@ -325,7 +386,8 @@ async function push(positional, flags) {
325
386
  `${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
326
387
  `${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
327
388
  (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)})`) : "")
389
+ (result.translate_job_id ? dim(` · translating (job ${result.translate_job_id.slice(0, 8)})`) : "") +
390
+ (result.translation_mode === "agent" && result.units.created > 0 ? dim(" · waiting for your agent: glossa missing") : "")
329
391
  );
330
392
  }
331
393
  for (const warning of result.warnings ?? []) console.log(dim(` ! ${warning}`));
@@ -354,6 +416,70 @@ async function pull(positional, flags) {
354
416
  }
355
417
  }
356
418
 
419
+ // ------------------------------------------------- agent-written translations
420
+ /** Splits `fill` input (an array, or {translations: […]}) into submit_translations batches of 200. */
421
+ export function fillBatches(parsed, size = 200) {
422
+ const items = Array.isArray(parsed) ? parsed : parsed?.translations;
423
+ if (!Array.isArray(items) || !items.length) throw new Error('expected a JSON array of {"unit", "locale", "text"} (or {"translations": […]}).');
424
+ for (const [i, item] of items.entries()) {
425
+ if (!item?.unit || !item?.locale || typeof item?.text !== "string") throw new Error(`item ${i} needs unit, locale and text.`);
426
+ }
427
+ const batches = [];
428
+ for (let i = 0; i < items.length; i += size) batches.push(items.slice(i, i + size));
429
+ return batches;
430
+ }
431
+
432
+ /** The worklist an agent translates from: missing strings with keys, paths and glossary. */
433
+ async function missing(flags) {
434
+ const { config } = projectConfig();
435
+ const project = flags.project ?? config.project;
436
+ const input = {
437
+ ...(project ? { project } : {}),
438
+ ...(flags.locale ? { locales: String(flags.locale).split(",").map((s) => s.trim()).filter(Boolean) } : {}),
439
+ ...(flags.limit ? { limit: Number(flags.limit) } : {}),
440
+ ...(flags.offset ? { offset: Number(flags.offset) } : {}),
441
+ };
442
+ const result = await call("list_missing_translations", input);
443
+ if (flags.out) {
444
+ writeFileSync(resolve(String(flags.out)), JSON.stringify(result, null, 2) + "\n");
445
+ console.error(`${green("wrote")} ${flags.out} ${dim(`(${result.units.length} of ${result.total_units} strings, ${result.total_missing} translations missing)`)}`);
446
+ return;
447
+ }
448
+ if (flags.json || !isTTY) return print(result, flags);
449
+ console.log(bold(`${result.total_units} strings missing ${result.total_missing} translations${project ? ` in ${project}` : ""}`));
450
+ for (const unit of result.units) {
451
+ const where = unit.keys.map((k) => `${k.key}${k.path ? dim(` (${k.path})`) : ""}`).join(", ");
452
+ console.log(`${dim(unit.unit_id.slice(0, 8))} ${JSON.stringify(unit.source_text)} → ${unit.missing_locales.join(", ")}`);
453
+ if (where) console.log(dim(` ${where}`));
454
+ }
455
+ if (result.total_units > result.units.length) console.log(dim(`… ${result.total_units - result.units.length} more (--limit, --offset)`));
456
+ console.log(dim(`\nFull context: glossa missing --out missing.json · write back: glossa fill translations.json`));
457
+ }
458
+
459
+ async function fill(positional, flags) {
460
+ const source = positional[0] ?? (flags.file ? String(flags.file) : null);
461
+ if (!source) fail('pass a JSON file of [{"unit", "locale", "text"}] (or - for stdin): glossa fill translations.json');
462
+ let batches;
463
+ try {
464
+ batches = fillBatches(source === "-" ? readJsonArg("-") : readJsonArg(`@${source}`));
465
+ } catch (error) {
466
+ fail(error.message);
467
+ }
468
+ const options = { ...(flags.status ? { status: String(flags.status) } : {}), ...(flags.overwrite ? { overwrite: String(flags.overwrite) } : {}) };
469
+ const totals = { written: 0, skipped: [], rejected: [], tasks_created: 0 };
470
+ for (const batch of batches) {
471
+ const result = await call("submit_translations", { translations: batch, ...options });
472
+ totals.written += result.written;
473
+ totals.skipped.push(...result.skipped);
474
+ totals.rejected.push(...result.rejected);
475
+ totals.tasks_created += result.tasks_created ?? 0;
476
+ }
477
+ if (flags.json || !isTTY) return print(totals, flags);
478
+ console.log(green(`✓ wrote ${totals.written} translations`) + dim(` · ${totals.skipped.length} skipped · ${totals.rejected.length} rejected · ${totals.tasks_created} review tasks`));
479
+ for (const r of [...totals.rejected, ...totals.skipped]) console.log(dim(` ${r.unit.slice(0, 8)} ${r.locale}: ${r.reason}`));
480
+ if (totals.rejected.length) process.exitCode = 1;
481
+ }
482
+
357
483
  async function tasks(flags) {
358
484
  const { tasks: list, total } = await call("list_tasks", coerceFlags(flags, { properties: { limit: { type: "integer" }, kind: { type: "string" }, status: { type: "string" } } }));
359
485
  if (flags.json || !isTTY) return print({ total, tasks: list }, flags);
@@ -404,14 +530,22 @@ ${bold("Usage")}
404
530
  glossa logout forget the saved key
405
531
  glossa push [files…] [--project p] [--locale l]
406
532
  glossa pull [paths…] [--project p] [--locale es,fr] [--out dir] [--approved-only]
533
+ glossa missing [--project p] [--locale es,fr] [--out file]
534
+ strings still missing a translation, with
535
+ their keys and file paths, for an agent
536
+ glossa fill <file.json|-> write translations: [{"unit","locale","text"}]
537
+ [--status reviewed|needs_review] [--overwrite missing|machine|all]
407
538
  glossa tasks [--kind k] [--limit n] the review queue
408
539
  glossa mcp stdio MCP bridge to the hosted server
409
- glossa pricing plans and prices (no key needed)
540
+ glossa pricing [--json] plans, prices and limits (no key needed)
541
+ glossa account plan, usage against every limit, next plan
410
542
  glossa billing [--refresh] this workspace's plan and usage
411
- glossa subscribe pro|team [--interval year] [--open]
412
- Stripe Checkout link for a human; --confirm
413
- charges the saved card (only once approved)
414
- glossa <op> [--field value…] [--json '{…}'] run any op (glossa ops lists them)
543
+ glossa billing portal [--open] invoices, card, plan change, cancel (Stripe)
544
+ glossa upgrade --plan pro|team [--yearly] [--open]
545
+ Stripe Checkout link for a human, also before
546
+ signing in (\`glossa login\` attaches it after);
547
+ --confirm charges the saved card (once approved)
548
+ glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
415
549
  glossa ops list every op
416
550
 
417
551
  ${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.json"], "locales": ["es", "fr"]}
@@ -441,6 +575,10 @@ async function main() {
441
575
  return push(positional, flags);
442
576
  case "pull":
443
577
  return pull(positional, flags);
578
+ case "missing":
579
+ return missing(flags);
580
+ case "fill":
581
+ return fill(positional, flags);
444
582
  case "tasks":
445
583
  return tasks(flags);
446
584
  case "mcp":
@@ -448,7 +586,9 @@ async function main() {
448
586
  case "pricing":
449
587
  return pricing(flags);
450
588
  case "billing":
451
- return billing(flags);
589
+ return billing(positional, flags);
590
+ case "account":
591
+ return account(flags);
452
592
  case "subscribe":
453
593
  case "upgrade":
454
594
  return subscribe(positional, flags);
@@ -466,7 +606,7 @@ async function main() {
466
606
  console.log(`${bold(op.name)} — ${op.description}\n\n${JSON.stringify(op.input_schema, null, 2)}`);
467
607
  return;
468
608
  }
469
- const input = { ...(flags.json ? JSON.parse(String(flags.json)) : {}), ...coerceFlags(flags, op.input_schema) };
609
+ const input = { ...(flags.json ? readJsonArg(flags.json) : {}), ...coerceFlags(flags, op.input_schema) };
470
610
  return print(await call(op.name, input), flags);
471
611
  }
472
612
  }
@@ -1,5 +1,6 @@
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, pricingLines, subscribeInput } from "./glossa.mjs";
3
+ import { pricingDocument } from "../../../lib/billing/pricing";
3
4
 
4
5
  describe("cli args", () => {
5
6
  it("parses flags and positionals", () => {
@@ -22,6 +23,26 @@ describe("subscribe", () => {
22
23
  });
23
24
  });
24
25
 
26
+ describe("--json", () => {
27
+ it("reads inline JSON or @file", () => {
28
+ expect(readJsonArg('{"a":1}')).toEqual({ a: 1 });
29
+ expect(readJsonArg("@t.json", { readFile: (path) => (path === "t.json" ? '{"b":2}' : "") })).toEqual({ b: 2 });
30
+ });
31
+ });
32
+
33
+ describe("fill", () => {
34
+ const item = { unit: "u1", locale: "es", text: "Hola" };
35
+ it("accepts an array or {translations} and batches by 200", () => {
36
+ expect(fillBatches([item])).toEqual([[item]]);
37
+ expect(fillBatches({ translations: [item, item] }, 1)).toEqual([[item], [item]]);
38
+ expect(fillBatches(Array(450).fill(item)).map((b) => b.length)).toEqual([200, 200, 50]);
39
+ });
40
+ it("rejects empty input and items without unit, locale or text", () => {
41
+ expect(() => fillBatches([])).toThrow(/expected a JSON array/);
42
+ expect(() => fillBatches([{ unit: "u1", locale: "es" }])).toThrow(/item 0/);
43
+ });
44
+ });
45
+
25
46
  describe("deviceLogin", () => {
26
47
  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
48
  const reply = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
@@ -80,3 +101,12 @@ describe("file bodies", () => {
80
101
  expect(fileText({ content: "x" })).toBe("x");
81
102
  });
82
103
  });
104
+
105
+ describe("pricing", () => {
106
+ it("prints one line per plan from the shared pricing document", () => {
107
+ const lines = pricingLines(pricingDocument("agent-cli"));
108
+ expect(lines.map((l) => l.name)).toEqual(["Free", "Pro", "Team", "Enterprise"]);
109
+ expect(lines[1].text).toMatch(/^\$49\/mo · \$490\/yr .*25,000 AI translations\/mo, 10 members, unlimited projects$/);
110
+ expect(lines[3].text).toMatch(/^custom/);
111
+ });
112
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meterapp/glossa",
3
- "version": "0.2.0",
3
+ "version": "0.4.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" },