altalandi 0.3.1 → 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
@@ -96,6 +96,9 @@ altalandi "Some English or Icelandic text."
96
96
  echo "some text" | altalandi --domain marketing
97
97
  altalandi --json "..." # full JSON response
98
98
  altalandi --fast "..." # skip the meaning + nativeness gates
99
+ altalandi --diff --file draft.txt # show before, after and every change with its reason
100
+ altalandi --review --file in.txt --out out.txt # for agents: one block to show as it is
101
+ altalandi --report --file draft.txt # also write a Markdown report to ./altalandi-reports/
99
102
  ```
100
103
 
101
104
  | flag | default | meaning |
@@ -103,8 +106,37 @@ altalandi --fast "..." # skip the meaning + nativeness gates
103
106
  | `--domain` | `general` | `general` \| `marketing` \| `data-analysis` \| `childrens-fiction` |
104
107
  | `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
105
108
  | `--json` | off | print the full API response |
109
+ | `--diff` | off | print the text before and after, then each change with its reason, on `stderr` (coloured in a terminal; `NO_COLOR` turns colour off) |
110
+ | `--review` | off | print one Markdown block on `stdout`, made to be shown as it is: the verdict, the text before and after, each change with its reason, what the call cost and the balance and free units left. Exits on the outcome: `0` rewritten and verified or no change needed, `3` refused or unverified, `4` out of credit |
111
+ | `--no-store` | off | send `contribute: false`: this text is never kept. Without it, free use (calls covered by the free daily units) is kept with personal data scrubbed, to improve the service; paid use is kept only with consent ([privacy](https://altalandi.is/personuvernd#textabanki)) |
112
+ | `--out <file>` | | write the refined text to `<file>` (UTF-8); with `--review` the block names it when the text is shortened |
113
+ | `--report` | off | also write `./altalandi-reports/altalandi-<time>-<id>.md`: input, output, a table of every change (before, after, reason, layer), warnings and the verdict |
114
+ | `--report-dir <dir>` | | write the report to `<dir>` instead (implies `--report`) |
106
115
  | `--fast` | off | `preserveMeaning:false`, `scoreNativeness:false`, `onlyImprove:false`, `polishPass:false`, `scoring:"none"` — normally one model call (structured input can still add a structure-preserving retry) |
107
116
 
117
+ ## Before and after
118
+
119
+ `--diff` prints what the website shows inline, in the terminal:
120
+
121
+ ```text
122
+ ── Fyrir ───────────────────────────────────
123
+ Vertu viss um að ná út til okkar ef þú hefur einhverjar spurningar.
124
+
125
+ ── Eftir ───────────────────────────────────
126
+ Ekki hika við að hafa samband ef einhverjar spurningar vakna.
127
+
128
+ ── Breytingar (1) ──────────────────────────
129
+ 1. − Vertu viss um að ná út til okkar ef þú hefur einhverjar spurningar.
130
+ + Ekki hika við að hafa samband ef einhverjar spurningar vakna.
131
+ ↳ <the reason the engine gives>
132
+
133
+ Endurskrifað og merking staðfest · innfæddni 91/100
134
+ ```
135
+
136
+ `--report` (or `ALTALANDI_REPORT=1`) writes the same content as a Markdown file
137
+ you can keep or share. Reports are off by default because they contain your text.
138
+ `stdout` stays the refined text alone either way, so piping is unaffected.
139
+
108
140
  ## The meaning guarantee
109
141
 
110
142
  Altalandi never silently changes meaning. For Icelandic input where the rewrite
@@ -138,6 +170,9 @@ the explicit verification opt-out and exits 0 after a successful API response.
138
170
  |-----|---------|---------|
139
171
  | `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
140
172
  | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
173
+ | `ALTALANDI_DIFF` | — | `1` = always print the before/after (as `--diff`) |
174
+ | `ALTALANDI_REPORT` | — | `1` = always write a report (as `--report`) |
175
+ | `ALTALANDI_REPORT_DIR` | `./altalandi-reports` | where reports go |
141
176
 
142
177
  MIT licensed. Calls are paid from the account's prepaid credit (a 500 kr trial,
143
178
  then credit bought in the portal); a call the credit cannot cover exits with the
package/altalandi.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  // meaning verdict, API warnings, and the diff.
11
11
 
12
12
  import { refine } from "./client.mjs";
13
+ import { beforeAfterText, costLines, outcomeOf, reportEnabled, reviewMarkdown, writeReport } from "./report.mjs";
13
14
  import { readCredentials, resolveKey, runLogin, runLogout, runWhoami } from "./auth.mjs";
14
15
 
15
16
  const args = process.argv.slice(2);
@@ -71,7 +72,7 @@ applied · 1 the check or the update failed.`);
71
72
  }
