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 +32 -7
- package/altalandi.mjs +87 -6
- package/auth.mjs +215 -0
- package/package.json +6 -3
- package/skill/SKILL.md +95 -0
- package/update.mjs +271 -0
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
//
|
|
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
|
|
60
|
-
const
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|