altalandi 0.1.1 → 0.3.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
@@ -6,14 +6,18 @@ native wrote it—from your terminal or application. This package provides the
6
6
  [Altalandi API](https://altalandi.is) (Node ≥ 18).
7
7
 
8
8
  ```bash
9
- # Mint once at https://altalandi.is/portal, then enter it without shell history:
10
- read -rsp "Altalandi API key: " ALTALANDI_API_KEY; echo
11
- export ALTALANDI_API_KEY
9
+ npx altalandi login # once: opens altalandi.is, you sign in or sign up and click "Tengja"
12
10
  npx altalandi "Make sure to reach out to us today."
13
11
  # → Hafðu samband við okkur í dag.
14
12
  ```
15
13
 
16
- PowerShell 7: `$env:ALTALANDI_API_KEY = Read-Host "Altalandi API key" -MaskInput`.
14
+ `login` stores the key in your config directory (`%APPDATA%ltalandi` on
15
+ Windows, `~/.config/altalandi` elsewhere) and installs the Claude Code skill, so
16
+ afterwards you can just tell Claude *"write this in proper Icelandic with
17
+ Altalandi"*. `altalandi whoami` shows the account and credit; `altalandi logout`
18
+ forgets the key. On servers and in CI, set `ALTALANDI_API_KEY` instead; it takes
19
+ precedence over the stored login. For agents, `--file draft.txt` avoids shell
20
+ quoting entirely.
17
21
 
18
22
  `stdout` is the refined text (pipe it anywhere); `stderr` carries the 0–100
19
23
  nativeness score, the meaning verdict, every API warning, and the diff.
@@ -31,6 +35,27 @@ For an application dependency, install locally and import the package root:
31
35
  npm i altalandi
32
36
  ```
33
37
 
38
+ ## Updating
39
+
40
+ ```bash
41
+ altalandi update # check, then ask before updating
42
+ altalandi update --check # only report; change nothing
43
+ altalandi --version
44
+ ```
45
+
46
+ `altalandi update` checks npm for a newer version and asks before installing
47
+ it. It works out how you installed the CLI (global, project dependency or `npx`)
48
+ and runs the matching command. If you use Claude Code, it then offers to install
49
+ or refresh the `/altalandi` skill that ships with each version. A skill you have
50
+ edited is backed up before it is replaced.
51
+
52
+ Nothing changes without your answer. Without a terminal to ask in (agents, CI,
53
+ pipes) it only reports what is available; pass `--yes` when a script already has
54
+ your consent.
55
+
56
+ Exit codes: `0` up to date or updated · `10` an update is available but was not
57
+ applied · `1` the check or the update failed.
58
+
34
59
  ## JavaScript / TypeScript client
35
60
 
36
61
  `refineVerified` requests the meaning gate and throws unless the API returns
@@ -114,8 +139,8 @@ the explicit verification opt-out and exits 0 after a successful API response.
114
139
  | `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
115
140
  | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
116
141
 
117
- MIT licensed. Portal keys share a daily account-level billable-word budget;
118
- operator-issued keys remain per-key, and key rotation does not reset account
119
- usage. The request's text and glossary fields are charged at the effective billing-tier
142
+ MIT licensed. Calls are paid from the account's prepaid credit (a 500 kr trial,
143
+ then credit bought in the portal); a call the credit cannot cover exits with the
144
+ server's `402 insufficient_balance` message. The request's text and glossary fields are charged at the effective billing-tier
120
145
  weight. Each whitespace-delimited token is at least one unit; an unbroken token
121
146
  uses one unit per 20 characters, rounded up.
package/altalandi.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // altalandi — turn English or stiff/AI Icelandic into native Icelandic from
3
3
  // your terminal. Zero dependencies (Node >= 18 has global fetch).
4
4
  //
5
- // export ALTALANDI_API_KEY=... # get one at https://altalandi.is
5
+ // npx altalandi login # once: connects this computer to your account
6
6
  // altalandi "Make sure to reach out to us today."
7
7
  // echo "some text" | altalandi --domain marketing
8
8
  //
@@ -10,8 +10,67 @@
10
10
  // meaning verdict, API warnings, and the diff.
11
11
 
12
12
  import { refine } from "./client.mjs";
13
+ import { readCredentials, resolveKey, runLogin, runLogout, runWhoami } from "./auth.mjs";
13
14
 
14
15
  const args = process.argv.slice(2);
