altalandi 0.6.0 → 0.6.1

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/altalandi.mjs CHANGED
@@ -1,325 +1,330 @@
1
- #!/usr/bin/env node
2
- // altalandi — turn English or stiff/AI Icelandic into native Icelandic from
3
- // your terminal. Zero dependencies (Node >= 18 has global fetch).
4
- //
5
- // npx altalandi login # once: connects this computer to your account
6
- // altalandi "Make sure to reach out to us today."
7
- // echo "some text" | altalandi --domain marketing
8
- // altalandi ./docs # a whole folder → ./docs-is (batch.mjs)
9
- // altalandi https://example.com --site
10
- //
11
- // stdout = the refined text (so you can pipe it); stderr = nativeness score,
12
- // meaning verdict, API warnings, and the diff.
13
-
14
- import { existsSync, statSync } from "node:fs";
15
-
16
- import { refine } from "./client.mjs";
17
- import { beforeAfterText, costLines, outcomeOf, reportEnabled, reviewMarkdown, writeReport } from "./report.mjs";
18
- import { readCredentials, resolveKey, runLogin, runLogout, runWhoami } from "./auth.mjs";
19
-
20
- const args = process.argv.slice(2);
21
-
22
- // Subcommands and meta flags are handled before anything that needs an API key
23
- // or input text. `altalandi update` checks for a newer CLI and skill and asks
24
- // before changing anything.
25
- let handled = false;
26
- if (args[0] === "update") {
27
- handled = true;
28
- const { runUpdate } = await import("./update.mjs");
29
- const rest = args.slice(1);
30
- if (rest.includes("-h") || rest.includes("--help")) { updateHelp(); process.exit(0); }
31
- // exitCode, never process.exit(): the registry request can leave a socket
32
- // mid-close, and exiting then aborts Node on Windows with a libuv assertion
33
- // (UV_HANDLE_CLOSING). The refine path below exits the same way for the
34
- // same reason.
35
- process.exitCode = await runUpdate({
36
- check: rest.includes("--check"),
37
- yes: rest.includes("--yes") || rest.includes("-y"),
38
- skillOnly: rest.includes("--skill-only"),
39
- });
40
- }
41
- if (!handled && ["login", "setup", "logout", "whoami"].includes(args[0])) {
42
- handled = true;
43
- const rest = args.slice(1);
44
- const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
45
- if (args[0] === "login" || args[0] === "setup") {
46
- process.exitCode = await runLogin({
47
- noBrowser: rest.includes("--no-browser"),
48
- noSkill: rest.includes("--no-skill"),
49
- baseUrl: flag("--base-url"),
50
- });
51
- } else if (args[0] === "logout") {
52
- process.exitCode = await runLogout();
53
- } else {
54
- process.exitCode = await runWhoami({ json: rest.includes("--json"), baseUrl: flag("--base-url") });
55
- }
56
- }
57
- if (!handled && (args.includes("-v") || args.includes("--version"))) {
58
- const { currentVersion } = await import("./update.mjs");
59
- console.log(await currentVersion());
60
- process.exit(0);
61
- }
62
-
63
- function updateHelp() {
64
- console.log(`altalandi update — check for a newer version and update if you agree
65
-
66
- Usage:
67
- altalandi update check, then ask before updating the CLI and the /altalandi skill
68
- altalandi update --check only report; never change anything
69
- altalandi update --yes update without asking (for scripts that already have consent)
70
-
71
- Nothing changes without consent. Without a terminal to ask in (agents, CI,
72
- pipes) and without --yes, it only reports what is available.
73
-
74
- Exit codes: 0 up to date or updated · 10 an update is available but was not
75
- applied · 1 the check or the update failed.`);
76
- }
77
-
78
- if (!handled) {
79
- const opts = {
80
- domain: "general", register: "neutral", json: false, fast: false, diff: false, review: false, report: undefined,
81
- site: false, dryRun: false, yes: false,
82
- };
83
- const positional = [];
84
- for (let i = 0; i < args.length; i++) {
85
- const a = args[i];
86
- if (a === "-h" || a === "--help") { help(); process.exit(0); }
87
- else if (a === "--json") opts.json = true;
88
- else if (a === "--fast") opts.fast = true;
89
- else if (a === "--domain") opts.domain = args[++i];
90
- else if (a === "--register") opts.register = args[++i];
91
- else if (a === "--base-url") opts.baseUrl = args[++i];
92
- else if (a === "--key") opts.key = args[++i];
93
- else if (a === "--file") opts.file = args[++i];
94
- else if (a === "--diff") opts.diff = true;
95
- else if (a === "--review") opts.review = true;
96
- else if (a === "--out") opts.out = args[++i];
97
- else if (a === "--no-store") opts.noStore = true;
98
- else if (a === "--report") opts.report = true;
99
- else if (a === "--report-dir") { opts.report = true; opts.reportDir = args[++i]; }
100
- else if (a === "--site") opts.site = true;
101
- else if (a === "--max-pages") opts.maxPages = Number(args[++i]);
102
- else if (a === "--dry-run") opts.dryRun = true;
103
- else if (a === "--yes" || a === "-y") opts.yes = true;
104
- else if (a === "--max-chars") opts.maxChars = Number(args[++i]);
105
- else if (a === "--concurrency") opts.concurrency = Number(args[++i]);
106
- else positional.push(a);
107
- }
108
-
109
- function help() {
110
- console.log(`altalandi — native Icelandic from your terminal
111
-
112
- Usage:
113
- altalandi login connect this computer to your account (once)
114
- altalandi "text to refine"
115
- altalandi --file draft.txt read the text from a file
116
- altalandi --review --file in.txt --out out.txt
117
- for agents: one block to show as it is
118
- echo "text" | altalandi
119
- altalandi --domain marketing --register warm "text"
120
-
121
- Folders and websites:
122
- altalandi ./docs every .md .txt .html .json under ./docs → ./docs-is
123
- altalandi ./locales --out ./locales-is
124
- altalandi https://example.com/about --out about-is.md one page, as Markdown
125
- altalandi https://example.com --site a site: /sitemap.xml, else its links
126
- altalandi ./docs --dry-run list what would be sent and the estimate; send nothing
127
-
128
- Commands:
129
- altalandi login open altalandi.is, sign in or sign up, approve; stores the key
130
- and installs the Claude Code skill (--no-skill, --no-browser)
131
- altalandi whoami show the account and balance behind the stored key
132
- altalandi logout forget the stored key on this computer
133
- altalandi update check for a newer version and update if you agree
134
- altalandi --version print the installed version
135
-
136
- Options:
137
- --domain general | marketing | data-analysis | childrens-fiction (default general)
138
- --register formal | neutral | warm (default neutral)
139
- --fast no polish, no nativeness score, no meaning gate — normally one writer call
140
- (structured input can still add a structure-preserving retry)
141
- --json print the full JSON response
142
- --diff show the text before and after, and every change with its reason (stderr)
143
- --review print one Markdown block on stdout to show the user as it is: verdict,
144
- before, after, every change with its reason, the cost and the balance
145
- --out write the refined text to this UTF-8 file (use with --review;
146
- for a folder or a URL, see below)
147
- --no-store don't keep this text: free use is otherwise kept, scrubbed of personal
148
- data, to improve the service (paid use is kept only with consent)
149
- --report also write a Markdown report (input, output, changes, reasons, verdict)
150
- to ./altalandi-reports/ (or --report-dir <dir>; ALTALANDI_REPORT=1 turns it on)
151
- --file read the text from a UTF-8 file instead of an argument
152
- --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
153
-
154
- stdout = refined text; stderr = nativeness / meaning / warnings / diff.
155
- Without --fast, false/null meaning verification exits 3 after printing the response.
156
-
157
- Folder and website options:
158
- --out output folder (default <folder>-is; never the input or inside it),
159
- or the .md file for one page
160
- --dry-run show every passage and the estimate; send nothing, write nothing
161
- --yes, -y send without asking (needed when there is no terminal to ask in)
162
- --max-chars send at most this many characters; files past the budget are left out
163
- --concurrency calls at once, 1-4 (default 2)
164
- --site with a URL: its pages on the same site, under the URL's folder
165
- --max-pages pages to read with --site (default 20)
166
- --json print a machine-readable summary at the end
167
- Code, URLs, link targets, placeholders ({name}, %s, ICU), HTML tags and JSON keys
168
- stay byte for byte. Only changes whose meaning check passed are written; other
169
- passages keep the original text and are listed in the report
170
- (./altalandi-reports/). Exit: 0 done · 2 some passages kept their original ·
171
- 4 out of credit · 1 error.`);
172
- }
173
-
174
- function readStdin() {
175
- return new Promise((resolve) => {
176
- if (process.stdin.isTTY) return resolve("");
177
- let d = "";
178
- process.stdin.setEncoding("utf8");
179
- process.stdin.on("data", (c) => (d += c));
180
- process.stdin.on("end", () => resolve(d));
181
- });
182
- }
183
-
184
- const stored = readCredentials();
185
- const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || stored?.baseUrl || "https://altalandi.is/api").replace(/\/$/, "");
186
- const { key } = resolveKey({ flag: opts.key });
187
-
188
- if (opts.key) {
189
- console.error("⚠ --key is deprecated because command arguments can leak through shell history/process listings; load ALTALANDI_API_KEY instead.");
190
- }
191
-
192
- // A folder or a URL as the one argument: many documents in one run (batch.mjs).
193
- const target = positional.length === 1 && !opts.file ? positional[0] : "";
194
- const many = /^https?:\/\//i.test(target) || (Boolean(target) && existsSync(target) && statSync(target).isDirectory());
195
- if (many) {
196
- const { runBatch } = await import("./batch.mjs");
197
- const { currentVersion } = await import("./update.mjs");
198
- process.exitCode = await runBatch({ ...opts, target, key, baseUrl }, { version: await currentVersion() });
199
- } else {
200
- if (opts.site || opts.dryRun || opts.maxChars !== undefined || opts.maxPages !== undefined || opts.concurrency !== undefined) {
201
- console.error("--site, --dry-run, --max-chars, --max-pages and --concurrency need a folder or a URL: altalandi ./docs --dry-run");
202
- process.exit(2);
203
- }
204
-
205
- let text = positional.join(" ").trim();
206
- if (opts.file) {
207
- const { readFile } = await import("node:fs/promises");
208
- try { text = (await readFile(opts.file, "utf8")).replace(/^/, "").trim(); }
209
- catch (e) { console.error(`Could not read ${opts.file}: ${e.message}`); process.exit(2); }
210
- }
211
- if (!text) text = (await readStdin()).trim();
212
- if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
213
- if (!key) { console.error("Not logged in. Run once: npx altalandi login (or set ALTALANDI_API_KEY)"); process.exit(2); }
214
-
215
- const requestOptions = {
216
- apiKey: key,
217
- baseUrl,
218
- domain: opts.domain,
219
- register: opts.register,
220
- ...(opts.noStore ? { contribute: false } : {}),
221
- };
222
- // --fast disables every optional quality/verification pass. The writer can
223
- // still retry once when it drops document structure, as documented in --help.
224
- // Name every switch explicitly so a future server-default change cannot make
225
- // this client silently slower.
226
- if (opts.fast) {
227
- requestOptions.preserveMeaning = false;
228
- requestOptions.scoreNativeness = false;
229
- requestOptions.onlyImprove = false;
230
- requestOptions.polishPass = false;
231
- requestOptions.scoring = "none";
232
- }
233
-
234
- // /v1/me before and after the call gives the cost and the balance for the
235
- // review block. Best-effort: a failed lookup only blanks those lines.
236
- async function me() {
237
- try {
238
- const res = await fetch(`${baseUrl}/v1/me`, {
239
- headers: { authorization: `Bearer ${key}`, accept: "application/json" },
240
- signal: AbortSignal.timeout(15_000),
241
- });
242
- return res.ok ? await res.json() : null;
243
- } catch { return null; }
244
- }
245
- const meBefore = opts.review ? await me() : null;
246
-
247
- try {
248
- // refine() owns a single 300-second AbortController deadline across fetch
249
- // and response-body parsing, matching the hosted package API.
250
- const data = await refine(text, requestOptions);
251
- // --review exits on the outcome the block states: a vetoed rewrite returns
252
- // the original with meaningPreserved true, and must not read as a success.
253
- const verificationFailed = opts.review
254
- ? ["refused", "unverified"].includes(outcomeOf(data)) && !(opts.fast && outcomeOf(data) === "unverified")
255
- : !opts.fast && data.meaningPreserved !== true;
256
-
257
- if (opts.out) {
258
- const { writeFile } = await import("node:fs/promises");
259
- try { await writeFile(opts.out, String(data.output ?? "") + "\n", "utf8"); }
260
- catch (e) { console.error(`Could not write ${opts.out}: ${e.message}`); process.exitCode = 1; }
261
- }
262
-
263
- if (opts.review) {
264
- const meAfter = await me();
265
- // Without --out the block is the only copy of a paid result: never shorten it.
266
- process.stdout.write(reviewMarkdown(text, data, {
267
- before: meBefore, after: meAfter, fullTextAt: opts.out || "", maxChars: opts.out ? 1500 : Infinity,
268
- }));
269
- } else if (opts.json) {
270
- process.stdout.write(JSON.stringify(data, null, 2) + "\n");
271
- if (verificationFailed) {
272
- console.error("Meaning verification did not pass; JSON was printed, exiting with status 3.");
273
- }
274
- } else {
275
- process.stdout.write(data.output + "\n");
276
- const q = data.quality || {};
277
- const meta = [];
278
- if (q.nativenessScore != null) meta.push(`nativeness ${q.nativenessScore}/100`);
279
- if (data.meaningPreserved === false) meta.push("⚠ MEANING DRIFT — returned the original");
280
- else if (data.meaningPreserved === true) meta.push("meaning preserved");
281
- else if (opts.fast) meta.push("meaning check skipped (--fast)");
282
- else meta.push("⚠ MEANING UNVERIFIED — review before use");
283
- if (meta.length) console.error("· " + meta.join(" · "));
284
- for (const warning of Array.isArray(data.warnings) ? data.warnings : []) {
285
- console.error(`⚠ ${warning}`);
286
- }
287
- if (opts.diff || process.env.ALTALANDI_DIFF === "1") {
288
- console.error("\n" + beforeAfterText(text, data, { color: Boolean(process.stderr.isTTY) && !process.env.NO_COLOR }));
289
- } else {
290
- for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
291
- }
292
- }
293
-
294
- if (reportEnabled(opts.report)) {
295
- try {
296
- const path = await writeReport(text, data, { domain: opts.domain, register: opts.register }, opts.reportDir);
297
- console.error(`Skýrsla: ${path}`);
298
- } catch (e) {
299
- console.error(`Could not write the report: ${e.message}`);
300
- }
301
- }
302
-
303
- // stdout/JSON is intentionally written first so callers can inspect the
304
- // server response, but guarded automation must not accept null/false as a
305
- // verified success. --fast is the explicit opt-out from this contract.
306
- if (verificationFailed) process.exitCode = 3;
307
- } catch (e) {
308
- if (opts.review) {
309
- // The block still comes out, so the user sees why and what is left.
310
- const head = e.status === 402 ? "Ekki næg inneign fyrir þetta kall"
311
- : e.status === 429 ? "Of mörg köll í einu eða á mínútu; reyndu aftur eftir smá stund"
312
- : e.status === 401 ? "Lykillinn er ógildur eða afturkallaður (`npx altalandi login`)"
313
- : `Kallið mistókst: ${e.message}`;
314
- const lines = [`**Altalandi:** ${head}`, "", "Textanum var ekki breytt.", "", "**Kostnaður**",
315
- "- Þetta kall: 0 kr.", ...costLines({}, null, meBefore).slice(1)];
316
- if (e.status === 402 && !lines.some((l) => l.includes("/portal"))) lines.push("- Bæta við inneign: https://altalandi.is/portal");
317
- process.stdout.write(lines.join("\n") + "\n");
318
- }
319
- console.error("Request failed:", e.message);
320
- if (e.status === 402) console.error("→ Add credit at https://altalandi.is/portal (automatic top-up is available there).");
321
- if (e.status === 401) console.error("→ The key is invalid or revoked. Run: npx altalandi login");
322
- process.exit(e.status === 402 ? 4 : 1);
323
- }
324
- } // single text
325
- }
1
+ #!/usr/bin/env node
2
+ // altalandi — turn English or stiff/AI Icelandic into native Icelandic from
3
+ // your terminal. Zero dependencies (Node >= 18 has global fetch).
4
+ //
5
+ // npx altalandi login # once: connects this computer to your account
6
+ // altalandi "Make sure to reach out to us today."
7
+ // echo "some text" | altalandi --domain marketing
8
+ // altalandi ./docs # a whole folder → ./docs-is (batch.mjs)
9
+ // altalandi https://example.com --site
10
+ //
11
+ // stdout = the refined text (so you can pipe it); stderr = nativeness score,
12
+ // meaning verdict, API warnings, and the diff.
13
+
14
+ import { existsSync, statSync } from "node:fs";
15
+
16
+ import { refine } from "./client.mjs";
17
+ import { beforeAfterText, costLines, outcomeOf, reportEnabled, reviewMarkdown, writeReport } from "./report.mjs";
18
+ import { readCredentials, resolveKey, runLogin, runLogout, runWhoami } from "./auth.mjs";
19
+
20
+ const args = process.argv.slice(2);
21
+
22
+ // Subcommands and meta flags are handled before anything that needs an API key
23
+ // or input text. `altalandi update` checks for a newer CLI and skill and asks
24
+ // before changing anything.
25
+ let handled = false;
26
+ if (args[0] === "update") {
27
+ handled = true;
28
+ const { runUpdate } = await import("./update.mjs");
29
+ const rest = args.slice(1);
30
+ if (rest.includes("-h") || rest.includes("--help")) { updateHelp(); process.exit(0); }
31
+ // exitCode, never process.exit(): the registry request can leave a socket
32
+ // mid-close, and exiting then aborts Node on Windows with a libuv assertion
33
+ // (UV_HANDLE_CLOSING). The refine path below exits the same way for the
34
+ // same reason.
35
+ process.exitCode = await runUpdate({
36
+ check: rest.includes("--check"),
37
+ yes: rest.includes("--yes") || rest.includes("-y"),
38
+ skillOnly: rest.includes("--skill-only"),
39
+ });
40
+ }
41
+ if (!handled && ["login", "setup", "logout", "whoami"].includes(args[0])) {
42
+ handled = true;
43
+ const rest = args.slice(1);
44
+ const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
45
+ if (args[0] === "login" || args[0] === "setup") {
46
+ process.exitCode = await runLogin({
47
+ noBrowser: rest.includes("--no-browser"),
48
+ noSkill: rest.includes("--no-skill"),
49
+ baseUrl: flag("--base-url"),
50
+ });
51
+ } else if (args[0] === "logout") {
52
+ process.exitCode = await runLogout();
53
+ } else {
54
+ process.exitCode = await runWhoami({ json: rest.includes("--json"), baseUrl: flag("--base-url") });
55
+ }
56
+ }
57
+ if (!handled && (args.includes("-v") || args.includes("--version"))) {
58
+ const { currentVersion } = await import("./update.mjs");
59
+ console.log(await currentVersion());
60
+ process.exit(0);
61
+ }
62
+ // Keep an unedited /altalandi skill current (update.mjs refreshShippedSkill).
63
+ if (!handled) {
64
+ const { refreshShippedSkill } = await import("./update.mjs");
65
+ await refreshShippedSkill({ log: (m) => console.error(m) });
66
+ }
67
+
68
+ function updateHelp() {
69
+ console.log(`altalandi update — check for a newer version and update if you agree
70
+
71
+ Usage:
72
+ altalandi update check, then ask before updating the CLI and the /altalandi skill
73
+ altalandi update --check only report; never change anything
74
+ altalandi update --yes update without asking (for scripts that already have consent)
75
+
76
+ Nothing changes without consent. Without a terminal to ask in (agents, CI,
77
+ pipes) and without --yes, it only reports what is available.
78
+
79
+ Exit codes: 0 up to date or updated · 10 an update is available but was not
80
+ applied · 1 the check or the update failed.`);
81
+ }
82
+
83
+ if (!handled) {
84
+ const opts = {
85
+ domain: "general", register: "neutral", json: false, fast: false, diff: false, review: false, report: undefined,
86
+ site: false, dryRun: false, yes: false,
87
+ };
88
+ const positional = [];
89
+ for (let i = 0; i < args.length; i++) {
90
+ const a = args[i];
91
+ if (a === "-h" || a === "--help") { help(); process.exit(0); }
92
+ else if (a === "--json") opts.json = true;
93
+ else if (a === "--fast") opts.fast = true;
94
+ else if (a === "--domain") opts.domain = args[++i];
95
+ else if (a === "--register") opts.register = args[++i];
96
+ else if (a === "--base-url") opts.baseUrl = args[++i];
97
+ else if (a === "--key") opts.key = args[++i];
98
+ else if (a === "--file") opts.file = args[++i];
99
+ else if (a === "--diff") opts.diff = true;
100
+ else if (a === "--review") opts.review = true;
101
+ else if (a === "--out") opts.out = args[++i];
102
+ else if (a === "--no-store") opts.noStore = true;
103
+ else if (a === "--report") opts.report = true;
104
+ else if (a === "--report-dir") { opts.report = true; opts.reportDir = args[++i]; }
105
+ else if (a === "--site") opts.site = true;
106
+ else if (a === "--max-pages") opts.maxPages = Number(args[++i]);
107
+ else if (a === "--dry-run") opts.dryRun = true;
108
+ else if (a === "--yes" || a === "-y") opts.yes = true;
109
+ else if (a === "--max-chars") opts.maxChars = Number(args[++i]);
110
+ else if (a === "--concurrency") opts.concurrency = Number(args[++i]);
111
+ else positional.push(a);
112
+ }
113
+
114
+ function help() {
115
+ console.log(`altalandi — native Icelandic from your terminal
116
+
117
+ Usage:
118
+ altalandi login connect this computer to your account (once)
119
+ altalandi "text to refine"
120
+ altalandi --file draft.txt read the text from a file
121
+ altalandi --review --file in.txt --out out.txt
122
+ for agents: one block to show as it is
123
+ echo "text" | altalandi
124
+ altalandi --domain marketing --register warm "text"
125
+
126
+ Folders and websites:
127
+ altalandi ./docs every .md .txt .html .json under ./docs → ./docs-is
128
+ altalandi ./locales --out ./locales-is
129
+ altalandi https://example.com/about --out about-is.md one page, as Markdown
130
+ altalandi https://example.com --site a site: /sitemap.xml, else its links
131
+ altalandi ./docs --dry-run list what would be sent and the estimate; send nothing
132
+
133
+ Commands:
134
+ altalandi login open altalandi.is, sign in or sign up, approve; stores the key
135
+ and installs the Claude Code skill (--no-skill, --no-browser)
136
+ altalandi whoami show the account and balance behind the stored key
137
+ altalandi logout forget the stored key on this computer
138
+ altalandi update check for a newer version and update if you agree
139
+ altalandi --version print the installed version
140
+
141
+ Options:
142
+ --domain general | marketing | data-analysis | childrens-fiction (default general)
143
+ --register formal | neutral | warm (default neutral)
144
+ --fast no polish, no nativeness score, no meaning gate — normally one writer call
145
+ (structured input can still add a structure-preserving retry)
146
+ --json print the full JSON response
147
+ --diff show the text before and after, and every change with its reason (stderr)
148
+ --review print one Markdown block on stdout to show the user as it is: verdict,
149
+ before, after, every change with its reason, the cost and the balance
150
+ --out write the refined text to this UTF-8 file (use with --review;
151
+ for a folder or a URL, see below)
152
+ --no-store don't keep this text: free use is otherwise kept, scrubbed of personal
153
+ data, to improve the service (paid use is kept only with consent)
154
+ --report also write a Markdown report (input, output, changes, reasons, verdict)
155
+ to ./altalandi-reports/ (or --report-dir <dir>; ALTALANDI_REPORT=1 turns it on)
156
+ --file read the text from a UTF-8 file instead of an argument
157
+ --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
158
+
159
+ stdout = refined text; stderr = nativeness / meaning / warnings / diff.
160
+ Without --fast, false/null meaning verification exits 3 after printing the response.
161
+
162
+ Folder and website options:
163
+ --out output folder (default <folder>-is; never the input or inside it),
164
+ or the .md file for one page
165
+ --dry-run show every passage and the estimate; send nothing, write nothing
166
+ --yes, -y send without asking (needed when there is no terminal to ask in)
167
+ --max-chars send at most this many characters; files past the budget are left out
168
+ --concurrency calls at once, 1-4 (default 2)
169
+ --site with a URL: its pages on the same site, under the URL's folder
170
+ --max-pages pages to read with --site (default 20)
171
+ --json print a machine-readable summary at the end
172
+ Code, URLs, link targets, placeholders ({name}, %s, ICU), HTML tags and JSON keys
173
+ stay byte for byte. Only changes whose meaning check passed are written; other
174
+ passages keep the original text and are listed in the report
175
+ (./altalandi-reports/). Exit: 0 done · 2 some passages kept their original ·
176
+ 4 out of credit · 1 error.`);
177
+ }
178
+
179
+ function readStdin() {
180
+ return new Promise((resolve) => {
181
+ if (process.stdin.isTTY) return resolve("");
182
+ let d = "";
183
+ process.stdin.setEncoding("utf8");
184
+ process.stdin.on("data", (c) => (d += c));
185
+ process.stdin.on("end", () => resolve(d));
186
+ });
187
+ }
188
+
189
+ const stored = readCredentials();
190
+ const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || stored?.baseUrl || "https://altalandi.is/api").replace(/\/$/, "");
191
+ const { key } = resolveKey({ flag: opts.key });
192
+
193
+ if (opts.key) {
194
+ console.error("⚠ --key is deprecated because command arguments can leak through shell history/process listings; load ALTALANDI_API_KEY instead.");
195
+ }
196
+
197
+ // A folder or a URL as the one argument: many documents in one run (batch.mjs).
198
+ const target = positional.length === 1 && !opts.file ? positional[0] : "";
199
+ const many = /^https?:\/\//i.test(target) || (Boolean(target) && existsSync(target) && statSync(target).isDirectory());
200
+ if (many) {
201
+ const { runBatch } = await import("./batch.mjs");
202
+ const { currentVersion } = await import("./update.mjs");
203
+ process.exitCode = await runBatch({ ...opts, target, key, baseUrl }, { version: await currentVersion() });
204
+ } else {
205
+ if (opts.site || opts.dryRun || opts.maxChars !== undefined || opts.maxPages !== undefined || opts.concurrency !== undefined) {
206
+ console.error("--site, --dry-run, --max-chars, --max-pages and --concurrency need a folder or a URL: altalandi ./docs --dry-run");
207
+ process.exit(2);
208
+ }
209
+
210
+ let text = positional.join(" ").trim();
211
+ if (opts.file) {
212
+ const { readFile } = await import("node:fs/promises");
213
+ try { text = (await readFile(opts.file, "utf8")).replace(/^/, "").trim(); }
214
+ catch (e) { console.error(`Could not read ${opts.file}: ${e.message}`); process.exit(2); }
215
+ }
216
+ if (!text) text = (await readStdin()).trim();
217
+ if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
218
+ if (!key) { console.error("Not logged in. Run once: npx altalandi login (or set ALTALANDI_API_KEY)"); process.exit(2); }
219
+
220
+ const requestOptions = {
221
+ apiKey: key,
222
+ baseUrl,
223
+ domain: opts.domain,
224
+ register: opts.register,
225
+ ...(opts.noStore ? { contribute: false } : {}),
226
+ };
227
+ // --fast disables every optional quality/verification pass. The writer can
228
+ // still retry once when it drops document structure, as documented in --help.
229
+ // Name every switch explicitly so a future server-default change cannot make
230
+ // this client silently slower.
231
+ if (opts.fast) {
232
+ requestOptions.preserveMeaning = false;
233
+ requestOptions.scoreNativeness = false;
234
+ requestOptions.onlyImprove = false;
235
+ requestOptions.polishPass = false;
236
+ requestOptions.scoring = "none";
237
+ }
238
+
239
+ // /v1/me before and after the call gives the cost and the balance for the
240
+ // review block. Best-effort: a failed lookup only blanks those lines.
241
+ async function me() {
242
+ try {
243
+ const res = await fetch(`${baseUrl}/v1/me`, {
244
+ headers: { authorization: `Bearer ${key}`, accept: "application/json" },
245
+ signal: AbortSignal.timeout(15_000),
246
+ });
247
+ return res.ok ? await res.json() : null;
248
+ } catch { return null; }
249
+ }
250
+ const meBefore = opts.review ? await me() : null;
251
+
252
+ try {
253
+ // refine() owns a single 300-second AbortController deadline across fetch
254
+ // and response-body parsing, matching the hosted package API.
255
+ const data = await refine(text, requestOptions);
256
+ // --review exits on the outcome the block states: a vetoed rewrite returns
257
+ // the original with meaningPreserved true, and must not read as a success.
258
+ const verificationFailed = opts.review
259
+ ? ["refused", "unverified"].includes(outcomeOf(data)) && !(opts.fast && outcomeOf(data) === "unverified")
260
+ : !opts.fast && data.meaningPreserved !== true;
261
+
262
+ if (opts.out) {
263
+ const { writeFile } = await import("node:fs/promises");
264
+ try { await writeFile(opts.out, String(data.output ?? "") + "\n", "utf8"); }
265
+ catch (e) { console.error(`Could not write ${opts.out}: ${e.message}`); process.exitCode = 1; }
266
+ }
267
+
268
+ if (opts.review) {
269
+ const meAfter = await me();
270
+ // Without --out the block is the only copy of a paid result: never shorten it.
271
+ process.stdout.write(reviewMarkdown(text, data, {
272
+ before: meBefore, after: meAfter, fullTextAt: opts.out || "", maxChars: opts.out ? 1500 : Infinity,
273
+ }));
274
+ } else if (opts.json) {
275
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
276
+ if (verificationFailed) {
277
+ console.error("Meaning verification did not pass; JSON was printed, exiting with status 3.");
278
+ }
279
+ } else {
280
+ process.stdout.write(data.output + "\n");
281
+ const q = data.quality || {};
282
+ const meta = [];
283
+ if (q.nativenessScore != null) meta.push(`nativeness ${q.nativenessScore}/100`);
284
+ if (data.meaningPreserved === false) meta.push("⚠ MEANING DRIFT — returned the original");
285
+ else if (data.meaningPreserved === true) meta.push("meaning preserved");
286
+ else if (opts.fast) meta.push("meaning check skipped (--fast)");
287
+ else meta.push("⚠ MEANING UNVERIFIED — review before use");
288
+ if (meta.length) console.error("· " + meta.join(" · "));
289
+ for (const warning of Array.isArray(data.warnings) ? data.warnings : []) {
290
+ console.error(`⚠ ${warning}`);
291
+ }
292
+ if (opts.diff || process.env.ALTALANDI_DIFF === "1") {
293
+ console.error("\n" + beforeAfterText(text, data, { color: Boolean(process.stderr.isTTY) && !process.env.NO_COLOR }));
294
+ } else {
295
+ for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
296
+ }
297
+ }
298
+
299
+ if (reportEnabled(opts.report)) {
300
+ try {
301
+ const path = await writeReport(text, data, { domain: opts.domain, register: opts.register }, opts.reportDir);
302
+ console.error(`Skýrsla: ${path}`);
303
+ } catch (e) {
304
+ console.error(`Could not write the report: ${e.message}`);
305
+ }
306
+ }
307
+
308
+ // stdout/JSON is intentionally written first so callers can inspect the
309
+ // server response, but guarded automation must not accept null/false as a
310
+ // verified success. --fast is the explicit opt-out from this contract.
311
+ if (verificationFailed) process.exitCode = 3;
312
+ } catch (e) {
313
+ if (opts.review) {
314
+ // The block still comes out, so the user sees why and what is left.
315
+ const head = e.status === 402 ? "Ekki næg inneign fyrir þetta kall"
316
+ : e.status === 429 ? "Of mörg köll í einu eða á mínútu; reyndu aftur eftir smá stund"
317
+ : e.status === 401 ? "Lykillinn er ógildur eða afturkallaður (`npx altalandi login`)"
318
+ : `Kallið mistókst: ${e.message}`;
319
+ const lines = [`**Altalandi:** ${head}`, "", "Textanum var ekki breytt.", "", "**Kostnaður**",
320
+ "- Þetta kall: 0 kr.", ...costLines({}, null, meBefore).slice(1)];
321
+ if (e.status === 402 && !lines.some((l) => l.includes("/stjornbord/inneign"))) lines.push("- Bæta við inneign: https://altalandi.is/stjornbord/inneign");
322
+ process.stdout.write(lines.join("\n") + "\n");
323
+ }
324
+ console.error("Request failed:", e.message);
325
+ if (e.status === 402) console.error("→ Add credit at https://altalandi.is/stjornbord/inneign (automatic top-up is available there).");
326
+ if (e.status === 401) console.error("→ The key is invalid or revoked. Run: npx altalandi login");
327
+ process.exit(e.status === 402 ? 4 : 1);
328
+ }
329
+ } // single text
330
+ }