@meterapp/glossa 0.4.0 → 0.6.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
@@ -39,3 +39,12 @@ open the printed URL on any device (`--no-browser` skips opening one). To save
39
39
  an existing key instead, run `login --key gls_…`.
40
40
 
41
41
  In CI set `GLOSSA_API_KEY` (a workspace key, `gls_…`).
42
+
43
+ ### Cancel a background job
44
+
45
+ `glossa cancel <job-id>` cancels queued or running work. `glossa cancel_job --job <job-id>`
46
+ is the equivalent registry op. Completed translations remain in place, and repeated
47
+ cancellation is safe. An in-flight model request may finish; translation workers
48
+ check cancellation before storing its results or beginning the next batch.
49
+ Use `glossa get_job --job <job-id>` to inspect progress and final status, or
50
+ `glossa list_jobs --status cancelled` to list cancelled jobs.
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.4.0";
12
+ const VERSION = "0.6.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
@@ -73,11 +75,24 @@ async function call(op, input = {}, { method = "POST", anonymous = false } = {})
73
75
  const error = new Error(body.detail || `${op} failed with HTTP ${response.status}`);
74
76
  error.status = response.status;
75
77
  error.body = body;
78
+ if (op !== "report_issue") rememberFailure(op, response.status, body, response.headers.get("x-request-id"));
76
79
  throw error;
77
80
  }
78
81
  return body;
79
82
  }
80
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
+
81
96
  async function manifest() {
82
97
  const response = await fetch(`${host()}/api/v1`, { headers: { "User-Agent": `glossa-cli/${VERSION}` } });
83
98
  if (!response.ok) fail(`could not read the op catalog from ${host()}/api/v1 (HTTP ${response.status}).`);
@@ -480,6 +495,14 @@ async function fill(positional, flags) {
480
495
  if (totals.rejected.length) process.exitCode = 1;
481
496
  }
482
497
 
498
+ export function cancelJobInput(positional, flags) {
499
+ const job = positional[0] ?? flags.job;
500
+ if (positional.length > 1 || typeof job !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(job)) {
501
+ throw new Error("Usage: glossa cancel <job-id> (or --job <job-id>)");
502
+ }
503
+ return { job };
504
+ }
505
+
483
506
  async function tasks(flags) {
484
507
  const { tasks: list, total } = await call("list_tasks", coerceFlags(flags, { properties: { limit: { type: "integer" }, kind: { type: "string" }, status: { type: "string" } } }));
485
508
  if (flags.json || !isTTY) return print({ total, tasks: list }, flags);
@@ -521,6 +544,173 @@ async function mcp() {
521
544
  });
522
545
  }
523
546
 
