@meterapp/glossa 0.0.0-stage → 0.2.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,33 @@
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 run_audit --locale es --question "Are brand names consistent?"
11
+ npx @meterapp/glossa ops # every op, live from the API
12
+ 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
16
+ ```
17
+
18
+ `subscribe` prints a Stripe Checkout link and charges nothing; a human pays in
19
+ the browser. With `--confirm` it subscribes on the card already saved for the
20
+ workspace instead, so pass it only after a human approved the plan and price.
21
+
22
+ `.glossa.json` at your repo root:
23
+
24
+ ```json
25
+ { "project": "web", "files": ["locales/en.json"], "locales": ["es", "fr", "de"] }
26
+ ```
27
+
28
+ `login` opens your browser: confirm the code your terminal shows at
29
+ getglossa.dev/activate and the CLI saves a new workspace API key. Over SSH,
30
+ open the printed URL on any device (`--no-browser` skips opening one). To save
31
+ an existing key instead, run `login --key gls_…`.
32
+
33
+ In CI set `GLOSSA_API_KEY` (a workspace key, `gls_…`).
package/bin/glossa.mjs ADDED
@@ -0,0 +1,481 @@
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.2.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
+ function print(value, flags) {
124
+ if (flags.pretty === false || (!isTTY && !flags.pretty)) process.stdout.write(JSON.stringify(value) + "\n");
125
+ else process.stdout.write(JSON.stringify(value, null, 2) + "\n");
126
+ }
127
+
128
+ // ----------------------------------------------------------------- billing
129
+ const money = (cents) => (cents === null || cents === undefined ? "custom" : `$${(cents / 100).toLocaleString("en-US")}`);
130
+
131
+ /** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
132
+ export function subscribeInput(positional, flags) {
133
+ 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`).');
135
+ const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
136
+ return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
137
+ }
138
+
139
+ function openUrl(url) {
140
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
141
+ spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
142
+ }
143
+
144
+ async function pricing(flags) {
145
+ const response = await fetch(`${host()}/api/v1/get_pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
146
+ 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]`));
155
+ }
156
+
157
+ async function billing(flags) {
158
+ const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
159
+ if (flags.json || !isTTY) return print(state, flags);
160
+ const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
161
+ 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")}`);
164
+ }
165
+ console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
166
+ }
167
+
168
+ /**
169
+ * Without --confirm: a Stripe Checkout link for a human (nothing is charged).
170
+ * With --confirm: subscribe on the saved card, which a human must have approved.
171
+ */
172
+ async function subscribe(positional, flags) {
173
+ let input;
174
+ try {
175
+ input = subscribeInput(positional, flags);
176
+ } catch (error) {
177
+ fail(error.message);
178
+ }
179
+ const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input);
180
+ const url = result.url ?? result.checkout_url ?? null;
181
+ if (flags.json || !isTTY) {
182
+ print(result, flags);
183
+ } else if (url) {
184
+ 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
+ console.log(url);
186
+ } else {
187
+ console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
188
+ }
189
+ if (url && flags.open) openUrl(url);
190
+ }
191
+
192
+ // ------------------------------------------------------------------ login
193
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
194
+
195
+ /**
196
+ * Browser sign-in (RFC 8628 device flow): print a code and a URL, open the
197
+ * browser, poll until the person approves at /activate. Works over SSH and for
198
+ * agents, who relay the URL and code to their human. Resolves to
199
+ * {api_key, email, workspace}.
200
+ */
201
+ export async function deviceLogin({ base, browser = true, fetchImpl = fetch, wait = sleep, log = (line) => console.error(line), open = openUrl, now = Date.now } = {}) {
202
+ const post = async (path, body) => {
203
+ const response = await fetchImpl(`${base}${path}`, {
204
+ method: "POST",
205
+ headers: { "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
206
+ body: JSON.stringify(body),
207
+ });
208
+ const text = await response.text();
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(text);
212
+ } catch {
213
+ parsed = { detail: text };
214
+ }
215
+ return { status: response.status, ok: response.ok, body: parsed };
216
+ };
217
+ const start = await post("/api/v1/auth/device", { client_name: `glossa CLI on ${hostname()}`.slice(0, 80) });
218
+ if (!start.ok) {
219
+ const error = new Error(start.body.detail || `could not start sign-in (HTTP ${start.status})`);
220
+ error.body = start.body;
221
+ throw error;
222
+ }
223
+ const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } = start.body;
224
+ log(`Open ${bold(verification_uri)} and enter the code ${bold(user_code)}`);
225
+ log(dim(`(or go straight to ${verification_uri_complete})`));
226
+ if (browser) open(verification_uri_complete);
227
+ log("Waiting for approval in the browser…");
228
+ const deadline = now() + expires_in * 1000;
229
+ let delay = Math.max(1, interval ?? 3) * 1000;
230
+ while (now() < deadline) {
231
+ await wait(delay);
232
+ let poll;
233
+ try {
234
+ poll = await post("/api/v1/auth/device/token", { device_code });
235
+ } catch {
236
+ delay += 2000; // network blip: back off and keep waiting
237
+ continue;
238
+ }
239
+ if (poll.ok) return poll.body;
240
+ if (poll.body.code === "authorization_pending") continue;
241
+ if (poll.status === 429 || poll.status >= 500) {
242
+ delay += 2000;
243
+ continue;
244
+ }
245
+ const error = new Error(poll.body.detail || `sign-in failed (HTTP ${poll.status})`);
246
+ error.body = poll.body;
247
+ throw error;
248
+ }
249
+ throw new Error("the code expired before it was approved. Run `glossa login` again.");
250
+ }
251
+
252
+ // ---------------------------------------------------------------- commands
253
+ async function login(flags) {
254
+ if (flags.host) saveUserConfig({ ...userConfig(), host: String(flags.host) });
255
+ // --key gls_… (CI, scripts) or --key alone to paste one; otherwise sign in with the browser.
256
+ if (flags.key !== undefined) {
257
+ let value = typeof flags.key === "string" ? flags.key : null;
258
+ if (!value) {
259
+ console.log(`Create an API key at ${bold(`${host()}/dashboard/settings`)} (Settings → API keys), then paste it here.`);
260
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
261
+ value = await new Promise((done) => rl.question("API key: ", (answer) => (rl.close(), done(answer.trim()))));
262
+ }
263
+ if (!value?.startsWith("gls_")) fail("that does not look like a Glossa API key (gls_…).");
264
+ saveUserConfig({ ...userConfig(), api_key: value });
265
+ } else {
266
+ const result = await deviceLogin({ base: host(), browser: !flags["no-browser"] });
267
+ saveUserConfig({ ...userConfig(), api_key: result.api_key, ...(result.email ? { email: result.email } : {}) });
268
+ }
269
+ const workspace = await call("get_workspace");
270
+ console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
271
+ }
272
+
273
+ function logout() {
274
+ const config = userConfig();
275
+ if (!config.api_key) return console.log("Not signed in.");
276
+ delete config.api_key;
277
+ delete config.email;
278
+ saveUserConfig(config);
279
+ console.log(`Signed out. The key still works until you revoke it (Settings → API keys).`);
280
+ }
281
+
282
+ function expandFiles(patterns, root) {
283
+ const files = [];
284
+ for (const pattern of patterns) {
285
+ const path = resolve(root, pattern);
286
+ if (existsSync(path) && statSync(path).isDirectory()) {
287
+ for (const entry of readdirSync(path, { recursive: true })) {
288
+ const full = join(path, String(entry));
289
+ if (statSync(full).isFile()) files.push(full);
290
+ }
291
+ } else if (existsSync(path)) files.push(path);
292
+ else fail(`no such file: ${pattern}`);
293
+ }
294
+ return files;
295
+ }
296
+
297
+ /** Files over 1 MB travel brotli-compressed: request and response bodies are capped at 4.5 MB. */
298
+ export function fileBody(text) {
299
+ if (Buffer.byteLength(text, "utf8") <= 1_000_000) return { content: text };
300
+ const bytes = Buffer.from(text, "utf8");
301
+ return { content_brotli: brotliCompressSync(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } }).toString("base64") };
302
+ }
303
+
304
+ export function fileText(out) {
305
+ return out.content_brotli !== undefined ? brotliDecompressSync(Buffer.from(out.content_brotli, "base64")).toString("utf8") : out.content;
306
+ }
307
+
308
+ async function push(positional, flags) {
309
+ const { root, config } = projectConfig();
310
+ const project = flags.project ?? config.project;
311
+ if (!project) fail("which project? Pass --project <slug> or add {\"project\": \"…\"} to .glossa.json.");
312
+ const sources = positional.length ? positional : (config.files ?? []).map((f) => (typeof f === "string" ? f : f.source));
313
+ if (!sources.length) fail("nothing to push. Pass files, or list them under \"files\" in .glossa.json.");
314
+ for (const file of expandFiles(sources, root)) {
315
+ const path = relative(root, file);
316
+ const result = await call("push_file", { project, path, ...fileBody(readFileSync(file, "utf8")), ...(flags.locale ? { locale: flags.locale } : {}), ...(flags.format ? { format: flags.format } : {}) });
317
+ if (flags.json || !isTTY) {
318
+ print(result, flags);
319
+ continue;
320
+ }
321
+ if (result.status === "needs_format") {
322
+ console.log(`${bold(path)} ${red("unknown format")} — AI is learning it (task ${result.learn_format_task_id ?? "queued"}).`);
323
+ } else {
324
+ console.log(
325
+ `${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
326
+ `${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
327
+ (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)})`) : "")
329
+ );
330
+ }
331
+ for (const warning of result.warnings ?? []) console.log(dim(` ! ${warning}`));
332
+ }
333
+ }
334
+
335
+ async function pull(positional, flags) {
336
+ const { root, config } = projectConfig();
337
+ const project = flags.project ?? config.project;
338
+ if (!project) fail("which project? Pass --project <slug> or add it to .glossa.json.");
339
+ const { files } = await call("list_files", { project });
340
+ const projects = (await call("list_projects")).projects;
341
+ const meta = projects.find((p) => p.slug === project || p.id === project);
342
+ const locales = flags.locale ? String(flags.locale).split(",") : (config.locales ?? meta?.target_locales ?? []);
343
+ if (!locales.length) fail("no locales: pass --locale es,fr or set target locales on the project.");
344
+ const outRoot = resolve(root, flags.out ?? ".");
345
+ for (const file of files.filter((f) => f.status === "parsed" && (!positional.length || positional.includes(f.path)))) {
346
+ for (const locale of locales) {
347
+ const out = await call("pull_file", { project, path: file.path, locale, missing: flags.missing ?? "source", approved_only: Boolean(flags["approved-only"]), compress: true });
348
+ const target = join(outRoot, out.path);
349
+ mkdirSync(dirname(target), { recursive: true });
350
+ writeFileSync(target, fileText(out));
351
+ console.log(`${green("wrote")} ${relative(process.cwd(), target)} ${dim(`(${out.keys} keys)`)}`);
352
+ if (file.formats && /xcstrings|csv|xliff/.test(file.formats.slug ?? "")) break; // multi-locale: one write covers all
353
+ }
354
+ }
355
+ }
356
+
357
+ async function tasks(flags) {
358
+ const { tasks: list, total } = await call("list_tasks", coerceFlags(flags, { properties: { limit: { type: "integer" }, kind: { type: "string" }, status: { type: "string" } } }));
359
+ if (flags.json || !isTTY) return print({ total, tasks: list }, flags);
360
+ console.log(bold(`${total} ${flags.status ?? "open"} tasks`));
361
+ for (const t of list) console.log(`${dim(t.id.slice(0, 8))} ${t.kind.padEnd(19)} ${t.title}`);
362
+ if (total > list.length) console.log(dim(`… ${total - list.length} more (--limit, --offset)`));
363
+ }
364
+
365
+ /** stdio ⇄ HTTP: lets any MCP host that only speaks stdio use the hosted server. */
366
+ async function mcp() {
367
+ const key = apiKey();
368
+ if (!key) fail("set GLOSSA_API_KEY (or run `glossa login`) before starting the MCP bridge.");
369
+ const rl = createInterface({ input: process.stdin });
370
+ let queue = Promise.resolve();
371
+ rl.on("line", (line) => {
372
+ if (!line.trim()) return;
373
+ queue = queue.then(async () => {
374
+ let message;
375
+ try {
376
+ message = JSON.parse(line);
377
+ } catch {
378
+ return;
379
+ }
380
+ const response = await fetch(`${host()}/api/mcp`, {
381
+ method: "POST",
382
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "X-Glossa-Integration": "cli-mcp" },
383
+ body: JSON.stringify(message),
384
+ }).catch((error) => ({ ok: false, status: 0, text: async () => String(error) }));
385
+ const text = await response.text();
386
+ if (!text.trim()) return; // notifications get 202 with no body
387
+ try {
388
+ process.stdout.write(JSON.stringify(JSON.parse(text)) + "\n");
389
+ } catch {
390
+ if (message.id !== undefined) {
391
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: `HTTP ${response.status}: ${text.slice(0, 300)}` } }) + "\n");
392
+ }
393
+ }
394
+ });
395
+ });
396
+ }
397
+
398
+ async function help() {
399
+ console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
400
+
401
+ ${bold("Usage")}
402
+ glossa login [--no-browser] [--host URL] sign in with your browser
403
+ glossa login --key gls_… save an API key instead (CI, scripts)
404
+ glossa logout forget the saved key
405
+ glossa push [files…] [--project p] [--locale l]
406
+ glossa pull [paths…] [--project p] [--locale es,fr] [--out dir] [--approved-only]
407
+ glossa tasks [--kind k] [--limit n] the review queue
408
+ glossa mcp stdio MCP bridge to the hosted server
409
+ glossa pricing plans and prices (no key needed)
410
+ 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)
415
+ glossa ops list every op
416
+
417
+ ${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.json"], "locales": ["es", "fr"]}
418
+ ${bold("Env")} GLOSSA_API_KEY, GLOSSA_HOST (default ${DEFAULT_HOST})`);
419
+ }
420
+
421
+ async function main() {
422
+ const [command, ...rest] = process.argv.slice(2);
423
+ const { positional, flags } = parseArgs(rest);
424
+ if (flags.host && command !== "login") process.env.GLOSSA_HOST = String(flags.host);
425
+ switch (command) {
426
+ case undefined:
427
+ case "help":
428
+ case "--help":
429
+ case "-h":
430
+ return help();
431
+ case "--version":
432
+ case "-v":
433
+ return console.log(VERSION);
434
+ case "login":
435
+ return login(flags);
436
+ case "logout":
437
+ return logout();
438
+ case "whoami":
439
+ return print(await call("get_workspace"), flags);
440
+ case "push":
441
+ return push(positional, flags);
442
+ case "pull":
443
+ return pull(positional, flags);
444
+ case "tasks":
445
+ return tasks(flags);
446
+ case "mcp":
447
+ return mcp();
448
+ case "pricing":
449
+ return pricing(flags);
450
+ case "billing":
451
+ return billing(flags);
452
+ case "subscribe":
453
+ case "upgrade":
454
+ return subscribe(positional, flags);
455
+ case "ops": {
456
+ const ops = await manifest();
457
+ if (flags.json || !isTTY) return print(ops, flags);
458
+ for (const op of ops) console.log(`${bold(op.name.padEnd(22))} ${dim(op.read_only ? "read " : "write")} ${op.title}`);
459
+ return;
460
+ }
461
+ default: {
462
+ const ops = await manifest();
463
+ const op = ops.find((o) => o.name === command || o.name === command.replace(/-/g, "_"));
464
+ if (!op) fail(`unknown command "${command}". Run \`glossa ops\` for the list.`);
465
+ if (flags.help) {
466
+ console.log(`${bold(op.name)} — ${op.description}\n\n${JSON.stringify(op.input_schema, null, 2)}`);
467
+ return;
468
+ }
469
+ const input = { ...(flags.json ? JSON.parse(String(flags.json)) : {}), ...coerceFlags(flags, op.input_schema) };
470
+ return print(await call(op.name, input), flags);
471
+ }
472
+ }
473
+ }
474
+
475
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/glossa") || process.argv[1]?.endsWith("glossa.mjs")) {
476
+ main().catch((error) => {
477
+ if (error.body) console.error(red(`error: ${error.body.detail ?? error.message}`) + (error.body.request_id ? dim(` (request ${error.body.request_id})`) : ""));
478
+ else console.error(red(`error: ${error.message}`));
479
+ process.exit(1);
480
+ });
481
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, 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("deviceLogin", () => {
26
+ 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
+ const reply = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
28
+
29
+ function harness(polls) {
30
+ const calls = [];
31
+ const opened = [];
32
+ const lines = [];
33
+ let clock = 0;
34
+ const fetchImpl = async (url, init) => {
35
+ calls.push({ url, body: JSON.parse(init.body) });
36
+ if (url.endsWith("/api/v1/auth/device")) return reply(200, start);
37
+ const next = polls.shift();
38
+ if (next instanceof Error) throw next;
39
+ return next;
40
+ };
41
+ 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 };
42
+ return { calls, opened, lines, opts, waited: () => clock };
43
+ }
44
+
45
+ it("opens the browser, polls while pending and returns the key", async () => {
46
+ const h = harness([reply(428, { code: "authorization_pending" }), new Error("offline"), reply(200, { api_key: "gls_new", email: "a@b.co" })]);
47
+ await expect(deviceLogin(h.opts)).resolves.toEqual({ api_key: "gls_new", email: "a@b.co" });
48
+ expect(h.opened).toEqual([start.verification_uri_complete]);
49
+ expect(h.lines.join("\n")).toContain("ABCD-EFGH");
50
+ expect(h.calls[0].body.client_name).toMatch(/^glossa CLI on /);
51
+ expect(h.calls.slice(1).every((c) => c.url.endsWith("/api/v1/auth/device/token") && c.body.device_code === "dc_secret")).toBe(true);
52
+ expect(h.waited()).toBe(3000 + 3000 + 5000); // backs off after the network error
53
+ });
54
+
55
+ it("does not open a browser with browser: false", async () => {
56
+ const h = harness([reply(200, { api_key: "gls_new", email: null })]);
57
+ await deviceLogin({ ...h.opts, browser: false });
58
+ expect(h.opened).toEqual([]);
59
+ });
60
+
61
+ it("stops when the person cancels", async () => {
62
+ const h = harness([reply(400, { code: "access_denied", detail: "The sign-in was cancelled in the browser." })]);
63
+ await expect(deviceLogin(h.opts)).rejects.toThrow(/cancelled/);
64
+ });
65
+
66
+ it("gives up when the code expires", async () => {
67
+ const h = harness(Array.from({ length: 400 }, () => reply(428, { code: "authorization_pending" })));
68
+ await expect(deviceLogin(h.opts)).rejects.toThrow(/expired/);
69
+ });
70
+ });
71
+
72
+ describe("file bodies", () => {
73
+ it("sends small files as text and large ones brotli-compressed", () => {
74
+ expect(fileBody("{}")).toEqual({ content: "{}" });
75
+ const big = JSON.stringify(Array.from({ length: 60_000 }, (_, i) => ({ key: `k${i}`, de: "Zurückgeben" })));
76
+ const body = fileBody(big);
77
+ expect(body.content).toBeUndefined();
78
+ expect(body.content_brotli.length).toBeLessThan(big.length / 10);
79
+ expect(fileText(body)).toBe(big);
80
+ expect(fileText({ content: "x" })).toBe("x");
81
+ });
82
+ });
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.2.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
+ }