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 +35 -0
- package/altalandi.mjs +76 -5
- package/auth.mjs +223 -215
- package/client.d.ts +117 -113
- package/client.mjs +209 -208
- package/package.json +3 -2
- package/report.mjs +280 -0
- package/skill/SKILL.md +93 -95
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
214
|
-
|
|
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
|
+
}
|