547
+ // ---------------------------------------------------------------- feedback
548
+ const FEEDBACK_USAGE = `glossa feedback list [--kind feature|issue] [--status active|all|done…] [--sort top|new|recent_activity] [--q text]
549
+ glossa feedback show <id>
550
+ glossa feedback mine
551
+ glossa feedback request "<title>" [--body text] [--force]
552
+ glossa feedback report "<title>" [--body text] [--request-id id] [--op op] [--project p --key k --locale l --file f] [--last] [--force]
553
+ glossa feedback upvote <id> [--remove]
554
+ glossa feedback comment <id> "<text>"
555
+ glossa review --rating 1-5 [--title t] [--body b] [--likes l] [--dislikes d] [--use-case u] [--name n] [--company c] [--consent]
556
+ glossa reviews [--sort newest|helpful|highest|lowest]`;
557
+
558
+ const str = (value) => (value === undefined || value === true ? undefined : String(value));
559
+ const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== ""));
560
+
561
+ /** `report` input: the title, body, flags as context, and with --last the last failed call. */
562
+ export function reportInput(title, flags, last = null) {
563
+ if (!title) throw new Error('give the problem a title: glossa feedback report "push_file drops plural forms" --last');
564
+ if (flags.last && !last) throw new Error("no failed call recorded yet; pass --request-id from the error instead.");
565
+ 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 }) : {};
566
+ // Flags win over the recorded failure.
567
+ const context = {
568
+ ...fromLast,
569
+ ...compact({
570
+ request_id: str(flags["request-id"] ?? flags.request_id),
571
+ op: str(flags.op),
572
+ project: str(flags.project),
573
+ key: str(flags.key),
574
+ locale: str(flags.locale),
575
+ file: str(flags.file),
576
+ client: `glossa-cli/${VERSION}`,
577
+ }),
578
+ };
579
+ return compact({ title, details: str(flags.body ?? flags.details), context, force: flags.force ? true : undefined });
580
+ }
581
+
582
+ /** `review` flags as submit_review input. */
583
+ export function reviewInput(flags) {
584
+ const rating = Number(flags.rating);
585
+ if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("rate 1 to 5: glossa review --rating 5 --body \"…\"");
586
+ return compact({
587
+ rating,
588
+ title: str(flags.title),
589
+ body: str(flags.body),
590
+ likes: str(flags.likes),
591
+ dislikes: str(flags.dislikes),
592
+ use_case: str(flags["use-case"] ?? flags.use_case),
593
+ display_name: str(flags.name),
594
+ display_company: str(flags.company),
595
+ marketing_consent: flags.consent ? true : undefined,
596
+ });
597
+ }
598
+
599
+ /** `feedback <sub> …` as the op to call and its input. */
600
+ export function feedbackCall(positional, flags, last = null) {
601
+ const [sub, ...rest] = positional;
602
+ const id = rest[0];
603
+ const needId = () => {
604
+ if (!/^\d+$/.test(String(id ?? ""))) throw new Error(`which item? glossa feedback ${sub} <id>`);
605
+ return Number(id);
606
+ };
607
+ switch (sub) {
608
+ case undefined:
609
+ case "list":
610
+ 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 }) };
611
+ case "show":
612
+ return { op: "get_feedback", anonymous: true, input: { id: needId() } };
613
+ case "mine":
614
+ return { op: "list_my_feedback", input: {} };
615
+ case "request":
616
+ if (!rest.join(" ").trim()) throw new Error('say what you want: glossa feedback request "Open a GitHub PR on pull"');
617
+ return { op: "request_feature", input: compact({ title: rest.join(" ").trim(), details: str(flags.body ?? flags.details), force: flags.force ? true : undefined }) };
618
+ case "report": {
619
+ // `report --last "title"` parses the title as --last's value.
620
+ const title = rest.join(" ").trim() || (typeof flags.last === "string" ? flags.last : "");
621
+ return { op: "report_issue", input: reportInput(title, flags, last) };
622
+ }
623
+ case "upvote":
624
+ case "vote":
625
+ return { op: flags.remove ? "remove_feedback_vote" : "upvote_feedback", input: { id: needId() } };
626
+ case "unvote":
627
+ return { op: "remove_feedback_vote", input: { id: needId() } };
628
+ case "comment": {
629
+ const body = rest.slice(1).join(" ").trim() || str(flags.body);
630
+ if (!body) throw new Error('what do you want to add? glossa feedback comment <id> "Same for us…"');
631
+ return { op: "comment_on_feedback", input: { id: needId(), body } };
632
+ }
633
+ default:
634
+ throw new Error(`unknown feedback command "${sub}".\n${FEEDBACK_USAGE}`);
635
+ }
636
+ }
637
+
638
+ function itemLine(item) {
639
+ const flags = [item.kind, item.status.replace(/_/g, " "), item.mine ? "yours" : null, item.voted ? "voted" : null].filter(Boolean).join(" · ");
640
+ return `${bold(String(item.votes).padStart(4))} ${dim(`#${item.id}`.padEnd(6))} ${item.title} ${dim(flags)}`;
641
+ }
642
+
643
+ async function feedback(positional, flags) {
644
+ let request;
645
+ try {
646
+ request = feedbackCall(positional, flags, lastFailure());
647
+ } catch (error) {
648
+ fail(error.message);
649
+ }
650
+ const result = await call(request.op, request.input, { anonymous: request.anonymous });
651
+ if (flags.json || !isTTY) return print(result, flags);
652
+ switch (request.op) {
653
+ case "list_feedback":
654
+ console.log(bold(`${result.total} items`));
655
+ for (const item of result.items) console.log(itemLine(item));
656
+ if (result.next_cursor) console.log(dim(`… more: --offset ${result.next_cursor}`));
657
+ return console.log(dim("\nUpvote: glossa feedback upvote <id> · new: glossa feedback request \"…\""));
658
+ case "get_feedback":
659
+ console.log(itemLine(result));
660
+ if (result.body) console.log(`\n${result.body}`);
661
+ 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}` : ""}`));
662
+ 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}`);
663
+ return console.log(dim(`\n${result.url}`));
664
+ case "list_my_feedback":
665
+ console.log(bold(`Filed by your workspace (${result.items.length})`));
666
+ for (const item of result.items) console.log(itemLine(item));
667
+ console.log(bold(`\nFollowing (${result.subscriptions.length})`));
668
+ for (const item of result.subscriptions) console.log(`${itemLine(item)}${item.muted ? dim(" · muted") : ""}`);
669
+ if (result.review) console.log(bold(`\nYour review: ${"★".repeat(result.review.rating)}`) + dim(` ${result.review.status}`));
670
+ return console.log(dim(`\nFeedback email: ${result.email.subscribed ? "on" : "off"}${result.email.address ? ` (${result.email.address})` : ""}`));
671
+ case "request_feature":
672
+ case "report_issue":
673
+ if (!result.filed && !result.deduplicated) {
674
+ console.log(`${bold("Not filed:")} ${result.note}`);
675
+ for (const s of result.similar) console.log(` ${dim(`#${s.id}`)} ${s.title} ${dim(`${s.votes} votes · ${s.status}`)}`);
676
+ return console.log(dim("\nglossa feedback upvote <id>, or add --force to file anyway."));
677
+ }
678
+ return console.log(`${green("✓")} ${result.note}\n${dim(result.url)}`);
679
+ default:
680
+ return console.log(`${green("✓")} ${result.note ?? itemLine(result.item ?? result)}`);
681
+ }
682
+ }
683
+
684
+ async function review(flags) {
685
+ if (flags.rating === undefined) {
686
+ const mine = await call("list_my_feedback");
687
+ if (flags.json || !isTTY) return print(mine.review, flags);
688
+ if (!mine.review) return console.log(`No review yet.\n${dim(FEEDBACK_USAGE.split("\n").slice(-2)[0])}`);
689
+ return console.log(`${"★".repeat(mine.review.rating)} ${bold(mine.review.title || "Your review")} ${dim(mine.review.status)}\n${mine.review.body}`);
690
+ }
691
+ let input;
692
+ try {
693
+ input = reviewInput(flags);
694
+ } catch (error) {
695
+ fail(error.message);
696
+ }
697
+ const result = await call("submit_review", input);
698
+ if (flags.json || !isTTY) return print(result, flags);
699
+ console.log(`${green("✓")} ${result.note}`);
700
+ }
701
+
702
+ async function reviews(flags) {
703
+ 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 });
704
+ if (flags.json || !isTTY) return print(result, flags);
705
+ const { average, count } = result.aggregate;
706
+ console.log(bold(`${average ?? "–"} / 5 from ${count} reviews`));
707
+ for (const r of result.reviews) {
708
+ 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)}`)}`);
709
+ if (r.body) console.log(r.body);
710
+ if (r.response) console.log(dim(` ↳ ${r.response.author}: ${r.response.body}`));
711
+ }
712
+ }
713
+
524
714
  async function help() {
525
715
  console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
526
716
 
@@ -535,6 +725,7 @@ ${bold("Usage")}
535
725
  their keys and file paths, for an agent
536
726
  glossa fill <file.json|-> write translations: [{"unit","locale","text"}]
537
727
  [--status reviewed|needs_review] [--overwrite missing|machine|all]
728
+ glossa cancel <job-id> cancel queued or running work; keeps completed translations
538
729
  glossa tasks [--kind k] [--limit n] the review queue
539
730
  glossa mcp stdio MCP bridge to the hosted server
540
731
  glossa pricing [--json] plans, prices and limits (no key needed)
@@ -545,6 +736,12 @@ ${bold("Usage")}
545
736
  Stripe Checkout link for a human, also before
546
737
  signing in (\`glossa login\` attaches it after);
547
738
  --confirm charges the saved card (once approved)
739
+ glossa feedback list|show|mine|request|report|upvote|comment
740
+ feature requests and issue reports, upvotes,
741
+ comments; report --last attaches the last
742
+ failed call (glossa feedback --help)
743
+ glossa review --rating 1-5 [--body …] post or edit your workspace's review
744
+ glossa reviews read reviews (no key needed)
548
745
  glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
549
746
  glossa ops list every op
550
747
 
@@ -579,6 +776,8 @@ async function main() {
579
776
  return missing(flags);
580
777
  case "fill":
581
778
  return fill(positional, flags);
779
+ case "cancel":
780
+ return print(await call("cancel_job", cancelJobInput(positional, flags)), flags);
582
781
  case "tasks":
583
782
  return tasks(flags);
584
783
  case "mcp":
@@ -592,6 +791,13 @@ async function main() {
592
791
  case "subscribe":
593
792
  case "upgrade":
594
793
  return subscribe(positional, flags);
794
+ case "feedback":
795
+ if (flags.help) return console.log(FEEDBACK_USAGE);
796
+ return feedback(positional, flags);
797
+ case "review":
798
+ return review(flags);
799
+ case "reviews":
800
+ return reviews(flags);
595
801
  case "ops": {
596
802
  const ops = await manifest();
597
803
  if (flags.json || !isTTY) return print(ops, flags);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, subscribeInput } from "./glossa.mjs";
2
+ import { cancelJobInput, coerceFlags, deviceLogin, feedbackCall, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, reportInput, reviewInput, subscribeInput } from "./glossa.mjs";
3
3
  import { pricingDocument } from "../../../lib/billing/pricing";
4
4
 
5
5
  describe("cli args", () => {
@@ -110,3 +110,52 @@ describe("pricing", () => {
110
110
  expect(lines[3].text).toMatch(/^custom/);
111
111
  });
112
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
+ });
150
+
151
+
152
+ describe("cancel", () => {
153
+ const job = "bcae2e7c-cbc6-466b-8d97-c21ac165d099";
154
+ it("accepts one job id as a positional or flag", () => {
155
+ expect(cancelJobInput([job], {})).toEqual({ job });
156
+ expect(cancelJobInput([], { job })).toEqual({ job });
157
+ });
158
+ it("rejects missing, malformed or extra ids", () => {
159
+ for (const ids of [[], ["invalid"], [job, job]]) expect(() => cancelJobInput(ids, {})).toThrow(/Usage/);
160
+ });
161
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meterapp/glossa",
3
- "version": "0.4.0",
3
+ "version": "0.6.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" },