@meterapp/glossa 0.3.0 → 0.5.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
@@ -12,12 +12,13 @@ npx @meterapp/glossa fill translations.json # write [{"unit","
12
12
  npx @meterapp/glossa run_audit --locale es --question "Are brand names consistent?"
13
13
  npx @meterapp/glossa ops # every op, live from the API
14
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
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
18
19
  ```
19
20
 
20
- `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
21
22
  the browser. With `--confirm` it subscribes on the card already saved for the
22
23
  workspace instead, so pass it only after a human approved the plan and price.
23
24
 
package/bin/glossa.mjs CHANGED
@@ -9,9 +9,11 @@ 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.3.0";
12
+ const VERSION = "0.5.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
+ /** The last failed call, so `glossa feedback report --last` can attach it. */
16
+ const LAST_ERROR_PATH = join(dirname(CONFIG_PATH), "last-error.json");
15
17
  const PROJECT_CONFIG = ".glossa.json";
16
18
 
17
19
  // ------------------------------------------------------------------ config
@@ -53,12 +55,13 @@ function fail(message, code = 1) {
53
55
  }
54
56
 
55
57
  // ------------------------------------------------------------------- http
56
- async function call(op, input = {}, { method = "POST" } = {}) {
58
+ /** `anonymous: true` calls a public op without a key when none is saved (pricing, checkout before signup). */
59
+ async function call(op, input = {}, { method = "POST", anonymous = false } = {}) {
57
60
  const key = apiKey();
58
- if (!key) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
61
+ if (!key && !anonymous) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
59
62
  const response = await fetch(`${host()}/api/v1/${op}`, {
60
63
  method,
61
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
64
+ headers: { ...(key ? { Authorization: `Bearer ${key}` } : {}), "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
62
65
  body: JSON.stringify(input),
63
66
  });
64
67
  const text = await response.text();
@@ -72,11 +75,24 @@ async function call(op, input = {}, { method = "POST" } = {}) {
72
75
  const error = new Error(body.detail || `${op} failed with HTTP ${response.status}`);
73
76
  error.status = response.status;
74
77
  error.body = body;
78
+ if (op !== "report_issue") rememberFailure(op, response.status, body, response.headers.get("x-request-id"));
75
79
  throw error;
76
80
  }
77
81
  return body;
78
82
  }
79
83
 
84
+ /** Saves what `report --last` attaches: never the input, only the request id and the error. */
85
+ function rememberFailure(op, status, body, headerId) {
86
+ try {
87
+ mkdirSync(dirname(LAST_ERROR_PATH), { recursive: true });
88
+ const record = { request_id: body.request_id ?? headerId ?? null, op, status, error_code: body.code ?? body.title ?? null, error: String(body.detail ?? "").slice(0, 2000), at: new Date().toISOString() };
89
+ writeFileSync(LAST_ERROR_PATH, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
90
+ } catch {
91
+ // Best effort: a read-only home directory must not hide the real error.
92
+ }
93
+ }
94
+ const lastFailure = () => readJson(LAST_ERROR_PATH);
95
+
80
96
  async function manifest() {
81
97
  const response = await fetch(`${host()}/api/v1`, { headers: { "User-Agent": `glossa-cli/${VERSION}` } });
82
98
  if (!response.ok) fail(`could not read the op catalog from ${host()}/api/v1 (HTTP ${response.status}).`);
@@ -139,7 +155,7 @@ const money = (cents) => (cents === null || cents === undefined ? "custom" : `$$
139
155
  /** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
140
156
  export function subscribeInput(positional, flags) {
141
157
  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`).');
158
+ if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa upgrade --plan pro` or `--plan team` (see `glossa pricing`).');
143
159
  const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
144
160
  return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
145
161
  }
@@ -149,28 +165,56 @@ function openUrl(url) {
149
165
  spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
150
166
  }
151
167
 
168
+ /** One line per plan from the shared pricing document (GET /api/v1/pricing). */
169
+ export function pricingLines(doc) {
170
+ return doc.plans.map((plan) => {
171
+ 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`;
172
+ const included = plan.included.ai_translations_per_month === null ? "volume" : plan.included.ai_translations_per_month.toLocaleString("en-US");
173
+ const members = plan.limits.members === null ? "unlimited" : plan.limits.members;
174
+ const projects = plan.limits.projects === null ? "unlimited" : plan.limits.projects;
175
+ return { name: plan.name, text: `${price.padEnd(22)} ${included} AI translations/mo, ${members} members, ${projects} projects` };
176
+ });
177
+ }
178
+
152
179
  async function pricing(flags) {
153
- const response = await fetch(`${host()}/api/v1/get_pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
180
+ const response = await fetch(`${host()}/api/v1/pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
154
181
  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]`));
182
+ const doc = await response.json();
183
+ if (flags.json || !isTTY) return print(doc, flags);
184
+ for (const line of pricingLines(doc)) console.log(`${bold(line.name.padEnd(11))} ${line.text}`);
185
+ console.log(dim(`\n${doc.unit}\nBuy (no account needed first): glossa upgrade --plan pro [--yearly] [--open]`));
163
186
  }
164
187
 
165
- async function billing(flags) {
188
+ const usageLine = (label, usage) => ` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`;
189
+
190
+ async function billing(positional, flags) {
191
+ if (positional[0] === "portal") {
192
+ const result = await call("get_billing_link");
193
+ if (flags.json || !isTTY) print(result, flags);
194
+ else console.log(`${result.message}\n${result.url}`);
195
+ if (flags.open) openUrl(result.url);
196
+ return;
197
+ }
166
198
  const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
167
199
  if (flags.json || !isTTY) return print(state, flags);
168
200
  const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
169
201
  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")}`);
202
+ for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
203
+ console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · invoices and card: glossa billing portal`));
204
+ }
205
+
206
+ async function account(flags) {
207
+ const state = await call("get_account");
208
+ if (flags.json || !isTTY) return print(state, flags);
209
+ const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
210
+ console.log(`${bold(state.workspace.name)} ${dim(`(${state.workspace.slug}, ${state.role})`)}`);
211
+ console.log(`${bold(state.plan_name)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
212
+ for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
213
+ if (state.next_tier) {
214
+ const adds = state.next_tier.adds;
215
+ 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" : "";
216
+ 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}`));
172
217
  }
173
- console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
174
218
  }
175
219
 
176
220
  /**
@@ -184,13 +228,17 @@ async function subscribe(positional, flags) {
184
228
  } catch (error) {
185
229
  fail(error.message);
186
230
  }
187
- const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input);
231
+ if (input.confirm && !apiKey()) fail("--confirm charges a saved card and needs `glossa login` first. Without it you get a checkout link.");
232
+ const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input, { anonymous: true });
188
233
  const url = result.url ?? result.checkout_url ?? null;
234
+ // Paid before signing in: remember the session so `glossa login` attaches it to the workspace.
235
+ if (!apiKey() && result.session_id) saveUserConfig({ ...userConfig(), pending_checkout: result.session_id });
189
236
  if (flags.json || !isTTY) {
190
237
  print(result, flags);
191
238
  } else if (url) {
192
239
  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
240
  console.log(url);
241
+ if (!apiKey()) console.log(dim("After paying, run `glossa login`: the plan attaches to your workspace."));
194
242
  } else {
195
243
  console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
196
244
  }
@@ -276,6 +324,26 @@ async function login(flags) {
276
324
  }
277
325
  const workspace = await call("get_workspace");
278
326
  console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
327
+ await claimPending();
328
+ }
329
+
330
+ /** Attaches a checkout paid before login (saved by `glossa upgrade`) to the workspace just signed in to. */
331
+ async function claimPending() {
332
+ const config = userConfig();
333
+ if (!config.pending_checkout) return;
334
+ try {
335
+ const result = await call("claim_checkout", { session_id: config.pending_checkout });
336
+ console.log(green(`✓ ${result.billing.plan_name} is now this workspace's plan.`));
337
+ delete config.pending_checkout;
338
+ saveUserConfig(config);
339
+ } catch (error) {
340
+ 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 …`)."));
341
+ if (["already_claimed", "not_found"].includes(error.body?.code)) {
342
+ delete config.pending_checkout;
343
+ saveUserConfig(config);
344
+ }
345
+ console.log(dim(`Could not attach the earlier checkout: ${error.message}`));
346
+ }
279
347
  }
280
348
 
281
349
  function logout() {
@@ -468,6 +536,173 @@ async function mcp() {
468
536
  });
469
537
  }
470
538
 
539
+ // ---------------------------------------------------------------- feedback
540
+ const FEEDBACK_USAGE = `glossa feedback list [--kind feature|issue] [--status active|all|done…] [--sort top|new|recent_activity] [--q text]
541
+ glossa feedback show <id>
542
+ glossa feedback mine
543
+ glossa feedback request "<title>" [--body text] [--force]
544
+ glossa feedback report "<title>" [--body text] [--request-id id] [--op op] [--project p --key k --locale l --file f] [--last] [--force]
545
+ glossa feedback upvote <id> [--remove]
546
+ glossa feedback comment <id> "<text>"
547
+ glossa review --rating 1-5 [--title t] [--body b] [--likes l] [--dislikes d] [--use-case u] [--name n] [--company c] [--consent]
548
+ glossa reviews [--sort newest|helpful|highest|lowest]`;
549
+
550
+ const str = (value) => (value === undefined || value === true ? undefined : String(value));
551
+ const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== ""));
552
+
553
+ /** `report` input: the title, body, flags as context, and with --last the last failed call. */
554
+ export function reportInput(title, flags, last = null) {
555
+ if (!title) throw new Error('give the problem a title: glossa feedback report "push_file drops plural forms" --last');
556
+ if (flags.last && !last) throw new Error("no failed call recorded yet; pass --request-id from the error instead.");
557
+ const fromLast = flags.last && last ? compact({ request_id: last.request_id ?? undefined, op: last.op, status: last.status, error_code: last.error_code ?? undefined, error: last.error }) : {};
558
+ // Flags win over the recorded failure.
559
+ const context = {
560
+ ...fromLast,
561
+ ...compact({
562
+ request_id: str(flags["request-id"] ?? flags.request_id),
563
+ op: str(flags.op),
564
+ project: str(flags.project),
565
+ key: str(flags.key),
566
+ locale: str(flags.locale),
567
+ file: str(flags.file),
568
+ client: `glossa-cli/${VERSION}`,
569
+ }),
570
+ };
571
+ return compact({ title, details: str(flags.body ?? flags.details), context, force: flags.force ? true : undefined });
572
+ }
573
+
574
+ /** `review` flags as submit_review input. */
575
+ export function reviewInput(flags) {
576
+ const rating = Number(flags.rating);
577
+ if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("rate 1 to 5: glossa review --rating 5 --body \"…\"");
578
+ return compact({
579
+ rating,
580
+ title: str(flags.title),
581
+ body: str(flags.body),
582
+ likes: str(flags.likes),
583
+ dislikes: str(flags.dislikes),
584
+ use_case: str(flags["use-case"] ?? flags.use_case),
585
+ display_name: str(flags.name),
586
+ display_company: str(flags.company),
587
+ marketing_consent: flags.consent ? true : undefined,
588
+ });
589
+ }
590
+
591
+ /** `feedback <sub> …` as the op to call and its input. */
592
+ export function feedbackCall(positional, flags, last = null) {
593
+ const [sub, ...rest] = positional;
594
+ const id = rest[0];
595
+ const needId = () => {
596
+ if (!/^\d+$/.test(String(id ?? ""))) throw new Error(`which item? glossa feedback ${sub} <id>`);
597
+ return Number(id);
598
+ };
599
+ switch (sub) {
600
+ case undefined:
601
+ case "list":
602
+ return { op: "list_feedback", anonymous: true, input: compact({ kind: str(flags.kind), status: str(flags.status), sort: str(flags.sort), q: str(flags.q), limit: flags.limit ? Number(flags.limit) : undefined, offset: flags.offset ? Number(flags.offset) : undefined }) };
603
+ case "show":
604
+ return { op: "get_feedback", anonymous: true, input: { id: needId() } };
605
+ case "mine":
606
+ return { op: "list_my_feedback", input: {} };
607
+ case "request":
608
+ if (!rest.join(" ").trim()) throw new Error('say what you want: glossa feedback request "Open a GitHub PR on pull"');
609
+ return { op: "request_feature", input: compact({ title: rest.join(" ").trim(), details: str(flags.body ?? flags.details), force: flags.force ? true : undefined }) };
610
+ case "report": {
611
+ // `report --last "title"` parses the title as --last's value.
612
+ const title = rest.join(" ").trim() || (typeof flags.last === "string" ? flags.last : "");
613
+ return { op: "report_issue", input: reportInput(title, flags, last) };
614
+ }
615
+ case "upvote":
616
+ case "vote":
617
+ return { op: flags.remove ? "remove_feedback_vote" : "upvote_feedback", input: { id: needId() } };
618
+ case "unvote":
619
+ return { op: "remove_feedback_vote", input: { id: needId() } };
620
+ case "comment": {
621
+ const body = rest.slice(1).join(" ").trim() || str(flags.body);
622
+ if (!body) throw new Error('what do you want to add? glossa feedback comment <id> "Same for us…"');
623
+ return { op: "comment_on_feedback", input: { id: needId(), body } };
624
+ }
625
+ default:
626
+ throw new Error(`unknown feedback command "${sub}".\n${FEEDBACK_USAGE}`);
627
+ }
628
+ }
629
+
630
+ function itemLine(item) {
631
+ const flags = [item.kind, item.status.replace(/_/g, " "), item.mine ? "yours" : null, item.voted ? "voted" : null].filter(Boolean).join(" · ");
632
+ return `${bold(String(item.votes).padStart(4))} ${dim(`#${item.id}`.padEnd(6))} ${item.title} ${dim(flags)}`;
633
+ }
634
+
635
+ async function feedback(positional, flags) {
636
+ let request;
637
+ try {
638
+ request = feedbackCall(positional, flags, lastFailure());
639
+ } catch (error) {
640
+ fail(error.message);
641
+ }
642
+ const result = await call(request.op, request.input, { anonymous: request.anonymous });
643
+ if (flags.json || !isTTY) return print(result, flags);
644
+ switch (request.op) {
645
+ case "list_feedback":
646
+ console.log(bold(`${result.total} items`));
647
+ for (const item of result.items) console.log(itemLine(item));
648
+ if (result.next_cursor) console.log(dim(`… more: --offset ${result.next_cursor}`));
649
+ return console.log(dim("\nUpvote: glossa feedback upvote <id> · new: glossa feedback request \"…\""));
650
+ case "get_feedback":
651
+ console.log(itemLine(result));
652
+ if (result.body) console.log(`\n${result.body}`);
653
+ for (const event of result.timeline.filter((e) => e.kind === "status")) console.log(dim(` ${event.occurred_at.slice(0, 10)} ${event.from ?? ""} → ${event.to}${event.note ? `: ${event.note}` : ""}`));
654
+ for (const c of result.comment_list) console.log(`\n${c.official ? green(c.author) : bold(c.author)} ${dim(c.created_at.slice(0, 10))}\n${c.body}`);
655
+ return console.log(dim(`\n${result.url}`));
656
+ case "list_my_feedback":
657
+ console.log(bold(`Filed by your workspace (${result.items.length})`));
658
+ for (const item of result.items) console.log(itemLine(item));
659
+ console.log(bold(`\nFollowing (${result.subscriptions.length})`));
660
+ for (const item of result.subscriptions) console.log(`${itemLine(item)}${item.muted ? dim(" · muted") : ""}`);
661
+ if (result.review) console.log(bold(`\nYour review: ${"★".repeat(result.review.rating)}`) + dim(` ${result.review.status}`));
662
+ return console.log(dim(`\nFeedback email: ${result.email.subscribed ? "on" : "off"}${result.email.address ? ` (${result.email.address})` : ""}`));
663
+ case "request_feature":
664
+ case "report_issue":
665
+ if (!result.filed && !result.deduplicated) {
666
+ console.log(`${bold("Not filed:")} ${result.note}`);
667
+ for (const s of result.similar) console.log(` ${dim(`#${s.id}`)} ${s.title} ${dim(`${s.votes} votes · ${s.status}`)}`);
668
+ return console.log(dim("\nglossa feedback upvote <id>, or add --force to file anyway."));
669
+ }
670
+ return console.log(`${green("✓")} ${result.note}\n${dim(result.url)}`);
671
+ default:
672
+ return console.log(`${green("✓")} ${result.note ?? itemLine(result.item ?? result)}`);
673
+ }
674
+ }
675
+
676
+ async function review(flags) {
677
+ if (flags.rating === undefined) {
678
+ const mine = await call("list_my_feedback");
679
+ if (flags.json || !isTTY) return print(mine.review, flags);
680
+ if (!mine.review) return console.log(`No review yet.\n${dim(FEEDBACK_USAGE.split("\n").slice(-2)[0])}`);
681
+ return console.log(`${"★".repeat(mine.review.rating)} ${bold(mine.review.title || "Your review")} ${dim(mine.review.status)}\n${mine.review.body}`);
682
+ }
683
+ let input;
684
+ try {
685
+ input = reviewInput(flags);
686
+ } catch (error) {
687
+ fail(error.message);
688
+ }
689
+ const result = await call("submit_review", input);
690
+ if (flags.json || !isTTY) return print(result, flags);
691
+ console.log(`${green("✓")} ${result.note}`);
692
+ }
693
+
694
+ async function reviews(flags) {
695
+ const result = await call("list_reviews", compact({ sort: str(flags.sort), rating: flags.rating ? Number(flags.rating) : undefined, limit: flags.limit ? Number(flags.limit) : undefined }), { anonymous: true });
696
+ if (flags.json || !isTTY) return print(result, flags);
697
+ const { average, count } = result.aggregate;
698
+ console.log(bold(`${average ?? "–"} / 5 from ${count} reviews`));
699
+ for (const r of result.reviews) {
700
+ console.log(`\n${"★".repeat(r.rating)}${"☆".repeat(5 - r.rating)} ${bold(r.title || "")} ${dim(`${r.author}${r.company ? `, ${r.company}` : ""} · ${r.created_at.slice(0, 10)}`)}`);
701
+ if (r.body) console.log(r.body);
702
+ if (r.response) console.log(dim(` ↳ ${r.response.author}: ${r.response.body}`));
703
+ }
704
+ }
705
+
471
706
  async function help() {
472
707
  console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
473
708
 
@@ -484,11 +719,20 @@ ${bold("Usage")}
484
719
  [--status reviewed|needs_review] [--overwrite missing|machine|all]
485
720
  glossa tasks [--kind k] [--limit n] the review queue
486
721
  glossa mcp stdio MCP bridge to the hosted server
487
- glossa pricing plans and prices (no key needed)
722
+ glossa pricing [--json] plans, prices and limits (no key needed)
723
+ glossa account plan, usage against every limit, next plan
488
724
  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)
725
+ glossa billing portal [--open] invoices, card, plan change, cancel (Stripe)
726
+ glossa upgrade --plan pro|team [--yearly] [--open]
727
+ Stripe Checkout link for a human, also before
728
+ signing in (\`glossa login\` attaches it after);
729
+ --confirm charges the saved card (once approved)
730
+ glossa feedback list|show|mine|request|report|upvote|comment
731
+ feature requests and issue reports, upvotes,
732
+ comments; report --last attaches the last
733
+ failed call (glossa feedback --help)
734
+ glossa review --rating 1-5 [--body …] post or edit your workspace's review
735
+ glossa reviews read reviews (no key needed)
492
736
  glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
493
737
  glossa ops list every op
494
738
 
@@ -530,10 +774,19 @@ async function main() {
530
774
  case "pricing":
531
775
  return pricing(flags);
532
776
  case "billing":
533
- return billing(flags);
777
+ return billing(positional, flags);
778
+ case "account":
779
+ return account(flags);
534
780
  case "subscribe":
535
781
  case "upgrade":
536
782
  return subscribe(positional, flags);
783
+ case "feedback":
784
+ if (flags.help) return console.log(FEEDBACK_USAGE);
785
+ return feedback(positional, flags);
786
+ case "review":
787
+ return review(flags);
788
+ case "reviews":
789
+ return reviews(flags);
537
790
  case "ops": {
538
791
  const ops = await manifest();
539
792
  if (flags.json || !isTTY) return print(ops, flags);
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, readJsonArg, fillBatches, subscribeInput } from "./glossa.mjs";
2
+ import { coerceFlags, deviceLogin, feedbackCall, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, reportInput, reviewInput, 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", () => {
@@ -100,3 +101,49 @@ describe("file bodies", () => {
100
101
  expect(fileText({ content: "x" })).toBe("x");
101
102
  });
102
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
+ });
113
+
114
+ describe("feedback", () => {
115
+ it("maps every feedback subcommand to its op", () => {
116
+ expect(feedbackCall([], {})).toEqual({ op: "list_feedback", anonymous: true, input: {} });
117
+ expect(feedbackCall(["list"], { kind: "issue", sort: "new", limit: "5" })).toEqual({ op: "list_feedback", anonymous: true, input: { kind: "issue", sort: "new", limit: 5 } });
118
+ expect(feedbackCall(["show", "42"], {})).toEqual({ op: "get_feedback", anonymous: true, input: { id: 42 } });
119
+ expect(feedbackCall(["mine"], {})).toEqual({ op: "list_my_feedback", input: {} });
120
+ expect(feedbackCall(["request", "Open", "a", "PR"], { body: "for CI", force: true })).toEqual({ op: "request_feature", input: { title: "Open a PR", details: "for CI", force: true } });
121
+ expect(feedbackCall(["upvote", "7"], {})).toEqual({ op: "upvote_feedback", input: { id: 7 } });
122
+ expect(feedbackCall(["upvote", "7"], { remove: true })).toEqual({ op: "remove_feedback_vote", input: { id: 7 } });
123
+ expect(feedbackCall(["comment", "7", "Same", "here"], {})).toEqual({ op: "comment_on_feedback", input: { id: 7, body: "Same here" } });
124
+ });
125
+
126
+ it("rejects missing ids, titles and unknown subcommands", () => {
127
+ expect(() => feedbackCall(["show"], {})).toThrow(/which item/);
128
+ expect(() => feedbackCall(["request"], {})).toThrow(/say what you want/);
129
+ expect(() => feedbackCall(["comment", "7"], {})).toThrow(/what do you want to add/);
130
+ expect(() => feedbackCall(["nope"], {})).toThrow(/unknown feedback command/);
131
+ });
132
+
133
+ it("report --last attaches the last failed call; flags add context", () => {
134
+ const last = { request_id: "req_9", op: "push_file", status: 500, error_code: "internal_error", error: "Something went wrong", at: "2026-10-17T00:00:00Z" };
135
+ const input = reportInput("push_file 500s", { last: true, project: "ios", locale: "de" }, last);
136
+ expect(input).toMatchObject({ title: "push_file 500s", context: { request_id: "req_9", op: "push_file", status: 500, error_code: "internal_error", error: "Something went wrong", project: "ios", locale: "de" } });
137
+ expect(input.context.client).toMatch(/^glossa-cli\//);
138
+ expect(reportInput("Docs typo", { "request-id": "req_1" }).context).toMatchObject({ request_id: "req_1" });
139
+ expect(() => reportInput("x y z", { last: true }, null)).toThrow(/no failed call/);
140
+ // `report --last "title"` parses the title as --last's value.
141
+ expect(feedbackCall(["report"], { last: "push_file 500s" }, last).input).toMatchObject({ title: "push_file 500s", context: { request_id: "req_9" } });
142
+ });
143
+
144
+ it("builds a review from flags", () => {
145
+ expect(reviewInput({ rating: "5", body: "Great", "use-case": "iOS app", consent: true, name: "Ann" })).toEqual({ rating: 5, body: "Great", use_case: "iOS app", display_name: "Ann", marketing_consent: true });
146
+ expect(() => reviewInput({ rating: "6" })).toThrow(/rate 1 to 5/);
147
+ expect(() => reviewInput({})).toThrow(/rate 1 to 5/);
148
+ });
149
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meterapp/glossa",
3
- "version": "0.3.0",
3
+ "version": "0.5.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" },