altalandi 0.5.0 → 0.6.1

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/update.mjs CHANGED
@@ -1,271 +1,313 @@
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
- }
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 { createHash } from "node:crypto";
12
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
13
+ import { existsSync, realpathSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+ import { createInterface } from "node:readline/promises";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ export const PACKAGE = "altalandi";
20
+ export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
21
+
22
+ // Exit codes. 10 is deliberately distinct from the refine command's 1/2/3 so a
23
+ // script can tell "an update exists" apart from "something failed".
24
+ export const EXIT_OK = 0;
25
+ export const EXIT_CHECK_FAILED = 1;
26
+ export const EXIT_UPDATE_AVAILABLE = 10;
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ export const BUNDLED_SKILL = path.join(HERE, "skill", "SKILL.md");
30
+
31
+ export async function currentVersion() {
32
+ const pkg = JSON.parse(await readFile(path.join(HERE, "package.json"), "utf8"));
33
+ return pkg.version;
34
+ }
35
+
36
+ /** Semver-ish compare: -1 if a<b, 0 if equal, 1 if a>b. A prerelease sorts
37
+ * before its release (0.2.0-beta < 0.2.0). */
38
+ export function compareVersions(a, b) {
39
+ const parse = (v) => {
40
+ const [core, pre = ""] = String(v).trim().replace(/^v/, "").split("-", 2);
41
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
42
+ };
43
+ const x = parse(a);
44
+ const y = parse(b);
45
+ for (let i = 0; i < 3; i++) {
46
+ const d = (x.nums[i] || 0) - (y.nums[i] || 0);
47
+ if (d !== 0) return d < 0 ? -1 : 1;
48
+ }
49
+ if (x.pre === y.pre) return 0;
50
+ if (!x.pre) return 1;
51
+ if (!y.pre) return -1;
52
+ return x.pre < y.pre ? -1 : 1;
53
+ }
54
+
55
+ export async function fetchLatestVersion({ fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
58
+ try {
59
+ const res = await fetchImpl(REGISTRY_URL, {
60
+ signal: controller.signal,
61
+ headers: { accept: "application/json" },
62
+ });
63
+ if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
64
+ const body = JSON.parse(await res.text());
65
+ if (!body || typeof body.version !== "string") throw new Error("npm registry returned no version");
66
+ return body.version;
67
+ } finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+
72
+ const norm = (p) => {
73
+ let out = path.resolve(p);
74
+ try { out = realpathSync(out); } catch { /* keep the resolved path */ }
75
+ return process.platform === "win32" ? out.toLowerCase() : out;
76
+ };
77
+
78
+ /** How is this copy of the CLI being run? That decides what "update" means.
79
+ *
80
+ * npx — lives in npm's _npx cache; there is nothing to install, but a bare
81
+ * `npx altalandi` can keep reusing an old cached copy
82
+ * global — `npm install -g altalandi`
83
+ * local — a project dependency
84
+ * source — a checkout of the repository; npm cannot update that
85
+ */
86
+ export function detectInstall({ scriptPath, globalRoot }) {
87
+ const p = norm(scriptPath);
88
+ const sep = path.sep === "\\" ? /[\\/]/ : /\//;
89
+ const parts = p.split(sep);
90
+ if (parts.includes("_npx")) return "npx";
91
+ const nm = parts.lastIndexOf("node_modules");
92
+ if (nm === -1) return "source";
93
+ if (globalRoot && p.startsWith(norm(globalRoot) + (process.platform === "win32" ? "\\" : "/"))) {
94
+ return "global";
95
+ }
96
+ return "local";
97
+ }
98
+
99
+ /** The exact command that updates this install. Constants only — never built
100
+ * from user input. */
101
+ export function updatePlan(kind, { projectDir } = {}) {
102
+ switch (kind) {
103
+ case "global":
104
+ return { cmd: "npm", args: ["install", "-g", `${PACKAGE}@latest`], show: `npm install -g ${PACKAGE}@latest` };
105
+ case "local":
106
+ return { cmd: "npm", args: ["install", `${PACKAGE}@latest`], cwd: projectDir, show: `npm install ${PACKAGE}@latest` };
107
+ case "npx":
108
+ return { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "--version"], show: `npx ${PACKAGE}@latest` };
109
+ default:
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /** Where Claude Code looks for personal skills. */
115
+ export function skillTarget({ env = process.env, home = homedir() } = {}) {
116
+ const root = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
117
+ return { root, file: path.join(root, "skills", PACKAGE, "SKILL.md") };
118
+ }
119
+
120
+ /** missing | current | outdated. `absent` means Claude Code itself is not set
121
+ * up, in which case the skill is not offered at all — never create ~/.claude
122
+ * for someone who does not use it. */
123
+ export async function skillStatus({ bundled = BUNDLED_SKILL, target = skillTarget() } = {}) {
124
+ if (!existsSync(bundled)) return "unbundled";
125
+ if (!existsSync(target.root)) return "absent";
126
+ if (!existsSync(target.file)) return "missing";
127
+ const [a, b] = await Promise.all([readFile(bundled, "utf8"), readFile(target.file, "utf8")]);
128
+ const clean = (s) => s.replace(/\r\n/g, "\n").trim();
129
+ return clean(a) === clean(b) ? "current" : "outdated";
130
+ }
131
+
132
+ // Fingerprints (sha256 of the text with LF line ends, trimmed; first 16 hex)
133
+ // of every SKILL.md a published version shipped. A skill that matches one is
134
+ // ours, unedited, so it may be refreshed without asking; anything else was
135
+ // changed by its owner and is only reported. Add the old fingerprint here in
136
+ // every release that changes the skill.
137
+ export const SHIPPED_SKILLS = new Set([
138
+ "1a64ea93bfae71cc", // 0.2.0
139
+ "51920af235d23f02", // 0.3.0, 0.3.1
140
+ "f7de208557f519a4", // 0.4.0
141
+ "4bd64565d251641e", // 0.5.0
142
+ "aeb88010599539fd", // 0.6.0
143
+ ]);
144
+
145
+ export function skillFingerprint(text) {
146
+ return createHash("sha256").update(text.replace(/\r\n/g, "\n").trim()).digest("hex").slice(0, 16);
147
+ }
148
+
149
+ /** On every ordinary run: the skill calls `npx altalandi@latest`, so this is
150
+ * already the newest CLI. An installed skill that is an unedited older copy
151
+ * of ours is brought up to date (backed up first), so people get the new
152
+ * skill without doing anything; an edited one is left alone with a hint.
153
+ * Never throws, never creates ~/.claude. ALTALANDI_NO_SKILL_REFRESH=1 opts out. */
154
+ export async function refreshShippedSkill({ bundled = BUNDLED_SKILL, target = skillTarget(), env = process.env, log = () => {} } = {}) {
155
+ try {
156
+ if (env.ALTALANDI_NO_SKILL_REFRESH === "1") return "skipped";
157
+ const status = await skillStatus({ bundled, target });
158
+ if (status !== "outdated") return status;
159
+ if (!SHIPPED_SKILLS.has(skillFingerprint(await readFile(target.file, "utf8")))) {
160
+ log("Altalandi: your /altalandi skill has your own edits, so it was not updated. Run `npx altalandi@latest update` to refresh it.");
161
+ return "edited";
162
+ }
163
+ await writeSkill({ bundled, target });
164
+ log("Altalandi: the /altalandi skill was updated to the newest version (it applies from your next session).");
165
+ return "refreshed";
166
+ } catch {
167
+ return "failed";
168
+ }
169
+ }
170
+
171
+ /** Install or refresh the skill. An existing copy is always backed up first:
172
+ * people edit their skills, and an update must never silently eat that. */
173
+ export async function writeSkill({ bundled = BUNDLED_SKILL, target = skillTarget(), now = new Date() } = {}) {
174
+ await mkdir(path.dirname(target.file), { recursive: true });
175
+ let backup = null;
176
+ if (existsSync(target.file)) {
177
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
178
+ backup = `${target.file}.bak-${stamp}`;
179
+ await copyFile(target.file, backup);
180
+ }
181
+ await writeFile(target.file, await readFile(bundled, "utf8"), "utf8");
182
+ return { file: target.file, backup };
183
+ }
184
+
185
+ function run(plan) {
186
+ return new Promise((resolve) => {
187
+ // npm and npx are .cmd shims on Windows, which need a shell. The arguments
188
+ // are constants from updatePlan(), so there is nothing to inject.
189
+ const child = spawn(plan.cmd, plan.args, {
190
+ cwd: plan.cwd,
191
+ stdio: "inherit",
192
+ shell: process.platform === "win32",
193
+ });
194
+ child.on("error", () => resolve(1));
195
+ child.on("close", (code) => resolve(code ?? 1));
196
+ });
197
+ }
198
+
199
+ function npmGlobalRoot() {
200
+ return new Promise((resolve) => {
201
+ const child = spawn("npm", ["root", "-g"], { shell: process.platform === "win32" });
202
+ let out = "";
203
+ child.stdout?.setEncoding("utf8").on("data", (c) => { out += c; });
204
+ child.on("error", () => resolve(null));
205
+ child.on("close", () => resolve(out.trim() || null));
206
+ });
207
+ }
208
+
209
+ /** A y/N question on stderr; only y/yes (or j/já) is a yes. Shared with the
210
+ * folder and site runs, which ask before sending anything. */
211
+ export async function ask(question, { input = process.stdin, output = process.stderr } = {}) {
212
+ const rl = createInterface({ input, output });
213
+ try {
214
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
215
+ return answer === "y" || answer === "yes" || answer === "j" || answer === "já";
216
+ } finally {
217
+ rl.close();
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The whole command. Every side effect is injectable so it can be tested
223
+ * without touching npm, the network or the real home directory.
224
+ */
225
+ export async function runUpdate(flags, io = {}) {
226
+ const log = io.log || ((m) => process.stderr.write(`${m}\n`));
227
+ const interactive = io.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
228
+ const confirm = io.confirm || ask;
229
+ const execute = io.run || run;
230
+ const target = io.skillTarget || skillTarget();
231
+ const bundled = io.bundledSkill || BUNDLED_SKILL;
232
+
233
+ // --check never changes anything. Without a terminal and without --yes there
234
+ // is nobody to ask, so the same rule applies: report, don't act.
235
+ const mayAct = !flags.check && (flags.yes || interactive);
236
+ const consent = async (q) => (flags.yes ? true : confirm(q));
237
+
238
+ const installed = io.currentVersion || await currentVersion();
239
+
240
+ if (!flags.skillOnly) {
241
+ let latest;
242
+ try {
243
+ latest = io.latestVersion || await fetchLatestVersion({ fetchImpl: io.fetch });
244
+ } catch (e) {
245
+ log(`Could not check for updates: ${e.message}`);
246
+ return EXIT_CHECK_FAILED;
247
+ }
248
+
249
+ if (compareVersions(installed, latest) < 0) {
250
+ const scriptPath = io.scriptPath || fileURLToPath(new URL("./altalandi.mjs", import.meta.url));
251
+ // `npm root -g` spawns npm, so only ask it when the answer matters: a copy
252
+ // inside some node_modules that is not npx's cache.
253
+ const inNodeModules = /[\\/]node_modules[\\/]/.test(scriptPath) && !/[\\/]_npx[\\/]/.test(scriptPath);
254
+ const globalRoot = io.globalRoot !== undefined ? io.globalRoot
255
+ : (inNodeModules ? await (io.npmGlobalRoot || npmGlobalRoot)() : null);
256
+ const kind = io.installKind || detectInstall({ scriptPath, globalRoot });
257
+ const plan = updatePlan(kind, { projectDir: io.projectDir || process.cwd() });
258
+ log(`A new version of ${PACKAGE} is available: ${installed} → ${latest}`);
259
+
260
+ if (!plan) {
261
+ log("This copy runs from a source checkout, so npm cannot update it. Pull the repository instead.");
262
+ return EXIT_UPDATE_AVAILABLE;
263
+ }
264
+ if (!mayAct) {
265
+ log(`Update with: ${plan.show}`);
266
+ return EXIT_UPDATE_AVAILABLE;
267
+ }
268
+ if (!(await consent(`Update ${PACKAGE} to ${latest} now?`))) {
269
+ log(`Skipped. Update later with: ${plan.show}`);
270
+ return EXIT_UPDATE_AVAILABLE;
271
+ }
272
+ log(`Running: ${plan.show}`);
273
+ const code = await execute(plan);
274
+ if (code !== 0) {
275
+ log(`Update failed (exit ${code}). Try running it yourself: ${plan.show}`);
276
+ return EXIT_CHECK_FAILED;
277
+ }
278
+ log(`Updated to ${latest}.`);
279
+ // The skill that ships with the NEW version is the one worth installing,
280
+ // and this process is still the old one. Hand over to the new CLI.
281
+ const handoff = kind === "npx"
282
+ ? { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] }
283
+ : { cmd: PACKAGE, args: ["update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] };
284
+ await execute(handoff);
285
+ return EXIT_OK;
286
+ }
287
+ log(`${PACKAGE} ${installed} is up to date.`);
288
+ }
289
+
290
+ const status = await skillStatus({ bundled, target });
291
+ if (status === "unbundled" || status === "absent") return EXIT_OK;
292
+ if (status === "current") {
293
+ log("The /altalandi skill for Claude Code is up to date.");
294
+ return EXIT_OK;
295
+ }
296
+ const what = status === "missing"
297
+ ? `Install the /altalandi skill for Claude Code (${target.file})?`
298
+ : "Your /altalandi skill for Claude Code differs from this version. Replace it (your copy is backed up first)?";
299
+ if (!mayAct) {
300
+ log(status === "missing"
301
+ ? "The /altalandi skill for Claude Code is not installed. Run `altalandi update` in a terminal to install it."
302
+ : "Your /altalandi skill for Claude Code is out of date. Run `altalandi update` in a terminal to refresh it.");
303
+ return flags.skillOnly ? EXIT_OK : EXIT_UPDATE_AVAILABLE;
304
+ }
305
+ if (!(await consent(what))) {
306
+ log("Skill left unchanged.");
307
+ return EXIT_OK;
308
+ }
309
+ const written = await writeSkill({ bundled, target, now: io.now });
310
+ log(`Skill ${status === "missing" ? "installed" : "updated"}: ${written.file}`);
311
+ if (written.backup) log(`Previous copy saved as: ${written.backup}`);
312
+ return EXIT_OK;
313
+ }