16
+
17
+ // Subcommands and meta flags are handled before anything that needs an API key
18
+ // or input text. `altalandi update` checks for a newer CLI and skill and asks
19
+ // before changing anything.
20
+ let handled = false;
21
+ if (args[0] === "update") {
22
+ handled = true;
23
+ const { runUpdate } = await import("./update.mjs");
24
+ const rest = args.slice(1);
25
+ if (rest.includes("-h") || rest.includes("--help")) { updateHelp(); process.exit(0); }
26
+ // exitCode, never process.exit(): the registry request can leave a socket
27
+ // mid-close, and exiting then aborts Node on Windows with a libuv assertion
28
+ // (UV_HANDLE_CLOSING). The refine path below exits the same way for the
29
+ // same reason.
30
+ process.exitCode = await runUpdate({
31
+ check: rest.includes("--check"),
32
+ yes: rest.includes("--yes") || rest.includes("-y"),
33
+ skillOnly: rest.includes("--skill-only"),
34
+ });
35
+ }
36
+ if (!handled && ["login", "setup", "logout", "whoami"].includes(args[0])) {
37
+ handled = true;
38
+ const rest = args.slice(1);
39
+ const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
40
+ if (args[0] === "login" || args[0] === "setup") {
41
+ process.exitCode = await runLogin({
42
+ noBrowser: rest.includes("--no-browser"),
43
+ noSkill: rest.includes("--no-skill"),
44
+ baseUrl: flag("--base-url"),
45
+ });
46
+ } else if (args[0] === "logout") {
47
+ process.exitCode = await runLogout();
48
+ } else {
49
+ process.exitCode = await runWhoami({ json: rest.includes("--json"), baseUrl: flag("--base-url") });
50
+ }
51
+ }
52
+ if (!handled && (args.includes("-v") || args.includes("--version"))) {
53
+ const { currentVersion } = await import("./update.mjs");
54
+ console.log(await currentVersion());
55
+ process.exit(0);
56
+ }
57
+
58
+ function updateHelp() {
59
+ console.log(`altalandi update — check for a newer version and update if you agree
60
+
61
+ Usage:
62
+ altalandi update check, then ask before updating the CLI and the /altalandi skill
63
+ altalandi update --check only report; never change anything
64
+ altalandi update --yes update without asking (for scripts that already have consent)
65
+
66
+ Nothing changes without consent. Without a terminal to ask in (agents, CI,
67
+ pipes) and without --yes, it only reports what is available.
68
+
69
+ Exit codes: 0 up to date or updated · 10 an update is available but was not
70
+ applied · 1 the check or the update failed.`);
71
+ }
72
+
73
+ if (!handled) {
15
74
  const opts = { domain: "general", register: "neutral", json: false, fast: false };
16
75
  const positional = [];
17
76
  for (let i = 0; i < args.length; i++) {
@@ -23,6 +82,7 @@ for (let i = 0; i < args.length; i++) {
23
82
  else if (a === "--register") opts.register = args[++i];
24
83
  else if (a === "--base-url") opts.baseUrl = args[++i];
25
84
  else if (a === "--key") opts.key = args[++i];
85
+ else if (a === "--file") opts.file = args[++i];
26
86
  else positional.push(a);
27
87
  }
28
88
 
@@ -30,16 +90,27 @@ function help() {
30
90
  console.log(`altalandi — native Icelandic from your terminal
31
91
 
32
92
  Usage:
93
+ altalandi login connect this computer to your account (once)
33
94
  altalandi "text to refine"
95
+ altalandi --file draft.txt read the text from a file (best for agents)
34
96
  echo "text" | altalandi
35
97
  altalandi --domain marketing --register warm "text"
36
98
 
99
+ Commands:
100
+ altalandi login open altalandi.is, sign in or sign up, approve; stores the key
101
+ and installs the Claude Code skill (--no-skill, --no-browser)
102
+ altalandi whoami show the account and balance behind the stored key
103
+ altalandi logout forget the stored key on this computer
104
+ altalandi update check for a newer version and update if you agree
105
+ altalandi --version print the installed version
106
+
37
107
  Options:
38
108
  --domain general | marketing | data-analysis | childrens-fiction (default general)
39
109
  --register formal | neutral | warm (default neutral)
40
110
  --fast no polish, no nativeness score, no meaning gate — normally one writer call
41
111
  (structured input can still add a structure-preserving retry)
42
112
  --json print the full JSON response
113
+ --file read the text from a UTF-8 file instead of an argument
43
114
  --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
44
115
 
45
116
  stdout = refined text; stderr = nativeness / meaning / warnings / diff.
@@ -56,16 +127,23 @@ function readStdin() {
56
127
  });
57
128
  }
58
129
 
59
- const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
60
- const key = opts.key || process.env.ALTALANDI_API_KEY || process.env.ALTALANDI_KEY;
130
+ const stored = readCredentials();
131
+ const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || stored?.baseUrl || "https://altalandi.is/api").replace(/\/$/, "");
132
+ const { key } = resolveKey({ flag: opts.key });
61
133
 
62
134
  if (opts.key) {
63
135
  console.error("⚠ --key is deprecated because command arguments can leak through shell history/process listings; load ALTALANDI_API_KEY instead.");
64
136
  }
65
137
 
66
- const text = (positional.join(" ").trim()) || (await readStdin()).trim();
138
+ let text = positional.join(" ").trim();
139
+ if (opts.file) {
140
+ const { readFile } = await import("node:fs/promises");
141
+ try { text = (await readFile(opts.file, "utf8")).replace(/^/, "").trim(); }
142
+ catch (e) { console.error(`Could not read ${opts.file}: ${e.message}`); process.exit(2); }
143
+ }
144
+ if (!text) text = (await readStdin()).trim();
67
145
  if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
68
- if (!key) { console.error("No API key. Load ALTALANDI_API_KEY securely (masked setup: https://altalandi.is/portal)."); process.exit(2); }
146
+ if (!key) { console.error("Not logged in. Run once: npx altalandi login (or set ALTALANDI_API_KEY)"); process.exit(2); }
69
147
 
70
148
  const requestOptions = {
71
149
  apiKey: key,
@@ -118,5 +196,8 @@ try {
118
196
  if (verificationFailed) process.exitCode = 3;
119
197
  } catch (e) {
120
198
  console.error("Request failed:", e.message);
121
- process.exit(1);
199
+ if (e.status === 402) console.error("→ Add credit at https://altalandi.is/portal (automatic top-up is available there).");
200
+ if (e.status === 401) console.error("→ The key is invalid or revoked. Run: npx altalandi login");
201
+ process.exit(e.status === 402 ? 4 : 1);
202
+ }
122
203
  }
package/auth.mjs ADDED
@@ -0,0 +1,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 out(`${me.username ?? "(lykill án aðgangs)"} · inneign ${me.balanceIsk ?? "—"} kr. · lykill ${me.keyId} (${source})`);
214
+ return 0;
215
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "altalandi",
3
- "version": "0.1.1",
4
- "description": "Hosted Altalandi API client and CLI for native-sounding Icelandic with meaning verification.",
3
+ "version": "0.3.0",
4
+ "description": "Native-sounding Icelandic from your terminal or AI agent: one-time login, a Claude Code skill, and meaning verification.",
5
5
  "type": "module",
6
6
  "main": "./client.mjs",
7
7
  "types": "./client.d.ts",
@@ -17,13 +17,16 @@
17
17
  },
18
18
  "files": [
19
19
  "altalandi.mjs",
20
+ "auth.mjs",
21
+ "update.mjs",
22
+ "skill/SKILL.md",
20
23
  "client.mjs",
21
24
  "client.d.ts",
22
25
  "README.md",
23
26
  "LICENSE"
24
27
  ],
25
28
  "scripts": {
26
- "test": "node --test test/cli.test.mjs test/client.test.mjs"
29
+ "test": "node --test test/cli.test.mjs test/client.test.mjs test/update.test.mjs test/auth.test.mjs"
27
30
  },
28
31
  "keywords": [
29
32
  "icelandic",
package/skill/SKILL.md ADDED
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: altalandi
3
+ description: >-
4
+ Turn English or stiff, translated or AI-written Icelandic into natural native
5
+ Icelandic through the Altalandi API, with a meaning check and a diff of every
6
+ change. Use when the user mentions Altalandi, or asks for text in proper,
7
+ natural, correct or native Icelandic ("á góðri íslensku", "á réttri íslensku",
8
+ "láttu þetta hljóma eðlilega á íslensku", "translate this into proper
9
+ Icelandic"), including website copy, UI strings, emails and documents meant for
10
+ Icelandic readers. Not for incidental Icelandic in conversation.
11
+ ---
12
+
13
+ # Altalandi: native Icelandic, with a meaning check
14
+
15
+ Altalandi rewrites English or stiff/AI-translated Icelandic into Icelandic that
16
+ reads as if a native wrote it. It checks that the meaning survived (numbers,
17
+ dates, names, negation, obligations and a back-translation) and returns every
18
+ change with a reason. Each call is paid from the user's Altalandi credit.
19
+
20
+ ## 1. Make sure this computer is connected (once)
21
+
22
+ Everything goes through the `altalandi` CLI, which finds the user's key by itself.
23
+ If a call prints `Not logged in` (exit code 2), connect the computer:
24
+
25
+ 1. Run `npx -y altalandi@latest login` **in the background** (it waits up to 10
26
+ minutes for the user).
27
+ 2. Read its output as soon as it appears. It prints a link
28
+ (`https://altalandi.is/tengja?kodi=XXXX-XXXX`) and a code, and opens the
29
+ browser. Show the user the link and the code, and tell them to sign in or
30
+ create an account (new accounts get 500 kr. of trial credit) and click
31
+ **Tengja**.
32
+ 3. Wait for the command to finish. `✓ Tengt sem <username>` means done. Then
33
+ continue with the task.
34
+
35
+ Never ask the user to paste an API key into the chat. If they have one and
36
+ prefer it, they can set `ALTALANDI_API_KEY` themselves.
37
+
38
+ ## 2. Refine text
39
+
40
+ Write the text to a UTF-8 temporary file and pass the file. Never put the text on
41
+ the command line: quotes, `$`, backticks and Icelandic characters break shell
42
+ quoting.
43
+
44
+ ```bash
45
+ npx -y altalandi@latest --json --file /tmp/altalandi-in.txt
46
+ ```
47
+
48
+ Options: `--domain general|marketing|data-analysis|childrens-fiction`,
49
+ `--register formal|neutral|warm`. Pick `marketing` for website and product copy,
50
+ `formal` for official or legal text, `warm` for friendly customer messages.
51
+
52
+ One call per coherent text (a page section, an email, one UI string). For many
53
+ strings, run them one by one or at most 4 at a time, and keep keys, placeholders
54
+ (`{name}`, `%s`), HTML and code exactly as they were: send only the human text.
55
+
56
+ ## 3. Read the result correctly
57
+
58
+ | Exit code | Meaning | What to do |
59
+ |---|---|---|
60
+ | 0 | done and verified | use `output`, show the diffs |
61
+ | 3 | meaning not verified: JSON still printed | read the table below; don't present it as verified |
62
+ | 4 | out of credit (`402 insufficient_balance`) | stop; tell the user to add credit at https://altalandi.is/portal. Don't retry |
63
+ | 2 | not logged in / no input | step 1 |
64
+ | 1 | other error (network, 429 usage limit) | on 429 wait for the time given and retry once; otherwise report it |
65
+
66
+ For Icelandic input, one of three things happened. Tell them apart before using `output`:
67
+
68
+ | Outcome | How to recognise it |
69
+ |---|---|
70
+ | **Rewritten and verified** | `output` differs from the input, `meaningPreserved: true`, `rejectionReason` empty |
71
+ | **Rewrite refused, original returned** | `rewriteAttempted: true` and `rejectionReason` set (`MEANING DRIFT`, `HARD INVARIANT LOST`, `UNVERIFIED`, `CANDIDATE VETO`) |
72
+ | **Already fine** | `rewriteAttempted: false` |
73
+
74
+ - `meaningPreserved` alone is not enough: a vetoed rewrite returns the original with
75
+ `meaningPreserved: true`. Check `rewriteAttempted` and `rejectionReason`.
76
+ - `meaningPreserved: null` means unverified, not safe. For English input the
77
+ translation still comes back; say it is unverified.
78
+ - When the original comes back, say so. Don't claim Altalandi improved it.
79
+ - Always show what changed (`diffs`: before, after, reason). Never silently replace
80
+ the user's text. Apply changes to files only after showing them, unless the user
81
+ asked for that directly.
82
+ - `quality.nativenessScore` is a hint, not a verdict. It has been confidently wrong
83
+ in both directions; never use it to override a refusal.
84
+
85
+ ## 4. Good to know
86
+
87
+ - Altalandi rewrites for natural Icelandic. If the user only wants spelling and
88
+ grammar fixed and every other word left alone, tell them it may rephrase, and
89
+ review the diffs with them.
90
+ - `npx -y altalandi@latest whoami` shows the connected account and its credit.
91
+ - Leave code, identifiers, legal quotations and anything that must stay verbatim
92
+ out of the text you send.
93
+ - Don't use `--fast` for text people will read: it skips the meaning check.
94
+ - Text is processed and discarded, not stored or used for training.
95
+ - Docs for integrating the API into an app: https://altalandi.is/agents.md
package/update.mjs ADDED
@@ -0,0 +1,271 @@
1
+ // `altalandi update` — check for a newer CLI, ask before installing it, then
2
+ // offer to install or refresh the /altalandi skill for Claude Code.
3
+ //
4
+ // Nothing is ever changed without consent. Interactive terminals get a y/N
5
+ // prompt; non-interactive callers (agents, CI, pipes) are only told what is
6
+ // available, unless they pass --yes, which is itself the explicit consent.
7
+ //
8
+ // Zero dependencies, like the rest of the CLI.
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { existsSync, realpathSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ export const PACKAGE = "altalandi";
19
+ export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
20
+
21
+ // Exit codes. 10 is deliberately distinct from the refine command's 1/2/3 so a
22
+ // script can tell "an update exists" apart from "something failed".
23
+ export const EXIT_OK = 0;
24
+ export const EXIT_CHECK_FAILED = 1;
25
+ export const EXIT_UPDATE_AVAILABLE = 10;
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+ export const BUNDLED_SKILL = path.join(HERE, "skill", "SKILL.md");
29
+
30
+ export async function currentVersion() {
31
+ const pkg = JSON.parse(await readFile(path.join(HERE, "package.json"), "utf8"));
32
+ return pkg.version;
33
+ }
34
+
35
+ /** Semver-ish compare: -1 if a<b, 0 if equal, 1 if a>b. A prerelease sorts
36
+ * before its release (0.2.0-beta < 0.2.0). */
37
+ export function compareVersions(a, b) {
38
+ const parse = (v) => {
39
+ const [core, pre = ""] = String(v).trim().replace(/^v/, "").split("-", 2);
40
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
41
+ };
42
+ const x = parse(a);
43
+ const y = parse(b);
44
+ for (let i = 0; i < 3; i++) {
45
+ const d = (x.nums[i] || 0) - (y.nums[i] || 0);
46
+ if (d !== 0) return d < 0 ? -1 : 1;
47
+ }
48
+ if (x.pre === y.pre) return 0;
49
+ if (!x.pre) return 1;
50
+ if (!y.pre) return -1;
51
+ return x.pre < y.pre ? -1 : 1;
52
+ }
53
+
54
+ export async function fetchLatestVersion({ fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
57
+ try {
58
+ const res = await fetchImpl(REGISTRY_URL, {
59
+ signal: controller.signal,
60
+ headers: { accept: "application/json" },
61
+ });
62
+ if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
63
+ const body = JSON.parse(await res.text());
64
+ if (!body || typeof body.version !== "string") throw new Error("npm registry returned no version");
65
+ return body.version;
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+
71
+ const norm = (p) => {
72
+ let out = path.resolve(p);
73
+ try { out = realpathSync(out); } catch { /* keep the resolved path */ }
74
+ return process.platform === "win32" ? out.toLowerCase() : out;
75
+ };
76
+
77
+ /** How is this copy of the CLI being run? That decides what "update" means.
78
+ *
79
+ * npx — lives in npm's _npx cache; there is nothing to install, but a bare
80
+ * `npx altalandi` can keep reusing an old cached copy
81
+ * global — `npm install -g altalandi`
82
+ * local — a project dependency
83
+ * source — a checkout of the repository; npm cannot update that
84
+ */
85
+ export function detectInstall({ scriptPath, globalRoot }) {
86
+ const p = norm(scriptPath);
87
+ const sep = path.sep === "\\" ? /[\\/]/ : /\//;
88
+ const parts = p.split(sep);
89
+ if (parts.includes("_npx")) return "npx";
90
+ const nm = parts.lastIndexOf("node_modules");
91
+ if (nm === -1) return "source";
92
+ if (globalRoot && p.startsWith(norm(globalRoot) + (process.platform === "win32" ? "\\" : "/"))) {
93
+ return "global";
94
+ }
95
+ return "local";
96
+ }
97
+
98
+ /** The exact command that updates this install. Constants only — never built
99
+ * from user input. */
100
+ export function updatePlan(kind, { projectDir } = {}) {
101
+ switch (kind) {
102
+ case "global":
103
+ return { cmd: "npm", args: ["install", "-g", `${PACKAGE}@latest`], show: `npm install -g ${PACKAGE}@latest` };
104
+ case "local":
105
+ return { cmd: "npm", args: ["install", `${PACKAGE}@latest`], cwd: projectDir, show: `npm install ${PACKAGE}@latest` };
106
+ case "npx":
107
+ return { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "--version"], show: `npx ${PACKAGE}@latest` };
108
+ default:
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** Where Claude Code looks for personal skills. */
114
+ export function skillTarget({ env = process.env, home = homedir() } = {}) {
115
+ const root = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
116
+ return { root, file: path.join(root, "skills", PACKAGE, "SKILL.md") };
117
+ }
118
+
119
+ /** missing | current | outdated. `absent` means Claude Code itself is not set
120
+ * up, in which case the skill is not offered at all — never create ~/.claude
121
+ * for someone who does not use it. */
122
+ export async function skillStatus({ bundled = BUNDLED_SKILL, target = skillTarget() } = {}) {
123
+ if (!existsSync(bundled)) return "unbundled";
124
+ if (!existsSync(target.root)) return "absent";
125
+ if (!existsSync(target.file)) return "missing";
126
+ const [a, b] = await Promise.all([readFile(bundled, "utf8"), readFile(target.file, "utf8")]);
127
+ const clean = (s) => s.replace(/\r\n/g, "\n").trim();
128
+ return clean(a) === clean(b) ? "current" : "outdated";
129
+ }
130
+
131
+ /** Install or refresh the skill. An existing copy is always backed up first:
132
+ * people edit their skills, and an update must never silently eat that. */
133
+ export async function writeSkill({ bundled = BUNDLED_SKILL, target = skillTarget(), now = new Date() } = {}) {
134
+ await mkdir(path.dirname(target.file), { recursive: true });
135
+ let backup = null;
136
+ if (existsSync(target.file)) {
137
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
138
+ backup = `${target.file}.bak-${stamp}`;
139
+ await copyFile(target.file, backup);
140
+ }
141
+ await writeFile(target.file, await readFile(bundled, "utf8"), "utf8");
142
+ return { file: target.file, backup };
143
+ }
144
+
145
+ function run(plan) {
146
+ return new Promise((resolve) => {
147
+ // npm and npx are .cmd shims on Windows, which need a shell. The arguments
148
+ // are constants from updatePlan(), so there is nothing to inject.
149
+ const child = spawn(plan.cmd, plan.args, {
150
+ cwd: plan.cwd,
151
+ stdio: "inherit",
152
+ shell: process.platform === "win32",
153
+ });
154
+ child.on("error", () => resolve(1));
155
+ child.on("close", (code) => resolve(code ?? 1));
156
+ });
157
+ }
158
+
159
+ function npmGlobalRoot() {
160
+ return new Promise((resolve) => {
161
+ const child = spawn("npm", ["root", "-g"], { shell: process.platform === "win32" });
162
+ let out = "";
163
+ child.stdout?.setEncoding("utf8").on("data", (c) => { out += c; });
164
+ child.on("error", () => resolve(null));
165
+ child.on("close", () => resolve(out.trim() || null));
166
+ });
167
+ }
168
+
169
+ async function ask(question, { input = process.stdin, output = process.stderr } = {}) {
170
+ const rl = createInterface({ input, output });
171
+ try {
172
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
173
+ return answer === "y" || answer === "yes" || answer === "j" || answer === "já";
174
+ } finally {
175
+ rl.close();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The whole command. Every side effect is injectable so it can be tested
181
+ * without touching npm, the network or the real home directory.
182
+ */
183
+ export async function runUpdate(flags, io = {}) {
184
+ const log = io.log || ((m) => process.stderr.write(`${m}\n`));
185
+ const interactive = io.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
186
+ const confirm = io.confirm || ask;
187
+ const execute = io.run || run;
188
+ const target = io.skillTarget || skillTarget();
189
+ const bundled = io.bundledSkill || BUNDLED_SKILL;
190
+
191
+ // --check never changes anything. Without a terminal and without --yes there
192
+ // is nobody to ask, so the same rule applies: report, don't act.
193
+ const mayAct = !flags.check && (flags.yes || interactive);
194
+ const consent = async (q) => (flags.yes ? true : confirm(q));
195
+
196
+ const installed = io.currentVersion || await currentVersion();
197
+
198
+ if (!flags.skillOnly) {
199
+ let latest;
200
+ try {
201
+ latest = io.latestVersion || await fetchLatestVersion({ fetchImpl: io.fetch });
202
+ } catch (e) {
203
+ log(`Could not check for updates: ${e.message}`);
204
+ return EXIT_CHECK_FAILED;
205
+ }
206
+
207
+ if (compareVersions(installed, latest) < 0) {
208
+ const scriptPath = io.scriptPath || fileURLToPath(new URL("./altalandi.mjs", import.meta.url));
209
+ // `npm root -g` spawns npm, so only ask it when the answer matters: a copy
210
+ // inside some node_modules that is not npx's cache.
211
+ const inNodeModules = /[\\/]node_modules[\\/]/.test(scriptPath) && !/[\\/]_npx[\\/]/.test(scriptPath);
212
+ const globalRoot = io.globalRoot !== undefined ? io.globalRoot
213
+ : (inNodeModules ? await (io.npmGlobalRoot || npmGlobalRoot)() : null);
214
+ const kind = io.installKind || detectInstall({ scriptPath, globalRoot });
215
+ const plan = updatePlan(kind, { projectDir: io.projectDir || process.cwd() });
216
+ log(`A new version of ${PACKAGE} is available: ${installed} → ${latest}`);
217
+
218
+ if (!plan) {
219
+ log("This copy runs from a source checkout, so npm cannot update it. Pull the repository instead.");
220
+ return EXIT_UPDATE_AVAILABLE;
221
+ }
222
+ if (!mayAct) {
223
+ log(`Update with: ${plan.show}`);
224
+ return EXIT_UPDATE_AVAILABLE;
225
+ }
226
+ if (!(await consent(`Update ${PACKAGE} to ${latest} now?`))) {
227
+ log(`Skipped. Update later with: ${plan.show}`);
228
+ return EXIT_UPDATE_AVAILABLE;
229
+ }
230
+ log(`Running: ${plan.show}`);
231
+ const code = await execute(plan);
232
+ if (code !== 0) {
233
+ log(`Update failed (exit ${code}). Try running it yourself: ${plan.show}`);
234
+ return EXIT_CHECK_FAILED;
235
+ }
236
+ log(`Updated to ${latest}.`);
237
+ // The skill that ships with the NEW version is the one worth installing,
238
+ // and this process is still the old one. Hand over to the new CLI.
239
+ const handoff = kind === "npx"
240
+ ? { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] }
241
+ : { cmd: PACKAGE, args: ["update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] };
242
+ await execute(handoff);
243
+ return EXIT_OK;
244
+ }
245
+ log(`${PACKAGE} ${installed} is up to date.`);
246
+ }
247
+
248
+ const status = await skillStatus({ bundled, target });
249
+ if (status === "unbundled" || status === "absent") return EXIT_OK;
250
+ if (status === "current") {
251
+ log("The /altalandi skill for Claude Code is up to date.");
252
+ return EXIT_OK;
253
+ }
254
+ const what = status === "missing"
255
+ ? `Install the /altalandi skill for Claude Code (${target.file})?`
256
+ : "Your /altalandi skill for Claude Code differs from this version. Replace it (your copy is backed up first)?";
257
+ if (!mayAct) {
258
+ log(status === "missing"
259
+ ? "The /altalandi skill for Claude Code is not installed. Run `altalandi update` in a terminal to install it."
260
+ : "Your /altalandi skill for Claude Code is out of date. Run `altalandi update` in a terminal to refresh it.");
261
+ return flags.skillOnly ? EXIT_OK : EXIT_UPDATE_AVAILABLE;
262
+ }
263
+ if (!(await consent(what))) {
264
+ log("Skill left unchanged.");
265
+ return EXIT_OK;
266
+ }
267
+ const written = await writeSkill({ bundled, target, now: io.now });
268
+ log(`Skill ${status === "missing" ? "installed" : "updated"}: ${written.file}`);
269
+ if (written.backup) log(`Previous copy saved as: ${written.backup}`);
270
+ return EXIT_OK;
271
+ }