72
73
 
73
74
  if (!handled) {
74
- const opts = { domain: "general", register: "neutral", json: false, fast: false };
75
+ const opts = { domain: "general", register: "neutral", json: false, fast: false, diff: false, review: false, report: undefined };
75
76
  const positional = [];
76
77
  for (let i = 0; i < args.length; i++) {
77
78
  const a = args[i];
@@ -83,6 +84,12 @@ for (let i = 0; i < args.length; i++) {
83
84
  else if (a === "--base-url") opts.baseUrl = args[++i];
84
85
  else if (a === "--key") opts.key = args[++i];
85
86
  else if (a === "--file") opts.file = args[++i];
87
+ else if (a === "--diff") opts.diff = true;
88
+ else if (a === "--review") opts.review = true;
89
+ else if (a === "--out") opts.out = args[++i];
90
+ else if (a === "--no-store") opts.noStore = true;
91
+ else if (a === "--report") opts.report = true;
92
+ else if (a === "--report-dir") { opts.report = true; opts.reportDir = args[++i]; }
86
93
  else positional.push(a);
87
94
  }
88
95
 
@@ -92,7 +99,9 @@ function help() {
92
99
  Usage:
93
100
  altalandi login connect this computer to your account (once)
94
101
  altalandi "text to refine"
95
- altalandi --file draft.txt read the text from a file (best for agents)
102
+ altalandi --file draft.txt read the text from a file
103
+ altalandi --review --file in.txt --out out.txt
104
+ for agents: one block to show as it is
96
105
  echo "text" | altalandi
97
106
  altalandi --domain marketing --register warm "text"
98
107
 
@@ -110,6 +119,14 @@ Options:
110
119
  --fast no polish, no nativeness score, no meaning gate — normally one writer call
111
120
  (structured input can still add a structure-preserving retry)
112
121
  --json print the full JSON response
122
+ --diff show the text before and after, and every change with its reason (stderr)
123
+ --review print one Markdown block on stdout to show the user as it is: verdict,
124
+ before, after, every change with its reason, the cost and the balance
125
+ --out write the refined text to this UTF-8 file (use with --review)
126
+ --no-store don't keep this text: free use is otherwise kept, scrubbed of personal
127
+ data, to improve the service (paid use is kept only with consent)
128
+ --report also write a Markdown report (input, output, changes, reasons, verdict)
129
+ to ./altalandi-reports/ (or --report-dir <dir>; ALTALANDI_REPORT=1 turns it on)
113
130
  --file read the text from a UTF-8 file instead of an argument
114
131
  --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
115
132
 
@@ -150,6 +167,7 @@ const requestOptions = {
150
167
  baseUrl,
151
168
  domain: opts.domain,
152
169
  register: opts.register,
170
+ ...(opts.noStore ? { contribute: false } : {}),
153
171
  };
154
172
  // --fast disables every optional quality/verification pass. The writer can
155
173
  // still retry once when it drops document structure, as documented in --help.
@@ -163,13 +181,42 @@ if (opts.fast) {
163
181
  requestOptions.scoring = "none";
164
182
  }
165
183
 
184
+ // /v1/me before and after the call gives the cost and the balance for the
185
+ // review block. Best-effort: a failed lookup only blanks those lines.
186
+ async function me() {
187
+ try {
188
+ const res = await fetch(`${baseUrl}/v1/me`, {
189
+ headers: { authorization: `Bearer ${key}`, accept: "application/json" },
190
+ signal: AbortSignal.timeout(15_000),
191
+ });
192
+ return res.ok ? await res.json() : null;
193
+ } catch { return null; }
194
+ }
195
+ const meBefore = opts.review ? await me() : null;
196
+
166
197
  try {
167
198
  // refine() owns a single 300-second AbortController deadline across fetch
168
199
  // and response-body parsing, matching the hosted package API.
169
200
  const data = await refine(text, requestOptions);
170
- const verificationFailed = !opts.fast && data.meaningPreserved !== true;
201
+ // --review exits on the outcome the block states: a vetoed rewrite returns
202
+ // the original with meaningPreserved true, and must not read as a success.
203
+ const verificationFailed = opts.review
204
+ ? ["refused", "unverified"].includes(outcomeOf(data)) && !(opts.fast && outcomeOf(data) === "unverified")
205
+ : !opts.fast && data.meaningPreserved !== true;
206
+
207
+ if (opts.out) {
208
+ const { writeFile } = await import("node:fs/promises");
209
+ try { await writeFile(opts.out, String(data.output ?? "") + "\n", "utf8"); }
210
+ catch (e) { console.error(`Could not write ${opts.out}: ${e.message}`); process.exitCode = 1; }
211
+ }
171
212
 
172
- if (opts.json) {
213
+ if (opts.review) {
214
+ const meAfter = await me();
215
+ // Without --out the block is the only copy of a paid result: never shorten it.
216
+ process.stdout.write(reviewMarkdown(text, data, {
217
+ before: meBefore, after: meAfter, fullTextAt: opts.out || "", maxChars: opts.out ? 1500 : Infinity,
218
+ }));
219
+ } else if (opts.json) {
173
220
  process.stdout.write(JSON.stringify(data, null, 2) + "\n");
174
221
  if (verificationFailed) {
175
222
  console.error("Meaning verification did not pass; JSON was printed, exiting with status 3.");
@@ -187,7 +234,20 @@ try {
187
234
  for (const warning of Array.isArray(data.warnings) ? data.warnings : []) {
188
235
  console.error(`⚠ ${warning}`);
189
236
  }
190
- for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
237
+ if (opts.diff || process.env.ALTALANDI_DIFF === "1") {
238
+ console.error("\n" + beforeAfterText(text, data, { color: Boolean(process.stderr.isTTY) && !process.env.NO_COLOR }));
239
+ } else {
240
+ for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
241
+ }
242
+ }
243
+
244
+ if (reportEnabled(opts.report)) {
245
+ try {
246
+ const path = await writeReport(text, data, { domain: opts.domain, register: opts.register }, opts.reportDir);
247
+ console.error(`Skýrsla: ${path}`);
248
+ } catch (e) {
249
+ console.error(`Could not write the report: ${e.message}`);
250
+ }
191
251
  }
192
252
 
193
253
  // stdout/JSON is intentionally written first so callers can inspect the
@@ -195,6 +255,17 @@ try {
195
255
  // verified success. --fast is the explicit opt-out from this contract.
196
256
  if (verificationFailed) process.exitCode = 3;
197
257
  } catch (e) {
258
+ if (opts.review) {
259
+ // The block still comes out, so the user sees why and what is left.
260
+ const head = e.status === 402 ? "Ekki næg inneign fyrir þetta kall"
261
+ : e.status === 429 ? "Of mörg köll í einu eða á mínútu; reyndu aftur eftir smá stund"
262
+ : e.status === 401 ? "Lykillinn er ógildur eða afturkallaður (`npx altalandi login`)"
263
+ : `Kallið mistókst: ${e.message}`;
264
+ const lines = [`**Altalandi:** ${head}`, "", "Textanum var ekki breytt.", "", "**Kostnaður**",
265
+ "- Þetta kall: 0 kr.", ...costLines({}, null, meBefore).slice(1)];
266
+ if (e.status === 402 && !lines.some((l) => l.includes("/portal"))) lines.push("- Bæta við inneign: https://altalandi.is/portal");
267
+ process.stdout.write(lines.join("\n") + "\n");
268
+ }
198
269
  console.error("Request failed:", e.message);
199
270
  if (e.status === 402) console.error("→ Add credit at https://altalandi.is/portal (automatic top-up is available there).");
200
271
  if (e.status === 401) console.error("→ The key is invalid or revoked. Run: npx altalandi login");
package/auth.mjs CHANGED
@@ -1,215 +1,223 @@
1
- // `altalandi login` — connect this computer to an Altalandi account once.
2
- //
3
- // Device login, the same pattern as `gh auth login`: the CLI asks the API for a
4
- // short code, the user opens altalandi.is/tengja, signs in or creates an
5
- // account, and approves. The CLI then receives a key for that account exactly
6
- // once and stores it in the user's config directory. The key never passes
7
- // through a chat, a prompt, the clipboard or shell history.
8
- //
9
- // Afterwards every `altalandi` call finds the key on its own, so an agent (the
10
- // Claude Code skill, a script) needs no environment variable at all.
11
- //
12
- // Zero dependencies, like the rest of the CLI.
13
-
14
- import { spawn } from "node:child_process";
15
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
- import { existsSync, readFileSync } from "node:fs";
17
- import { homedir, hostname } from "node:os";
18
- import path from "node:path";
19
-
20
- import { skillStatus, skillTarget, writeSkill } from "./update.mjs";
21
-
22
- export const DEFAULT_BASE_URL = "https://altalandi.is/api";
23
-
24
- /** Where the key is stored. ALTALANDI_CONFIG_DIR overrides (tests, CI). */
25
- export function configDir({ env = process.env, home = homedir(), platform = process.platform } = {}) {
26
- if (env.ALTALANDI_CONFIG_DIR) return env.ALTALANDI_CONFIG_DIR;
27
- if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "altalandi");
28
- return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "altalandi");
29
- }
30
-
31
- export function credentialsFile(opts) {
32
- return path.join(configDir(opts), "credentials.json");
33
- }
34
-
35
- /** The stored credentials, or null. Never throws. */
36
- export function readCredentials(opts) {
37
- try {
38
- const data = JSON.parse(readFileSync(credentialsFile(opts), "utf8"));
39
- return data && typeof data.apiKey === "string" && data.apiKey ? data : null;
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- /** Key resolution order: explicit flag, ALTALANDI_API_KEY, stored login. */
46
- export function resolveKey({ flag, env = process.env, ...opts } = {}) {
47
- if (flag) return { key: flag, source: "flag" };
48
- const fromEnv = env.ALTALANDI_API_KEY || env.ALTALANDI_KEY;
49
- if (fromEnv) return { key: fromEnv, source: "env" };
50
- const stored = readCredentials({ env, ...opts });
51
- return stored ? { key: stored.apiKey, source: "login", username: stored.username } : { key: "", source: "none" };
52
- }
53
-
54
- async function saveCredentials(data, opts) {
55
- const dir = configDir(opts);
56
- await mkdir(dir, { recursive: true });
57
- const file = credentialsFile(opts);
58
- await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
59
- try { await chmod(file, 0o600); } catch { /* Windows: the per-user profile directory is the protection */ }
60
- return file;
61
- }
62
-
63
- function clientName(env = process.env) {
64
- const where = (hostname() || "").split(".")[0].slice(0, 24);
65
- const who = env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT ? "Claude Code" : "altalandi CLI";
66
- return where ? `${who} á ${where}` : who;
67
- }
68
-
69
- function openBrowser(url) {
70
- try {
71
- const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
72
- : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
73
- const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
74
- child.on("error", () => {});
75
- child.unref();
76
- return true;
77
- } catch {
78
- return false;
79
- }
80
- }
81
-
82
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
83
-
84
- async function postJson(fetchImpl, url, body) {
85
- const res = await fetchImpl(url, {
86
- method: "POST",
87
- headers: { "content-type": "application/json", accept: "application/json" },
88
- body: JSON.stringify(body),
89
- });
90
- let data = {};
91
- try { data = await res.json(); } catch { /* keep {} */ }
92
- return { status: res.status, data };
93
- }
94
-
95
- /**
96
- * Run the device login. Returns an exit code.
97
- * 0 logged in · 1 failed or expired · 2 bad usage
98
- */
99
- export async function runLogin(flags = {}, io = {}) {
100
- const env = io.env || process.env;
101
- const fetchImpl = io.fetch || globalThis.fetch;
102
- const log = io.log || ((m) => console.error(m));
103
- const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
104
- const wait = io.sleep || sleep;
105
-
106
- let start;
107
- try {
108
- start = await postJson(fetchImpl, `${base}/v1/device/start`, { clientName: clientName(env) });
109
- } catch (e) {
110
- log(`Náði ekki sambandi við Altalandi (${e.message}).`);
111
- return 1;
112
- }
113
- if (start.status !== 200 || !start.data.deviceCode) {
114
- log(`Innskráning fór ekki af stað: ${start.data.detail || `HTTP ${start.status}`}`);
115
- return 1;
116
- }
117
- const { deviceCode, userCode, verificationUriComplete, verificationUri, interval = 3, expiresIn = 600 } = start.data;
118
-
119
- log("");
120
- log(" Tengdu þessa tölvu við Altalandi-aðganginn þinn:");
121
- log("");
122
- log(` 1. Opnaðu ${verificationUriComplete}`);
123
- log(` 2. Skráðu þig inn eða stofnaðu aðgang (500 kr. prufuinneign fylgir)`);
124
- log(` 3. Staðfestu kóðann ${userCode}`);
125
- log("");
126
- log(` (Connect this computer: open the link, sign in or sign up, confirm the code.`);
127
- log(` Code page: ${verificationUri})`);
128
- log("");
129
- if (!flags.noBrowser && !io.noBrowser) openBrowser(verificationUriComplete);
130
- log(" Bíð eftir staðfestingu…");
131
-
132
- const deadline = Date.now() + expiresIn * 1000;
133
- let result = null;
134
- while (Date.now() < deadline) {
135
- await wait(Math.max(1, interval) * 1000);
136
- let poll;
137
- try {
138
- poll = await postJson(fetchImpl, `${base}/v1/device/token`, { deviceCode });
139
- } catch {
140
- continue; // transient network error: keep waiting until the code expires
141
- }
142
- if (poll.status === 200 && poll.data.status === "approved") { result = poll.data; break; }
143
- if (poll.status === 200 && poll.data.status === "pending") continue;
144
- if (poll.status === 429) { await wait(10_000); continue; }
145
- log(` Kóðinn rann út eða var hafnað. Keyrðu \`altalandi login\` aftur.`);
146
- return 1;
147
- }
148
- if (!result) {
149
- log(` Kóðinn rann út. Keyrðu \`altalandi login\` aftur.`);
150
- return 1;
151
- }
152
-
153
- const file = await saveCredentials({
154
- apiKey: result.apiKey,
155
- username: result.username,
156
- baseUrl: base === DEFAULT_BASE_URL ? undefined : base,
157
- savedAt: new Date().toISOString(),
158
- }, { env });
159
- log("");
160
- log(` ✓ Tengt sem ${result.username}. Lykillinn er vistaður í ${file}`);
161
-
162
- // Install or refresh the Claude Code skill — the point of logging in for most
163
- // people. Only where Claude Code exists; never create ~/.claude for someone who
164
- // does not use it. --no-skill opts out.
165
- if (!flags.noSkill) {
166
- const target = skillTarget({ env });
167
- const status = await skillStatus({ target });
168
- if (status === "missing" || status === "outdated") {
169
- const { file: skillFile, backup } = await writeSkill({ target });
170
- log(` ✓ Claude Code-hæfnin „altalandi" sett upp: ${skillFile}${backup ? ` (eldri útgáfa afrituð í ${backup})` : ""}`);
171
- } else if (status === "current") {
172
- log(` ✓ Claude Code-hæfnin „altalandi" er þegar uppfærð.`);
173
- } else if (status === "absent") {
174
- log(` (Claude Code fannst ekki á þessari tölvu, svo hæfnin var ekki sett upp.)`);
175
- }
176
- }
177
- log("");
178
- log(` Prófaðu: altalandi "Make sure to reach out to us today."`);
179
- log(` Eða segðu Claude: „Skrifaðu þetta á góðri íslensku með Altalandi."`);
180
- return 0;
181
- }
182
-
183
- export async function runLogout(io = {}) {
184
- const env = io.env || process.env;
185
- const log = io.log || ((m) => console.error(m));
186
- const file = credentialsFile({ env });
187
- if (!existsSync(file)) { log("Engin vistuð innskráning."); return 0; }
188
- await rm(file, { force: true });
189
- log(`Innskráning fjarlægð (${file}). Lykillinn sjálfur er enn virkur þar til þú afturkallar hann á https://altalandi.is/portal`);
190
- return 0;
191
- }
192
-
193
- export async function runWhoami(flags = {}, io = {}) {
194
- const env = io.env || process.env;
195
- const fetchImpl = io.fetch || globalThis.fetch;
196
- const out = io.out || ((m) => console.log(m));
197
- const log = io.log || ((m) => console.error(m));
198
- const { key, source } = resolveKey({ flag: flags.key, env });
199
- if (!key) { log("Ekki innskráð(ur). Keyrðu: npx altalandi login"); return 2; }
200
- const stored = readCredentials({ env });
201
- const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || stored?.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
202
- let res;
203
- try {
204
- res = await fetchImpl(`${base}/v1/me`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" } });
205
- } catch (e) {
206
- log(`Náði ekki sambandi við Altalandi (${e.message}).`);
207
- return 1;
208
- }
209
- if (res.status === 401) { log("Lykillinn er ógildur eða afturkallaður. Keyrðu: npx altalandi login"); return 1; }
210
- if (!res.ok) { log(`Villa: HTTP ${res.status}`); return 1; }
211
- const me = await res.json();
212
- if (flags.json) out(JSON.stringify({ ...me, keySource: source }, null, 2));
213
- else out(`${me.username ?? "(lykill án aðgangs)"} · inneign ${me.balanceIsk ?? "—"} kr. · lykill ${me.keyId} (${source})`);
214
- return 0;
215
- }
1
+ // `altalandi login` — connect this computer to an Altalandi account once.
2
+ //
3
+ // Device login, the same pattern as `gh auth login`: the CLI asks the API for a
4
+ // short code, the user opens altalandi.is/tengja, signs in or creates an
5
+ // account, and approves. The CLI then receives a key for that account exactly
6
+ // once and stores it in the user's config directory. The key never passes
7
+ // through a chat, a prompt, the clipboard or shell history.
8
+ //
9
+ // Afterwards every `altalandi` call finds the key on its own, so an agent (the
10
+ // Claude Code skill, a script) needs no environment variable at all.
11
+ //
12
+ // Zero dependencies, like the rest of the CLI.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { homedir, hostname } from "node:os";
18
+ import path from "node:path";
19
+
20
+ import { skillStatus, skillTarget, writeSkill } from "./update.mjs";
21
+
22
+ export const DEFAULT_BASE_URL = "https://altalandi.is/api";
23
+
24
+ /** Where the key is stored. ALTALANDI_CONFIG_DIR overrides (tests, CI). */
25
+ export function configDir({ env = process.env, home = homedir(), platform = process.platform } = {}) {
26
+ if (env.ALTALANDI_CONFIG_DIR) return env.ALTALANDI_CONFIG_DIR;
27
+ if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "altalandi");
28
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "altalandi");
29
+ }
30
+
31
+ export function credentialsFile(opts) {
32
+ return path.join(configDir(opts), "credentials.json");
33
+ }
34
+
35
+ /** The stored credentials, or null. Never throws. */
36
+ export function readCredentials(opts) {
37
+ try {
38
+ const data = JSON.parse(readFileSync(credentialsFile(opts), "utf8"));
39
+ return data && typeof data.apiKey === "string" && data.apiKey ? data : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Key resolution order: explicit flag, ALTALANDI_API_KEY, stored login. */
46
+ export function resolveKey({ flag, env = process.env, ...opts } = {}) {
47
+ if (flag) return { key: flag, source: "flag" };
48
+ const fromEnv = env.ALTALANDI_API_KEY || env.ALTALANDI_KEY;
49
+ if (fromEnv) return { key: fromEnv, source: "env" };
50
+ const stored = readCredentials({ env, ...opts });
51
+ return stored ? { key: stored.apiKey, source: "login", username: stored.username } : { key: "", source: "none" };
52
+ }
53
+
54
+ async function saveCredentials(data, opts) {
55
+ const dir = configDir(opts);
56
+ await mkdir(dir, { recursive: true });
57
+ const file = credentialsFile(opts);
58
+ await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
59
+ try { await chmod(file, 0o600); } catch { /* Windows: the per-user profile directory is the protection */ }
60
+ return file;
61
+ }
62
+
63
+ function clientName(env = process.env) {
64
+ const where = (hostname() || "").split(".")[0].slice(0, 24);
65
+ const who = env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT ? "Claude Code" : "altalandi CLI";
66
+ return where ? `${who} á ${where}` : who;
67
+ }
68
+
69
+ function openBrowser(url) {
70
+ try {
71
+ const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
72
+ : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
73
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
74
+ child.on("error", () => {});
75
+ child.unref();
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
83
+
84
+ async function postJson(fetchImpl, url, body) {
85
+ const res = await fetchImpl(url, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", accept: "application/json" },
88
+ body: JSON.stringify(body),
89
+ });
90
+ let data = {};
91
+ try { data = await res.json(); } catch { /* keep {} */ }
92
+ return { status: res.status, data };
93
+ }
94
+
95
+ /**
96
+ * Run the device login. Returns an exit code.
97
+ * 0 logged in · 1 failed or expired · 2 bad usage
98
+ */
99
+ export async function runLogin(flags = {}, io = {}) {
100
+ const env = io.env || process.env;
101
+ const fetchImpl = io.fetch || globalThis.fetch;
102
+ const log = io.log || ((m) => console.error(m));
103
+ const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
104
+ const wait = io.sleep || sleep;
105
+
106
+ let start;
107
+ try {
108
+ start = await postJson(fetchImpl, `${base}/v1/device/start`, { clientName: clientName(env) });
109
+ } catch (e) {
110
+ log(`Náði ekki sambandi við Altalandi (${e.message}).`);
111
+ return 1;
112
+ }
113
+ if (start.status !== 200 || !start.data.deviceCode) {
114
+ log(`Innskráning fór ekki af stað: ${start.data.detail || `HTTP ${start.status}`}`);
115
+ return 1;
116
+ }
117
+ const { deviceCode, userCode, verificationUriComplete, verificationUri, interval = 3, expiresIn = 600 } = start.data;
118
+
119
+ log("");
120
+ log(" Tengdu þessa tölvu við Altalandi-aðganginn þinn:");
121
+ log("");
122
+ log(` 1. Opnaðu ${verificationUriComplete}`);
123
+ log(` 2. Skráðu þig inn eða stofnaðu aðgang (500 kr. prufuinneign fylgir)`);
124
+ log(` 3. Staðfestu kóðann ${userCode}`);
125
+ log("");
126
+ log(` (Connect this computer: open the link, sign in or sign up, confirm the code.`);
127
+ log(` Code page: ${verificationUri})`);
128
+ log("");
129
+ if (!flags.noBrowser && !io.noBrowser) openBrowser(verificationUriComplete);
130
+ log(" Bíð eftir staðfestingu…");
131
+
132
+ const deadline = Date.now() + expiresIn * 1000;
133
+ let result = null;
134
+ while (Date.now() < deadline) {
135
+ await wait(Math.max(1, interval) * 1000);
136
+ let poll;
137
+ try {
138
+ poll = await postJson(fetchImpl, `${base}/v1/device/token`, { deviceCode });
139
+ } catch {
140
+ continue; // transient network error: keep waiting until the code expires
141
+ }
142
+ if (poll.status === 200 && poll.data.status === "approved") { result = poll.data; break; }
143
+ if (poll.status === 200 && poll.data.status === "pending") continue;
144
+ if (poll.status === 429) { await wait(10_000); continue; }
145
+ log(` Kóðinn rann út eða var hafnað. Keyrðu \`altalandi login\` aftur.`);
146
+ return 1;
147
+ }
148
+ if (!result) {
149
+ log(` Kóðinn rann út. Keyrðu \`altalandi login\` aftur.`);
150
+ return 1;
151
+ }
152
+
153
+ const file = await saveCredentials({
154
+ apiKey: result.apiKey,
155
+ username: result.username,
156
+ baseUrl: base === DEFAULT_BASE_URL ? undefined : base,
157
+ savedAt: new Date().toISOString(),
158
+ }, { env });
159
+ log("");
160
+ log(` ✓ Tengt sem ${result.username}. Lykillinn er vistaður í ${file}`);
161
+
162
+ // Install or refresh the Claude Code skill — the point of logging in for most
163
+ // people. Only where Claude Code exists; never create ~/.claude for someone who
164
+ // does not use it. --no-skill opts out.
165
+ if (!flags.noSkill) {
166
+ const target = skillTarget({ env });
167
+ const status = await skillStatus({ target });
168
+ if (status === "missing" || status === "outdated") {
169
+ const { file: skillFile, backup } = await writeSkill({ target });
170
+ log(` ✓ Claude Code-hæfnin „altalandi" sett upp: ${skillFile}${backup ? ` (eldri útgáfa afrituð í ${backup})` : ""}`);
171
+ } else if (status === "current") {
172
+ log(` ✓ Claude Code-hæfnin „altalandi" er þegar uppfærð.`);
173
+ } else if (status === "absent") {
174
+ log(` (Claude Code fannst ekki á þessari tölvu, svo hæfnin var ekki sett upp.)`);
175
+ }
176
+ }
177
+ log("");
178
+ log(` Prófaðu: altalandi "Make sure to reach out to us today."`);
179
+ log(` Eða segðu Claude: „Skrifaðu þetta á góðri íslensku með Altalandi."`);
180
+ return 0;
181
+ }
182
+
183
+ export async function runLogout(io = {}) {
184
+ const env = io.env || process.env;
185
+ const log = io.log || ((m) => console.error(m));
186
+ const file = credentialsFile({ env });
187
+ if (!existsSync(file)) { log("Engin vistuð innskráning."); return 0; }
188
+ await rm(file, { force: true });
189
+ log(`Innskráning fjarlægð (${file}). Lykillinn sjálfur er enn virkur þar til þú afturkallar hann á https://altalandi.is/portal`);
190
+ return 0;
191
+ }
192
+
193
+ export async function runWhoami(flags = {}, io = {}) {
194
+ const env = io.env || process.env;
195
+ const fetchImpl = io.fetch || globalThis.fetch;
196
+ const out = io.out || ((m) => console.log(m));
197
+ const log = io.log || ((m) => console.error(m));
198
+ const { key, source } = resolveKey({ flag: flags.key, env });
199
+ if (!key) { log("Ekki innskráð(ur). Keyrðu: npx altalandi login"); return 2; }
200
+ const stored = readCredentials({ env });
201
+ const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || stored?.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
202
+ let res;
203
+ try {
204
+ res = await fetchImpl(`${base}/v1/me`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" } });
205
+ } catch (e) {
206
+ log(`Náði ekki sambandi við Altalandi (${e.message}).`);
207
+ return 1;
208
+ }
209
+ if (res.status === 401) { log("Lykillinn er ógildur eða afturkallaður. Keyrðu: npx altalandi login"); return 1; }
210
+ if (!res.ok) { log(`Villa: HTTP ${res.status}`); return 1; }
211
+ const me = await res.json();
212
+ if (flags.json) out(JSON.stringify({ ...me, keySource: source }, null, 2));
213
+ else {
214
+ const { formatInt, formatIsk } = await import("./report.mjs");
215
+ const free = me.freeUnitsLeftToday != null && me.freeDailyUnits != null
216
+ ? `ókeypis eftir í dag ${formatInt(me.freeUnitsLeftToday)} af ${formatInt(me.freeDailyUnits)} einingum`
217
+ : me.freeDailyUnits ? `ókeypis ${formatInt(me.freeDailyUnits)} einingar á dag` : "";
218
+ out([me.username ?? "(lykill án aðgangs)",
219
+ `inneign ${me.balanceIsk != null ? formatIsk(me.balanceIsk) : "—"}`,
220
+ free, `lykill ${me.keyId} (${source})`].filter(Boolean).join(" · "));
221
+ }
222
+ return 0;
223
+ }