@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 +12 -4
- package/bin/glossa.mjs +167 -27
- package/bin/glossa.test.mjs +31 -1
- package/package.json +1 -1
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
|
|
14
|
-
npx @meterapp/glossa
|
|
15
|
-
npx @meterapp/glossa
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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
|
|
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/
|
|
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
|
|
148
|
-
if (flags.json || !isTTY) return print(
|
|
149
|
-
for (const
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 ?
|
|
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
|
}
|
package/bin/glossa.test.mjs
CHANGED
|
@@ -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.
|
|
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" },
|