@meterapp/glossa 0.0.0-stage → 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
@@ -1,3 +1,40 @@
1
- # Temporary Holding Version
1
+ # @meterapp/glossa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Glossa CLI. Push and pull translation files in any format, work the AI review queue, and run every op the Glossa dashboard can do, from your terminal, CI or an agent.
4
+
5
+ ```bash
6
+ npx @meterapp/glossa login
7
+ npx @meterapp/glossa push locales/en.json --project web
8
+ npx @meterapp/glossa pull --project web --locale es,fr,de
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
12
+ npx @meterapp/glossa run_audit --locale es --question "Are brand names consistent?"
13
+ npx @meterapp/glossa ops # every op, live from the API
14
+ npx @meterapp/glossa mcp # stdio MCP bridge for hosts without HTTP MCP
15
+ npx @meterapp/glossa pricing # plans and prices, no key needed
16
+ npx @meterapp/glossa billing # this workspace's plan and usage
17
+ npx @meterapp/glossa subscribe pro --interval year --open # Stripe Checkout link for a human
18
+ ```
19
+
20
+ `subscribe` prints a Stripe Checkout link and charges nothing; a human pays in
21
+ the browser. With `--confirm` it subscribes on the card already saved for the
22
+ workspace instead, so pass it only after a human approved the plan and price.
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
+
29
+ `.glossa.json` at your repo root:
30
+
31
+ ```json
32
+ { "project": "web", "files": ["locales/en.json"], "locales": ["es", "fr", "de"] }
33
+ ```
34
+
35
+ `login` opens your browser: confirm the code your terminal shows at
36
+ getglossa.dev/activate and the CLI saves a new workspace API key. Over SSH,
37
+ open the printed URL on any device (`--no-browser` skips opening one). To save
38
+ an existing key instead, run `login --key gls_…`.
39
+
40
+ In CI set `GLOSSA_API_KEY` (a workspace key, `gls_…`).
package/bin/glossa.mjs ADDED
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env node
2
+ // Glossa CLI. Zero dependencies. Every op the API offers is a command
3
+ // (`glossa <op> --field value` or `--json '{…}'`), read live from GET /api/v1,
4
+ // so the CLI never lags the dashboard. push/pull/tasks/mcp are conveniences on top.
5
+ import { spawn } from "node:child_process";
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from "node:fs";
7
+ import { homedir, hostname } from "node:os";
8
+ import { dirname, join, relative, resolve } from "node:path";
9
+ import { createInterface } from "node:readline";
10
+ import { brotliCompressSync, brotliDecompressSync, constants as zlib } from "node:zlib";
11
+
12
+ const VERSION = "0.3.0";
13
+ const DEFAULT_HOST = "https://getglossa.dev";
14
+ const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "glossa", "config.json");
15
+ const PROJECT_CONFIG = ".glossa.json";
16
+
17
+ // ------------------------------------------------------------------ config
18
+ function readJson(path) {
19
+ try {
20
+ return JSON.parse(readFileSync(path, "utf8"));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ const userConfig = () => readJson(CONFIG_PATH) ?? {};
26
+ function saveUserConfig(next) {
27
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
28
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
29
+ }
30
+ const host = () => (process.env.GLOSSA_HOST || userConfig().host || DEFAULT_HOST).replace(/\/$/, "");
31
+ const apiKey = () => process.env.GLOSSA_API_KEY || userConfig().api_key || null;
32
+
33
+ function projectConfig() {
34
+ let dir = process.cwd();
35
+ for (;;) {
36
+ const path = join(dir, PROJECT_CONFIG);
37
+ if (existsSync(path)) return { root: dir, config: readJson(path) ?? {} };
38
+ const parent = dirname(dir);
39
+ if (parent === dir) return { root: process.cwd(), config: {} };
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------- output
45
+ const isTTY = process.stdout.isTTY;
46
+ const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
47
+ const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
48
+ const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
49
+ const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
50
+ function fail(message, code = 1) {
51
+ console.error(red(`error: ${message}`));
52
+ process.exit(code);
53
+ }
54
+
55
+ // ------------------------------------------------------------------- http
56
+ async function call(op, input = {}, { method = "POST" } = {}) {
57
+ const key = apiKey();
58
+ if (!key) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
59
+ const response = await fetch(`${host()}/api/v1/${op}`, {
60
+ method,
61
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
62
+ body: JSON.stringify(input),
63
+ });
64
+ const text = await response.text();
65
+ let body;
66
+ try {
67
+ body = JSON.parse(text);
68
+ } catch {
69
+ body = { detail: text };
70
+ }
71
+ if (!response.ok) {
72
+ const error = new Error(body.detail || `${op} failed with HTTP ${response.status}`);
73
+ error.status = response.status;
74
+ error.body = body;
75
+ throw error;
76
+ }
77
+ return body;
78
+ }
79
+
80
+ async function manifest() {
81
+ const response = await fetch(`${host()}/api/v1`, { headers: { "User-Agent": `glossa-cli/${VERSION}` } });
82
+ if (!response.ok) fail(`could not read the op catalog from ${host()}/api/v1 (HTTP ${response.status}).`);
83
+ return (await response.json()).ops;
84
+ }
85
+
86
+ // ------------------------------------------------------------------- args
87
+ export function parseArgs(argv) {
88
+ const positional = [];
89
+ const flags = {};
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const arg = argv[i];
92
+ if (arg === "--") {
93
+ positional.push(...argv.slice(i + 1));
94
+ break;
95
+ }
96
+ if (arg.startsWith("--")) {
97
+ const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
98
+ if (inline !== undefined) flags[name] = inline;
99
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[name] = argv[++i];
100
+ else flags[name] = true;
101
+ } else positional.push(arg);
102
+ }
103
+ return { positional, flags };
104
+ }
105
+
106
+ /** Turns --flag strings into the types the op's JSON schema asks for. */
107
+ export function coerceFlags(flags, schema) {
108
+ const props = schema?.properties ?? {};
109
+ const out = {};
110
+ for (const [rawName, value] of Object.entries(flags)) {
111
+ if (["json", "host", "pretty", "help"].includes(rawName)) continue;
112
+ const name = rawName.replace(/-/g, "_");
113
+ const type = props[name]?.type;
114
+ if (type === "number" || type === "integer") out[name] = Number(value);
115
+ else if (type === "boolean") out[name] = value === true || value === "true";
116
+ else if (type === "array") out[name] = String(value).split(",").map((s) => s.trim()).filter(Boolean);
117
+ else if (type === "object") out[name] = JSON.parse(String(value));
118
+ else out[name] = value === true ? "" : String(value);
119
+ }
120
+ return out;
121
+ }
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
+
131
+ function print(value, flags) {
132
+ if (flags.pretty === false || (!isTTY && !flags.pretty)) process.stdout.write(JSON.stringify(value) + "\n");
133
+ else process.stdout.write(JSON.stringify(value, null, 2) + "\n");
134
+ }
135
+
136
+ // ----------------------------------------------------------------- billing
137
+ const money = (cents) => (cents === null || cents === undefined ? "custom" : `$${(cents / 100).toLocaleString("en-US")}`);
138
+
139
+ /** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
140
+ export function subscribeInput(positional, flags) {
141
+ const plan = String(positional[0] ?? flags.plan ?? "").toLowerCase();
142
+ if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa subscribe pro` or `glossa subscribe team` (see `glossa pricing`).');
143
+ const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
144
+ return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
145
+ }
146
+
147
+ function openUrl(url) {
148
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
149
+ spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
150
+ }
151
+
152
+ async function pricing(flags) {
153
+ const response = await fetch(`${host()}/api/v1/get_pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
154
+ if (!response.ok) fail(`could not read pricing (HTTP ${response.status}).`);
155
+ const catalog = await response.json();
156
+ if (flags.json || !isTTY) return print(catalog, flags);
157
+ for (const plan of catalog.plans) {
158
+ const price = plan.monthly_cents === null ? "custom" : plan.monthly_cents === 0 ? "free" : `${money(plan.monthly_cents)}/mo · ${money(plan.yearly_cents)}/yr`;
159
+ const allowance = plan.ai_translations_per_month === null ? "volume" : plan.ai_translations_per_month.toLocaleString("en-US");
160
+ console.log(`${bold(plan.name.padEnd(11))} ${price.padEnd(22)} ${allowance} AI translations/mo, ${plan.members} members`);
161
+ }
162
+ console.log(dim(`\n${catalog.unit}\nBuy: glossa subscribe pro [--interval year] [--open]`));
163
+ }
164
+
165
+ async function billing(flags) {
166
+ const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
167
+ if (flags.json || !isTTY) return print(state, flags);
168
+ const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
169
+ console.log(`${bold(state.plan_name)} ${dim(state.status === "none" ? "" : state.status)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
170
+ for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) {
171
+ console.log(` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`);
172
+ }
173
+ console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
174
+ }
175
+
176
+ /**
177
+ * Without --confirm: a Stripe Checkout link for a human (nothing is charged).
178
+ * With --confirm: subscribe on the saved card, which a human must have approved.
179
+ */
180
+ async function subscribe(positional, flags) {
181
+ let input;
182
+ try {
183
+ input = subscribeInput(positional, flags);
184
+ } catch (error) {
185
+ fail(error.message);
186
+ }
187
+ const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input);
188
+ const url = result.url ?? result.checkout_url ?? null;
189
+ if (flags.json || !isTTY) {
190
+ print(result, flags);
191
+ } else if (url) {
192
+ 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)."}`);
193
+ console.log(url);
194
+ } else {
195
+ console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
196
+ }
197
+ if (url && flags.open) openUrl(url);
198
+ }
199
+
200
+ // ------------------------------------------------------------------ login
201
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
202
+
203
+ /**
204
+ * Browser sign-in (RFC 8628 device flow): print a code and a URL, open the
205
+ * browser, poll until the person approves at /activate. Works over SSH and for
206
+ * agents, who relay the URL and code to their human. Resolves to
207
+ * {api_key, email, workspace}.
208
+ */
209
+ export async function deviceLogin({ base, browser = true, fetchImpl = fetch, wait = sleep, log = (line) => console.error(line), open = openUrl, now = Date.now } = {}) {
210
+ const post = async (path, body) => {
211
+ const response = await fetchImpl(`${base}${path}`, {
212
+ method: "POST",
213
+ headers: { "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
214
+ body: JSON.stringify(body),
215
+ });
216
+ const text = await response.text();
217
+ let parsed;
218
+ try {
219
+ parsed = JSON.parse(text);
220
+ } catch {
221
+ parsed = { detail: text };
222
+ }
223
+ return { status: response.status, ok: response.ok, body: parsed };
224
+ };
225
+ const start = await post("/api/v1/auth/device", { client_name: `glossa CLI on ${hostname()}`.slice(0, 80) });
226
+ if (!start.ok) {
227
+ const error = new Error(start.body.detail || `could not start sign-in (HTTP ${start.status})`);
228
+ error.body = start.body;
229
+ throw error;
230
+ }
231
+ const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } = start.body;
232
+ log(`Open ${bold(verification_uri)} and enter the code ${bold(user_code)}`);
233
+ log(dim(`(or go straight to ${verification_uri_complete})`));
234
+ if (browser) open(verification_uri_complete);
235
+ log("Waiting for approval in the browser…");
236
+ const deadline = now() + expires_in * 1000;
237
+ let delay = Math.max(1, interval ?? 3) * 1000;
238
+ while (now() < deadline) {
239
+ await wait(delay);
240
+ let poll;
241
+ try {
242
+ poll = await post("/api/v1/auth/device/token", { device_code });
243
+ } catch {
244
+ delay += 2000; // network blip: back off and keep waiting
245
+ continue;
246
+ }
247
+ if (poll.ok) return poll.body;
248
+ if (poll.body.code === "authorization_pending") continue;
249
+ if (poll.status === 429 || poll.status >= 500) {
250
+ delay += 2000;
251
+ continue;
252
+ }
253
+ const error = new Error(poll.body.detail || `sign-in failed (HTTP ${poll.status})`);
254
+ error.body = poll.body;
255
+ throw error;
256
+ }
257
+ throw new Error("the code expired before it was approved. Run `glossa login` again.");
258
+ }
259
+
260
+ // ---------------------------------------------------------------- commands
261
+ async function login(flags) {
262
+ if (flags.host) saveUserConfig({ ...userConfig(), host: String(flags.host) });
263
+ // --key gls_… (CI, scripts) or --key alone to paste one; otherwise sign in with the browser.
264
+ if (flags.key !== undefined) {
265
+ let value = typeof flags.key === "string" ? flags.key : null;
266
+ if (!value) {
267
+ console.log(`Create an API key at ${bold(`${host()}/dashboard/settings`)} (Settings → API keys), then paste it here.`);
268
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
269
+ value = await new Promise((done) => rl.question("API key: ", (answer) => (rl.close(), done(answer.trim()))));
270
+ }
271
+ if (!value?.startsWith("gls_")) fail("that does not look like a Glossa API key (gls_…).");
272
+ saveUserConfig({ ...userConfig(), api_key: value });
273
+ } else {
274
+ const result = await deviceLogin({ base: host(), browser: !flags["no-browser"] });
275
+ saveUserConfig({ ...userConfig(), api_key: result.api_key, ...(result.email ? { email: result.email } : {}) });
276
+ }
277
+ const workspace = await call("get_workspace");
278
+ console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
279
+ }
280
+
281
+ function logout() {
282
+ const config = userConfig();
283
+ if (!config.api_key) return console.log("Not signed in.");
284
+ delete config.api_key;
285
+ delete config.email;
286
+ saveUserConfig(config);
287
+ console.log(`Signed out. The key still works until you revoke it (Settings → API keys).`);
288
+ }
289
+
290
+ function expandFiles(patterns, root) {
291
+ const files = [];
292
+ for (const pattern of patterns) {
293
+ const path = resolve(root, pattern);
294
+ if (existsSync(path) && statSync(path).isDirectory()) {
295
+ for (const entry of readdirSync(path, { recursive: true })) {
296
+ const full = join(path, String(entry));
297
+ if (statSync(full).isFile()) files.push(full);
298
+ }
299
+ } else if (existsSync(path)) files.push(path);
300
+ else fail(`no such file: ${pattern}`);
301
+ }
302
+ return files;
303
+ }
304
+
305
+ /** Files over 1 MB travel brotli-compressed: request and response bodies are capped at 4.5 MB. */
306
+ export function fileBody(text) {
307
+ if (Buffer.byteLength(text, "utf8") <= 1_000_000) return { content: text };
308
+ const bytes = Buffer.from(text, "utf8");
309
+ return { content_brotli: brotliCompressSync(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } }).toString("base64") };
310
+ }
311
+
312
+ export function fileText(out) {
313
+ return out.content_brotli !== undefined ? brotliDecompressSync(Buffer.from(out.content_brotli, "base64")).toString("utf8") : out.content;
314
+ }
315
+
316
+ async function push(positional, flags) {
317
+ const { root, config } = projectConfig();
318
+ const project = flags.project ?? config.project;
319
+ if (!project) fail("which project? Pass --project <slug> or add {\"project\": \"…\"} to .glossa.json.");
320
+ const sources = positional.length ? positional : (config.files ?? []).map((f) => (typeof f === "string" ? f : f.source));
321
+ if (!sources.length) fail("nothing to push. Pass files, or list them under \"files\" in .glossa.json.");
322
+ for (const file of expandFiles(sources, root)) {
323
+ const path = relative(root, file);
324
+ const result = await call("push_file", { project, path, ...fileBody(readFileSync(file, "utf8")), ...(flags.locale ? { locale: flags.locale } : {}), ...(flags.format ? { format: flags.format } : {}) });
325
+ if (flags.json || !isTTY) {
326
+ print(result, flags);
327
+ continue;
328
+ }
329
+ if (result.status === "needs_format") {
330
+ console.log(`${bold(path)} ${red("unknown format")} — AI is learning it (task ${result.learn_format_task_id ?? "queued"}).`);
331
+ } else {
332
+ console.log(
333
+ `${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
334
+ `${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
335
+ (result.keys_split ? ` · ${result.keys_split} keys split (their translations differed)` : "") +
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") : "")
338
+ );
339
+ }
340
+ for (const warning of result.warnings ?? []) console.log(dim(` ! ${warning}`));
341
+ }
342
+ }
343
+
344
+ async function pull(positional, flags) {
345
+ const { root, config } = projectConfig();
346
+ const project = flags.project ?? config.project;
347
+ if (!project) fail("which project? Pass --project <slug> or add it to .glossa.json.");
348
+ const { files } = await call("list_files", { project });
349
+ const projects = (await call("list_projects")).projects;
350
+ const meta = projects.find((p) => p.slug === project || p.id === project);
351
+ const locales = flags.locale ? String(flags.locale).split(",") : (config.locales ?? meta?.target_locales ?? []);
352
+ if (!locales.length) fail("no locales: pass --locale es,fr or set target locales on the project.");
353
+ const outRoot = resolve(root, flags.out ?? ".");
354
+ for (const file of files.filter((f) => f.status === "parsed" && (!positional.length || positional.includes(f.path)))) {
355
+ for (const locale of locales) {
356
+ const out = await call("pull_file", { project, path: file.path, locale, missing: flags.missing ?? "source", approved_only: Boolean(flags["approved-only"]), compress: true });
357
+ const target = join(outRoot, out.path);
358
+ mkdirSync(dirname(target), { recursive: true });
359
+ writeFileSync(target, fileText(out));
360
+ console.log(`${green("wrote")} ${relative(process.cwd(), target)} ${dim(`(${out.keys} keys)`)}`);
361
+ if (file.formats && /xcstrings|csv|xliff/.test(file.formats.slug ?? "")) break; // multi-locale: one write covers all
362
+ }
363
+ }
364
+ }
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
+
430
+ async function tasks(flags) {
431
+ const { tasks: list, total } = await call("list_tasks", coerceFlags(flags, { properties: { limit: { type: "integer" }, kind: { type: "string" }, status: { type: "string" } } }));
432
+ if (flags.json || !isTTY) return print({ total, tasks: list }, flags);
433
+ console.log(bold(`${total} ${flags.status ?? "open"} tasks`));
434
+ for (const t of list) console.log(`${dim(t.id.slice(0, 8))} ${t.kind.padEnd(19)} ${t.title}`);
435
+ if (total > list.length) console.log(dim(`… ${total - list.length} more (--limit, --offset)`));
436
+ }
437
+
438
+ /** stdio ⇄ HTTP: lets any MCP host that only speaks stdio use the hosted server. */
439
+ async function mcp() {
440
+ const key = apiKey();
441
+ if (!key) fail("set GLOSSA_API_KEY (or run `glossa login`) before starting the MCP bridge.");
442
+ const rl = createInterface({ input: process.stdin });
443
+ let queue = Promise.resolve();
444
+ rl.on("line", (line) => {
445
+ if (!line.trim()) return;
446
+ queue = queue.then(async () => {
447
+ let message;
448
+ try {
449
+ message = JSON.parse(line);
450
+ } catch {
451
+ return;
452
+ }
453
+ const response = await fetch(`${host()}/api/mcp`, {
454
+ method: "POST",
455
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "X-Glossa-Integration": "cli-mcp" },
456
+ body: JSON.stringify(message),
457
+ }).catch((error) => ({ ok: false, status: 0, text: async () => String(error) }));
458
+ const text = await response.text();
459
+ if (!text.trim()) return; // notifications get 202 with no body
460
+ try {
461
+ process.stdout.write(JSON.stringify(JSON.parse(text)) + "\n");
462
+ } catch {
463
+ if (message.id !== undefined) {
464
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: `HTTP ${response.status}: ${text.slice(0, 300)}` } }) + "\n");
465
+ }
466
+ }
467
+ });
468
+ });
469
+ }
470
+
471
+ async function help() {
472
+ console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
473
+
474
+ ${bold("Usage")}
475
+ glossa login [--no-browser] [--host URL] sign in with your browser
476
+ glossa login --key gls_… save an API key instead (CI, scripts)
477
+ glossa logout forget the saved key
478
+ glossa push [files…] [--project p] [--locale l]
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]
485
+ glossa tasks [--kind k] [--limit n] the review queue
486
+ glossa mcp stdio MCP bridge to the hosted server
487
+ glossa pricing plans and prices (no key needed)
488
+ glossa billing [--refresh] this workspace's plan and usage
489
+ glossa subscribe pro|team [--interval year] [--open]
490
+ Stripe Checkout link for a human; --confirm
491
+ charges the saved card (only once approved)
492
+ glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
493
+ glossa ops list every op
494
+
495
+ ${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.json"], "locales": ["es", "fr"]}
496
+ ${bold("Env")} GLOSSA_API_KEY, GLOSSA_HOST (default ${DEFAULT_HOST})`);
497
+ }
498
+
499
+ async function main() {
500
+ const [command, ...rest] = process.argv.slice(2);
501
+ const { positional, flags } = parseArgs(rest);
502
+ if (flags.host && command !== "login") process.env.GLOSSA_HOST = String(flags.host);
503
+ switch (command) {
504
+ case undefined:
505
+ case "help":
506
+ case "--help":
507
+ case "-h":
508
+ return help();
509
+ case "--version":
510
+ case "-v":
511
+ return console.log(VERSION);
512
+ case "login":
513
+ return login(flags);
514
+ case "logout":
515
+ return logout();
516
+ case "whoami":
517
+ return print(await call("get_workspace"), flags);
518
+ case "push":
519
+ return push(positional, flags);
520
+ case "pull":
521
+ return pull(positional, flags);
522
+ case "missing":
523
+ return missing(flags);
524
+ case "fill":
525
+ return fill(positional, flags);
526
+ case "tasks":
527
+ return tasks(flags);
528
+ case "mcp":
529
+ return mcp();
530
+ case "pricing":
531
+ return pricing(flags);
532
+ case "billing":
533
+ return billing(flags);
534
+ case "subscribe":
535
+ case "upgrade":
536
+ return subscribe(positional, flags);
537
+ case "ops": {
538
+ const ops = await manifest();
539
+ if (flags.json || !isTTY) return print(ops, flags);
540
+ for (const op of ops) console.log(`${bold(op.name.padEnd(22))} ${dim(op.read_only ? "read " : "write")} ${op.title}`);
541
+ return;
542
+ }
543
+ default: {
544
+ const ops = await manifest();
545
+ const op = ops.find((o) => o.name === command || o.name === command.replace(/-/g, "_"));
546
+ if (!op) fail(`unknown command "${command}". Run \`glossa ops\` for the list.`);
547
+ if (flags.help) {
548
+ console.log(`${bold(op.name)} — ${op.description}\n\n${JSON.stringify(op.input_schema, null, 2)}`);
549
+ return;
550
+ }
551
+ const input = { ...(flags.json ? readJsonArg(flags.json) : {}), ...coerceFlags(flags, op.input_schema) };
552
+ return print(await call(op.name, input), flags);
553
+ }
554
+ }
555
+ }
556
+
557
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/glossa") || process.argv[1]?.endsWith("glossa.mjs")) {
558
+ main().catch((error) => {
559
+ if (error.body) console.error(red(`error: ${error.body.detail ?? error.message}`) + (error.body.request_id ? dim(` (request ${error.body.request_id})`) : ""));
560
+ else console.error(red(`error: ${error.message}`));
561
+ process.exit(1);
562
+ });
563
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, readJsonArg, fillBatches, subscribeInput } from "./glossa.mjs";
3
+
4
+ describe("cli args", () => {
5
+ it("parses flags and positionals", () => {
6
+ expect(parseArgs(["a.json", "--project", "app", "--dry", "--locale=es"])).toEqual({ positional: ["a.json"], flags: { project: "app", dry: true, locale: "es" } });
7
+ });
8
+ it("coerces by schema", () => {
9
+ const schema = { properties: { limit: { type: "integer" }, locales: { type: "array" }, auto_translate: { type: "boolean" } } };
10
+ expect(coerceFlags({ limit: "5", locales: "es, fr", "auto-translate": "false", json: "{}" }, schema)).toEqual({ limit: 5, locales: ["es", "fr"], auto_translate: false });
11
+ });
12
+ });
13
+
14
+ describe("subscribe", () => {
15
+ it("asks for a checkout link unless --confirm", () => {
16
+ expect(subscribeInput(["pro"], {})).toEqual({ plan: "pro", interval: "month" });
17
+ expect(subscribeInput(["Team"], { yearly: true })).toEqual({ plan: "team", interval: "year" });
18
+ expect(subscribeInput([], { plan: "pro", interval: "year", confirm: true })).toEqual({ plan: "pro", interval: "year", confirm: true });
19
+ });
20
+ it("rejects unknown plans", () => {
21
+ expect(() => subscribeInput(["enterprise"], {})).toThrow(/name a plan/);
22
+ });
23
+ });
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
+
45
+ describe("deviceLogin", () => {
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 };
47
+ const reply = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
48
+
49
+ function harness(polls) {
50
+ const calls = [];
51
+ const opened = [];
52
+ const lines = [];
53
+ let clock = 0;
54
+ const fetchImpl = async (url, init) => {
55
+ calls.push({ url, body: JSON.parse(init.body) });
56
+ if (url.endsWith("/api/v1/auth/device")) return reply(200, start);
57
+ const next = polls.shift();
58
+ if (next instanceof Error) throw next;
59
+ return next;
60
+ };
61
+ const opts = { base: "https://x.test", fetchImpl, wait: async (ms) => void (clock += ms), log: (l) => lines.push(l), open: (u) => opened.push(u), now: () => clock };
62
+ return { calls, opened, lines, opts, waited: () => clock };
63
+ }
64
+
65
+ it("opens the browser, polls while pending and returns the key", async () => {
66
+ const h = harness([reply(428, { code: "authorization_pending" }), new Error("offline"), reply(200, { api_key: "gls_new", email: "a@b.co" })]);
67
+ await expect(deviceLogin(h.opts)).resolves.toEqual({ api_key: "gls_new", email: "a@b.co" });
68
+ expect(h.opened).toEqual([start.verification_uri_complete]);
69
+ expect(h.lines.join("\n")).toContain("ABCD-EFGH");
70
+ expect(h.calls[0].body.client_name).toMatch(/^glossa CLI on /);
71
+ expect(h.calls.slice(1).every((c) => c.url.endsWith("/api/v1/auth/device/token") && c.body.device_code === "dc_secret")).toBe(true);
72
+ expect(h.waited()).toBe(3000 + 3000 + 5000); // backs off after the network error
73
+ });
74
+
75
+ it("does not open a browser with browser: false", async () => {
76
+ const h = harness([reply(200, { api_key: "gls_new", email: null })]);
77
+ await deviceLogin({ ...h.opts, browser: false });
78
+ expect(h.opened).toEqual([]);
79
+ });
80
+
81
+ it("stops when the person cancels", async () => {
82
+ const h = harness([reply(400, { code: "access_denied", detail: "The sign-in was cancelled in the browser." })]);
83
+ await expect(deviceLogin(h.opts)).rejects.toThrow(/cancelled/);
84
+ });
85
+
86
+ it("gives up when the code expires", async () => {
87
+ const h = harness(Array.from({ length: 400 }, () => reply(428, { code: "authorization_pending" })));
88
+ await expect(deviceLogin(h.opts)).rejects.toThrow(/expired/);
89
+ });
90
+ });
91
+
92
+ describe("file bodies", () => {
93
+ it("sends small files as text and large ones brotli-compressed", () => {
94
+ expect(fileBody("{}")).toEqual({ content: "{}" });
95
+ const big = JSON.stringify(Array.from({ length: 60_000 }, (_, i) => ({ key: `k${i}`, de: "Zurückgeben" })));
96
+ const body = fileBody(big);
97
+ expect(body.content).toBeUndefined();
98
+ expect(body.content_brotli.length).toBeLessThan(big.length / 10);
99
+ expect(fileText(body)).toBe(big);
100
+ expect(fileText({ content: "x" })).toBe("x");
101
+ });
102
+ });
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "@meterapp/glossa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
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
+ "type": "module",
6
+ "bin": { "glossa": "bin/glossa.mjs" },
7
+ "files": ["bin", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "license": "MIT",
10
+ "repository": { "type": "git", "url": "git+https://github.com/MeterApp/glossa.git", "directory": "packages/cli" },
11
+ "keywords": ["i18n", "l10n", "localization", "translation", "crowdin", "mcp", "ai", "cli"],
12
+ "scripts": { "test": "cd ../.. && vitest run packages/cli" }
13
+ }