altalandi 0.1.1 → 0.2.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 +21 -0
- package/altalandi.mjs +47 -0
- package/package.json +4 -2
- package/skill/SKILL.md +105 -0
- package/update.mjs +271 -0
package/README.md
CHANGED
|
@@ -31,6 +31,27 @@ For an application dependency, install locally and import the package root:
|
|
|
31
31
|
npm i altalandi
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## Updating
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
altalandi update # check, then ask before updating
|
|
38
|
+
altalandi update --check # only report; change nothing
|
|
39
|
+
altalandi --version
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`altalandi update` checks npm for a newer version and asks before installing
|
|
43
|
+
it. It works out how you installed the CLI (global, project dependency or `npx`)
|
|
44
|
+
and runs the matching command. If you use Claude Code, it then offers to install
|
|
45
|
+
or refresh the `/altalandi` skill that ships with each version. A skill you have
|
|
46
|
+
edited is backed up before it is replaced.
|
|
47
|
+
|
|
48
|
+
Nothing changes without your answer. Without a terminal to ask in (agents, CI,
|
|
49
|
+
pipes) it only reports what is available; pass `--yes` when a script already has
|
|
50
|
+
your consent.
|
|
51
|
+
|
|
52
|
+
Exit codes: `0` up to date or updated · `10` an update is available but was not
|
|
53
|
+
applied · `1` the check or the update failed.
|
|
54
|
+
|
|
34
55
|
## JavaScript / TypeScript client
|
|
35
56
|
|
|
36
57
|
`refineVerified` requests the meaning gate and throws unless the API returns
|
package/altalandi.mjs
CHANGED
|
@@ -12,6 +12,48 @@
|
|
|
12
12
|
import { refine } from "./client.mjs";
|
|
13
13
|
|
|
14
14
|
const args = process.argv.slice(2);
|
|
15
|
+
|
|
16
|
+
// Subcommands and meta flags are handled before anything that needs an API key
|
|
17
|
+
// or input text. `altalandi update` checks for a newer CLI and skill and asks
|
|
18
|
+
// before changing anything.
|
|
19
|
+
let handled = false;
|
|
20
|
+
if (args[0] === "update") {
|
|
21
|
+
handled = true;
|
|
22
|
+
const { runUpdate } = await import("./update.mjs");
|
|
23
|
+
const rest = args.slice(1);
|
|
24
|
+
if (rest.includes("-h") || rest.includes("--help")) { updateHelp(); process.exit(0); }
|
|
25
|
+
// exitCode, never process.exit(): the registry request can leave a socket
|
|
26
|
+
// mid-close, and exiting then aborts Node on Windows with a libuv assertion
|
|
27
|
+
// (UV_HANDLE_CLOSING). The refine path below exits the same way for the
|
|
28
|
+
// same reason.
|
|
29
|
+
process.exitCode = await runUpdate({
|
|
30
|
+
check: rest.includes("--check"),
|
|
31
|
+
yes: rest.includes("--yes") || rest.includes("-y"),
|
|
32
|
+
skillOnly: rest.includes("--skill-only"),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!handled && (args.includes("-v") || args.includes("--version"))) {
|
|
36
|
+
const { currentVersion } = await import("./update.mjs");
|
|
37
|
+
console.log(await currentVersion());
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateHelp() {
|
|
42
|
+
console.log(`altalandi update — check for a newer version and update if you agree
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
altalandi update check, then ask before updating the CLI and the /altalandi skill
|
|
46
|
+
altalandi update --check only report; never change anything
|
|
47
|
+
altalandi update --yes update without asking (for scripts that already have consent)
|
|
48
|
+
|
|
49
|
+
Nothing changes without consent. Without a terminal to ask in (agents, CI,
|
|
50
|
+
pipes) and without --yes, it only reports what is available.
|
|
51
|
+
|
|
52
|
+
Exit codes: 0 up to date or updated · 10 an update is available but was not
|
|
53
|
+
applied · 1 the check or the update failed.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!handled) {
|
|
15
57
|
const opts = { domain: "general", register: "neutral", json: false, fast: false };
|
|
16
58
|
const positional = [];
|
|
17
59
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -34,6 +76,10 @@ Usage:
|
|
|
34
76
|
echo "text" | altalandi
|
|
35
77
|
altalandi --domain marketing --register warm "text"
|
|
36
78
|
|
|
79
|
+
Commands:
|
|
80
|
+
altalandi update check for a newer version and update if you agree
|
|
81
|
+
altalandi --version print the installed version
|
|
82
|
+
|
|
37
83
|
Options:
|
|
38
84
|
--domain general | marketing | data-analysis | childrens-fiction (default general)
|
|
39
85
|
--register formal | neutral | warm (default neutral)
|
|
@@ -120,3 +166,4 @@ try {
|
|
|
120
166
|
console.error("Request failed:", e.message);
|
|
121
167
|
process.exit(1);
|
|
122
168
|
}
|
|
169
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "altalandi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Hosted Altalandi API client and CLI for native-sounding Icelandic with meaning verification.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./client.mjs",
|
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"altalandi.mjs",
|
|
20
|
+
"update.mjs",
|
|
21
|
+
"skill/SKILL.md",
|
|
20
22
|
"client.mjs",
|
|
21
23
|
"client.d.ts",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE"
|
|
24
26
|
],
|
|
25
27
|
"scripts": {
|
|
26
|
-
"test": "node --test test/cli.test.mjs test/client.test.mjs"
|
|
28
|
+
"test": "node --test test/cli.test.mjs test/client.test.mjs test/update.test.mjs"
|
|
27
29
|
},
|
|
28
30
|
"keywords": [
|
|
29
31
|
"icelandic",
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: altalandi
|
|
3
|
+
description: >-
|
|
4
|
+
MANUAL COMMAND — only run when the user explicitly invokes /altalandi. Do NOT
|
|
5
|
+
trigger automatically, and do not use it proactively on Icelandic writing tasks.
|
|
6
|
+
When invoked: refine English or stiff/AI-translated Icelandic into native
|
|
7
|
+
Icelandic via the Altalandi API, with a meaning-preservation check.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Altalandi — native Icelandic, with a meaning guarantee
|
|
11
|
+
|
|
12
|
+
Altalandi is a guarded pipeline, not a single model call: a frontier LLM rewrites
|
|
13
|
+
to native register, a rule proofreader cleans up, then a validation gate checks
|
|
14
|
+
meaning (back-translation **plus** deterministic invariants — numbers, dates,
|
|
15
|
+
negation, legal force, names) and a **candidate veto** refuses any rewrite the
|
|
16
|
+
pipeline itself has flagged as broken (invented words, a quoted defect, mojibake).
|
|
17
|
+
Every change comes back as a diff with the reason.
|
|
18
|
+
|
|
19
|
+
## How to call it
|
|
20
|
+
|
|
21
|
+
When the user runs `/altalandi`, refine the target text via the API.
|
|
22
|
+
|
|
23
|
+
1. **CLI, always with `--json`** — `npx altalandi --json "<text>"`. Flags:
|
|
24
|
+
`--domain`, `--register`. Needs `ALTALANDI_API_KEY` in the env.
|
|
25
|
+
**Use `--json` from an agent.** When a rewrite is refused, the plain-text mode
|
|
26
|
+
prints the reason as a `⚠` warning on stderr, but it also prints "meaning
|
|
27
|
+
preserved" and exits 0 — easy for an agent to misread as success. The JSON
|
|
28
|
+
carries `rewriteAttempted` and `rejectionReason`, which are unambiguous.
|
|
29
|
+
Run `npx altalandi update` now and then to get the latest CLI and this skill.
|
|
30
|
+
2. **HTTP** — `POST https://altalandi.is/api/v1/refine` with
|
|
31
|
+
`Authorization: Bearer $ALTALANDI_API_KEY` and body `{ "text": "...", ... }`.
|
|
32
|
+
|
|
33
|
+
(Do NOT rely on an MCP tool here — an MCP `refine_icelandic` tool is always available
|
|
34
|
+
to the model, which defeats manual-only invocation. This command calls the CLI/HTTP
|
|
35
|
+
directly so it only runs when you ask.)
|
|
36
|
+
|
|
37
|
+
Auth: set **`ALTALANDI_API_KEY`** once in the shell env (or your project's `.env`).
|
|
38
|
+
Existing `rtv_…` keys keep working — there is no rotation. Mint or view keys at
|
|
39
|
+
https://altalandi.is/portal. Machine-readable docs: https://altalandi.is/llms-full.txt
|
|
40
|
+
and https://altalandi.is/api/openapi.json.
|
|
41
|
+
|
|
42
|
+
## Parameters
|
|
43
|
+
|
|
44
|
+
- `text` (required) — the text to refine (English or Icelandic; auto-detected).
|
|
45
|
+
- `domain` — `general` | `marketing` | `data-analysis` | `childrens-fiction`.
|
|
46
|
+
- `register` — `formal` | `neutral` | `warm`.
|
|
47
|
+
- `sourceLang` — `auto` (default) | `en` | `is`.
|
|
48
|
+
|
|
49
|
+
## The non-negotiable contract — read the outcome, not one field
|
|
50
|
+
|
|
51
|
+
An Icelandic→Icelandic call ends in one of three ways. Tell them apart before you
|
|
52
|
+
use the output:
|
|
53
|
+
|
|
54
|
+
| outcome | how to recognise it |
|
|
55
|
+
|---|---|
|
|
56
|
+
| **Rewritten and verified** | `output` differs from your text, `meaningPreserved: true`, `rejectionReason` empty |
|
|
57
|
+
| **Rewrite refused — original returned** | `rewriteAttempted: true` and `rejectionReason` set. Causes: `MEANING DRIFT`, `HARD INVARIANT LOST`, `UNVERIFIED`, or `CANDIDATE VETO` (then `candidateVetoFired: true`, `candidateVetoSignal` says why) |
|
|
58
|
+
| **Already fine — nothing to change** | `rewriteAttempted: false` |
|
|
59
|
+
|
|
60
|
+
- **`meaningPreserved` alone is not enough.** A vetoed rewrite returns your
|
|
61
|
+
original with `meaningPreserved` still `true`: the meaning check passed, but the
|
|
62
|
+
rewrite was broken in another way. Check `rewriteAttempted` and `rejectionReason`.
|
|
63
|
+
- `meaningPreserved: null` means **unverified**, not safe. For Icelandic input the
|
|
64
|
+
original comes back; for English input the translation comes back marked
|
|
65
|
+
unverified — do not present it as verified.
|
|
66
|
+
- **When the original comes back, say so.** Don't report "Altalandi improved
|
|
67
|
+
this" when it declined to.
|
|
68
|
+
- **Show the diffs.** When you apply Altalandi's output, surface what changed and
|
|
69
|
+
why, never silently replace the user's text.
|
|
70
|
+
|
|
71
|
+
## The nativeness score is a hint, not a verdict
|
|
72
|
+
|
|
73
|
+
`quality.nativenessScore` and `translationeseTells` point at likely problems, but
|
|
74
|
+
the score has been measured to be confidently wrong in both directions: it gave a
|
|
75
|
+
rewrite containing an invented word 82/100, and a perfectly fine rewrite 18/100.
|
|
76
|
+
Treat a low score as "look at the tells", never as proof of bad Icelandic — and
|
|
77
|
+
never as permission to override a refusal. A tell that **quotes** a specific phrase
|
|
78
|
+
is far more trustworthy than the number.
|
|
79
|
+
|
|
80
|
+
## When to use it
|
|
81
|
+
|
|
82
|
+
- Producing or polishing user-facing Icelandic (web copy, UI strings, emails,
|
|
83
|
+
product text, children's prose).
|
|
84
|
+
- You translated something to Icelandic and want it to not "sound translated".
|
|
85
|
+
- Many strings at once: `POST /refine/batch`.
|
|
86
|
+
|
|
87
|
+
## When NOT to use it
|
|
88
|
+
|
|
89
|
+
- Non-Icelandic output. Code, identifiers, or data that must stay literal. Quotes
|
|
90
|
+
you must preserve verbatim (it may rephrase — check the diff).
|
|
91
|
+
|
|
92
|
+
## `--fast` removes the safety net
|
|
93
|
+
|
|
94
|
+
`--fast` (`scoreNativeness:false`, `scoring:"none"`) skips the meaning gate **and**
|
|
95
|
+
leaves the candidate veto blind: with no BÍN morphology check and no nativeness
|
|
96
|
+
tells, it cannot catch an invented or wrong word. Only mojibake is still caught.
|
|
97
|
+
Use it only for throwaway text you will read yourself — never for anything shipped.
|
|
98
|
+
|
|
99
|
+
## Notes
|
|
100
|
+
|
|
101
|
+
- Usage is metered by a daily **word budget** per key (default 10,000 words/day;
|
|
102
|
+
429 when spent, resets 00:00 UTC).
|
|
103
|
+
- A full call makes several model passes and is slower; that is the price of the
|
|
104
|
+
checks above.
|
|
105
|
+
- Data is processed and discarded — not stored or trained on.
|
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
|
+
}
|