@tiens.nguyen/gu-cli 1.0.884 → 1.0.886
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 +3 -0
- package/gu-repl.mjs +179 -14
- package/hooks-config.mjs +3 -1
- package/package.json +4 -1
- package/resume-memory.mjs +85 -0
- package/skill-install.mjs +563 -0
- package/skill-package.mjs +455 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* INSTALL A PACKED SKILL (#281) — the other half of /skill-pack (skill-package.mjs).
|
|
4
|
+
*
|
|
5
|
+
* This one file is BOTH gu's installer (/skill-install imports it) AND the install.mjs that ships
|
|
6
|
+
* inside every package, so a machine without gu can still run `node install.mjs`. It must stay
|
|
7
|
+
* self-contained: Node 18+ built-ins only, no imports from the rest of gu.
|
|
8
|
+
*
|
|
9
|
+
* A package (a zip, or the same files unzipped):
|
|
10
|
+
* <name>/install.mjs this file
|
|
11
|
+
* <name>/skill.json the manifest (below)
|
|
12
|
+
* <name>/skill/… the skill's folder: SKILL.md and whatever sits next to it
|
|
13
|
+
* <name>/runtime/… optional — the code its MCP servers and hooks run (lived outside the
|
|
14
|
+
* skill folder on the machine it was packed on)
|
|
15
|
+
*
|
|
16
|
+
* skill.json = { format: 1, name, description, python?: { requirements: "requirements.txt" },
|
|
17
|
+
* mcpServers?: { <server>: { command, args, env } },
|
|
18
|
+
* hooks?: { <Claude event>: [ { matcher, hooks: [ { type, command, args, timeout } ] } ] },
|
|
19
|
+
* env?: [names asked for at install], notes?: [ … ] }
|
|
20
|
+
*
|
|
21
|
+
* Paths in the manifest and in the packed text files are placeholders, filled in here:
|
|
22
|
+
* {{SKILL}} the skill's folder (one per tool: ~/.gonext/skills/<name>, ~/.claude/skills/<name>)
|
|
23
|
+
* {{RUNTIME}} <base>/skill-runtime/<name>, <base> = ~/.gonext if gu is a target, else ~/.claude
|
|
24
|
+
* {{PYTHON}} the runtime's own venv python (built here from requirements.txt), else python3
|
|
25
|
+
* {{HOME}} the home folder; {{ENV:NAME}} NAME from the terminal's environment (never packed)
|
|
26
|
+
*
|
|
27
|
+
* Hooks are written in Claude Code's vocabulary. Claude gets them as they are, in
|
|
28
|
+
* ~/.claude/settings.json; gu gets them under its own event names with dialect "claude", so gu
|
|
29
|
+
* speaks Claude's input format to the same script (gu_hooks.py). A hook from an older install of
|
|
30
|
+
* the same script (same path inside the runtime) is replaced, not doubled.
|
|
31
|
+
*
|
|
32
|
+
* Nothing is overwritten without a copy: a skill folder, runtime or settings file that changes is
|
|
33
|
+
* first copied to <base>/skill-backups/<name>-<time>/. A config file that is not valid JSON is
|
|
34
|
+
* refused, never "repaired".
|
|
35
|
+
*/
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { dirname, join, resolve } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
import { inflateRawSync } from "node:zlib";
|
|
42
|
+
|
|
43
|
+
export const FORMAT = 1;
|
|
44
|
+
export const MANIFEST = "skill.json";
|
|
45
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
46
|
+
export const TARGETS = ["gu", "claude"];
|
|
47
|
+
/** Claude Code's hook events → gu's moments (hooks-config.mjs has the same table). */
|
|
48
|
+
export const CLAUDE_TO_GU = { SessionStart: "onStart", UserPromptSubmit: "onAsk", PostToolUse: "onToolDone", Stop: "onTurnEnd" };
|
|
49
|
+
/** Claude Code events gu has no moment for: installed for Claude Code only (#284). */
|
|
50
|
+
export const CLAUDE_ONLY_EVENTS = ["SessionEnd", "PreCompact"];
|
|
51
|
+
|
|
52
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
53
|
+
const slash = (p) => String(p).replace(/\\/g, "/");
|
|
54
|
+
|
|
55
|
+
// ---- zip (read) --------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const CRC_TABLE = (() => {
|
|
58
|
+
const t = new Uint32Array(256);
|
|
59
|
+
for (let n = 0; n < 256; n++) {
|
|
60
|
+
let c = n;
|
|
61
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
62
|
+
t[n] = c >>> 0;
|
|
63
|
+
}
|
|
64
|
+
return t;
|
|
65
|
+
})();
|
|
66
|
+
export function crc32(buf) {
|
|
67
|
+
let c = 0xffffffff;
|
|
68
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
69
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A path inside a package: relative, forward slashes, no "..", nothing absolute. */
|
|
73
|
+
export function safeRel(p) {
|
|
74
|
+
const s = slash(p).replace(/^\.\//, "");
|
|
75
|
+
if (!s || s.startsWith("/") || /^[A-Za-z]:/.test(s) || s.split("/").some((x) => x === ".." || x === "")) return null;
|
|
76
|
+
return s;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A zip's files → Map<path, { data, mode }>. Folders are left out; a damaged entry throws. */
|
|
80
|
+
export function readZip(buf) {
|
|
81
|
+
let end = -1;
|
|
82
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
|
|
83
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
84
|
+
end = i;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (end < 0) throw new Error("not a zip file");
|
|
89
|
+
const count = buf.readUInt16LE(end + 10);
|
|
90
|
+
let at = buf.readUInt32LE(end + 16);
|
|
91
|
+
const files = new Map();
|
|
92
|
+
for (let n = 0; n < count; n++) {
|
|
93
|
+
if (buf.readUInt32LE(at) !== 0x02014b50) throw new Error("the zip's file list is damaged");
|
|
94
|
+
const method = buf.readUInt16LE(at + 10);
|
|
95
|
+
const crc = buf.readUInt32LE(at + 16);
|
|
96
|
+
const size = buf.readUInt32LE(at + 20);
|
|
97
|
+
const nameLen = buf.readUInt16LE(at + 28);
|
|
98
|
+
const extraLen = buf.readUInt16LE(at + 30);
|
|
99
|
+
const commentLen = buf.readUInt16LE(at + 32);
|
|
100
|
+
const attrs = buf.readUInt32LE(at + 38);
|
|
101
|
+
const local = buf.readUInt32LE(at + 42);
|
|
102
|
+
const name = buf.toString("utf8", at + 46, at + 46 + nameLen);
|
|
103
|
+
at += 46 + nameLen + extraLen + commentLen;
|
|
104
|
+
if (name.endsWith("/")) continue;
|
|
105
|
+
const rel = safeRel(name);
|
|
106
|
+
if (!rel) throw new Error(`the zip has an unsafe path: ${name}`);
|
|
107
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
108
|
+
const packed = buf.subarray(start, start + size);
|
|
109
|
+
const data = method === 8 ? inflateRawSync(packed) : method === 0 ? Buffer.from(packed) : null;
|
|
110
|
+
if (!data) throw new Error(`${name}: compression method ${method} is not supported`);
|
|
111
|
+
if (crc32(data) !== crc) throw new Error(`${name} is damaged (CRC mismatch)`);
|
|
112
|
+
files.set(rel, { data, mode: (attrs >>> 16) & 0o777 });
|
|
113
|
+
}
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readTree(dir, base = dir, out = new Map()) {
|
|
118
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
119
|
+
const p = join(dir, e.name);
|
|
120
|
+
if (e.isDirectory()) readTree(p, base, out);
|
|
121
|
+
else if (e.isFile()) out.set(slash(p.slice(base.length + 1)), { data: readFileSync(p), mode: statSync(p).mode & 0o777 });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- the package -------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Check a manifest; → list of problems (empty = fine). */
|
|
129
|
+
export function manifestProblems(m) {
|
|
130
|
+
const out = [];
|
|
131
|
+
if (!isObj(m)) return ["skill.json is not a JSON object"];
|
|
132
|
+
if (m.format !== FORMAT) out.push(`skill.json format ${m.format} — this installer reads format ${FORMAT}`);
|
|
133
|
+
if (!NAME_RE.test(String(m.name ?? ""))) out.push(`"${m.name}" is not a valid skill name`);
|
|
134
|
+
if (m.mcpServers !== undefined && !isObj(m.mcpServers)) out.push("mcpServers must be an object");
|
|
135
|
+
for (const [k, v] of Object.entries(isObj(m.mcpServers) ? m.mcpServers : {})) {
|
|
136
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(k)) out.push(`MCP server name "${k}" is not valid`);
|
|
137
|
+
if (!isObj(v) || !String(v.command ?? "").trim()) out.push(`MCP server "${k}" has no command`);
|
|
138
|
+
}
|
|
139
|
+
if (m.hooks !== undefined && !isObj(m.hooks)) out.push("hooks must be an object");
|
|
140
|
+
for (const ev of Object.keys(isObj(m.hooks) ? m.hooks : {})) {
|
|
141
|
+
if (!(ev in CLAUDE_TO_GU) && !CLAUDE_ONLY_EVENTS.includes(ev)) out.push(`hook event "${ev}" is not one this installer knows (${[...Object.keys(CLAUDE_TO_GU), ...CLAUDE_ONLY_EVENTS].join(", ")})`);
|
|
142
|
+
}
|
|
143
|
+
if (m.python !== undefined && !(isObj(m.python) && safeRel(m.python.requirements ?? ""))) out.push("python.requirements must name a file in runtime/");
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A package from a zip or a folder → { manifest, skill: Map, runtime: Map }. Accepts the folder
|
|
149
|
+
* itself or a zip whose files sit under one top folder (as /skill-pack writes them).
|
|
150
|
+
*/
|
|
151
|
+
export function loadPackage(src) {
|
|
152
|
+
const st = statSync(src);
|
|
153
|
+
let files = st.isDirectory() ? readTree(src) : readZip(readFileSync(src));
|
|
154
|
+
if (!files.has(MANIFEST)) {
|
|
155
|
+
const tops = new Set([...files.keys()].map((k) => k.split("/")[0]));
|
|
156
|
+
const top = tops.size === 1 ? [...tops][0] : null;
|
|
157
|
+
if (!top || !files.has(`${top}/${MANIFEST}`)) throw new Error(`no ${MANIFEST} in ${src} — is it a gu skill package?`);
|
|
158
|
+
files = new Map([...files].map(([k, v]) => [k.slice(top.length + 1), v]));
|
|
159
|
+
}
|
|
160
|
+
let manifest;
|
|
161
|
+
try {
|
|
162
|
+
manifest = JSON.parse(files.get(MANIFEST).data.toString("utf8"));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
throw new Error(`${MANIFEST} is not valid JSON (${e.message})`);
|
|
165
|
+
}
|
|
166
|
+
const problems = manifestProblems(manifest);
|
|
167
|
+
if (problems.length) throw new Error(problems.join("; "));
|
|
168
|
+
const part = (prefix) => new Map([...files].filter(([k]) => k.startsWith(prefix)).map(([k, v]) => [k.slice(prefix.length), v]));
|
|
169
|
+
const skill = part("skill/");
|
|
170
|
+
if (!skill.has("SKILL.md")) throw new Error("the package has no skill/SKILL.md");
|
|
171
|
+
const runtime = part("runtime/");
|
|
172
|
+
if (manifest.python && !runtime.has(manifest.python.requirements)) throw new Error(`runtime/${manifest.python.requirements} is missing`);
|
|
173
|
+
return { manifest, skill, runtime };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- placeholders ------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Fill {{SKILL}} {{RUNTIME}} {{PYTHON}} {{HOME}} {{ENV:X}} in one string. */
|
|
179
|
+
export function fill(s, vars) {
|
|
180
|
+
return String(s).replace(/\{\{(SKILL|RUNTIME|PYTHON|HOME|ENV:([A-Za-z_][A-Za-z0-9_]*))\}\}/g, (all, k, env) =>
|
|
181
|
+
env ? (vars.env?.[env] ?? "") : vars[k.toLowerCase()] ?? all
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const isText = (buf) => !buf.subarray(0, 8192).includes(0);
|
|
185
|
+
/** Text files get placeholders filled with forward-slash paths (safe inside Python/JS strings on Windows too). */
|
|
186
|
+
function fillFile(f, vars) {
|
|
187
|
+
if (!isText(f.data)) return f.data;
|
|
188
|
+
const text = f.data.toString("utf8");
|
|
189
|
+
if (!text.includes("{{")) return f.data;
|
|
190
|
+
const v = { ...vars, skill: slash(vars.skill), runtime: slash(vars.runtime), python: slash(vars.python), home: slash(vars.home) };
|
|
191
|
+
return Buffer.from(fill(text, v), "utf8");
|
|
192
|
+
}
|
|
193
|
+
const fillDeep = (v, vars) =>
|
|
194
|
+
typeof v === "string" ? fill(v, vars) : Array.isArray(v) ? v.map((x) => fillDeep(x, vars)) : isObj(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fillDeep(x, vars)])) : v;
|
|
195
|
+
|
|
196
|
+
/** Every {{ENV:X}} the manifest uses. */
|
|
197
|
+
export function envNames(manifest) {
|
|
198
|
+
const names = new Set(Array.isArray(manifest.env) ? manifest.env.map(String) : []);
|
|
199
|
+
for (const m of JSON.stringify(manifest).matchAll(/\{\{ENV:([A-Za-z_][A-Za-z0-9_]*)\}\}/g)) names.add(m[1]);
|
|
200
|
+
return [...names];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- config files ------------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function readJsonFile(file) {
|
|
206
|
+
if (!existsSync(file)) return { data: {} };
|
|
207
|
+
try {
|
|
208
|
+
const data = JSON.parse(readFileSync(file, "utf8") || "{}");
|
|
209
|
+
return isObj(data) ? { data } : { error: "not a JSON object" };
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return { error: `not valid JSON (${e.message})` };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function writeJsonFile(file, data) {
|
|
215
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
216
|
+
let mode = 0o600;
|
|
217
|
+
try {
|
|
218
|
+
mode = statSync(file).mode & 0o777;
|
|
219
|
+
} catch {
|
|
220
|
+
/* new file: private */
|
|
221
|
+
}
|
|
222
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
223
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode });
|
|
224
|
+
renameSync(tmp, file);
|
|
225
|
+
}
|
|
226
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
227
|
+
|
|
228
|
+
/** The path a hook runs, inside its package: "hooks/memory_hook.py" for "{{RUNTIME}}/hooks/memory_hook.py". */
|
|
229
|
+
function hookKey(h) {
|
|
230
|
+
for (const a of [h.command, ...(Array.isArray(h.args) ? h.args : [])]) {
|
|
231
|
+
const m = String(a).match(/\{\{(?:RUNTIME|SKILL)\}\}[\\/](.+)$/);
|
|
232
|
+
if (m) return slash(m[1]);
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
/** Does an installed hook run the same script (by its path inside the runtime/skill)? */
|
|
237
|
+
function runsSame(installed, key) {
|
|
238
|
+
if (!key) return false;
|
|
239
|
+
return [installed.command, ...(Array.isArray(installed.args) ? installed.args : [])].some((a) => slash(a).endsWith("/" + key));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Add `groups` under `event` in a hooks object: an installed group running the same script is
|
|
244
|
+
* replaced (an older install, maybe from another folder); an identical one is left alone.
|
|
245
|
+
* → { hooks, replaced: [paths], added: n }
|
|
246
|
+
*/
|
|
247
|
+
export function mergeHooks(existing, event, groups, extra = {}) {
|
|
248
|
+
const hooks = isObj(existing) ? structuredClone(existing) : {};
|
|
249
|
+
const list = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
250
|
+
const replaced = [];
|
|
251
|
+
let added = 0;
|
|
252
|
+
for (const g of groups) {
|
|
253
|
+
const want = { matcher: g.matcher ?? "", hooks: g.hooks, ...extra };
|
|
254
|
+
if (list.some((x) => sameJson(x, want))) continue;
|
|
255
|
+
const keys = (g.keys ?? []).filter(Boolean);
|
|
256
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
257
|
+
const hs = Array.isArray(list[i]?.hooks) ? list[i].hooks : [];
|
|
258
|
+
if (hs.length && hs.every((h) => keys.some((k) => runsSame(h, k)))) {
|
|
259
|
+
replaced.push(...hs.map((h) => [h.command, ...(h.args ?? [])].join(" ")));
|
|
260
|
+
list.splice(i, 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
list.push(want);
|
|
264
|
+
added++;
|
|
265
|
+
}
|
|
266
|
+
hooks[event] = list;
|
|
267
|
+
return { hooks, replaced, added };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- the plan ----------------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
const venvPython = (dir, platform) => (platform === "win32" ? join(dir, ".venv", "Scripts", "python.exe") : join(dir, ".venv", "bin", "python"));
|
|
273
|
+
|
|
274
|
+
function sameTree(files, dir, vars) {
|
|
275
|
+
if (!existsSync(dir)) return false;
|
|
276
|
+
let have;
|
|
277
|
+
try {
|
|
278
|
+
have = readTree(dir);
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
for (const [k, f] of files) if (!have.has(k) || !have.get(k).data.equals(fillFile(f, vars))) return false;
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* What installing `pkg` would do → a plan for applyInstall and for showing the user. `targets`
|
|
288
|
+
* left out = every tool that is on this machine (its folder exists).
|
|
289
|
+
*/
|
|
290
|
+
export function planInstall(pkg, { home = homedir(), targets, platform = process.platform, env = process.env } = {}) {
|
|
291
|
+
const { manifest: m } = pkg;
|
|
292
|
+
const name = m.name;
|
|
293
|
+
const found = TARGETS.filter((t) => existsSync(join(home, t === "gu" ? ".gonext" : ".claude")));
|
|
294
|
+
const use = targets?.length ? targets : found;
|
|
295
|
+
if (!use.length) throw new Error("neither gu (~/.gonext) nor Claude Code (~/.claude) is set up here — pass --gu or --claude");
|
|
296
|
+
const base = join(home, use.includes("gu") ? ".gonext" : ".claude");
|
|
297
|
+
const runtime = join(base, "skill-runtime", name);
|
|
298
|
+
// platform-ok: only for a package whose command is {{PYTHON}} with no venv — "python" on Windows (not the Store stub's name).
|
|
299
|
+
const python = m.python ? venvPython(runtime, platform) : platform === "win32" ? "python" : "python3";
|
|
300
|
+
const envValues = {};
|
|
301
|
+
const missingEnv = [];
|
|
302
|
+
for (const k of envNames(m)) {
|
|
303
|
+
if (env[k]) envValues[k] = env[k];
|
|
304
|
+
else missingEnv.push(k);
|
|
305
|
+
}
|
|
306
|
+
const warnings = [];
|
|
307
|
+
const items = [];
|
|
308
|
+
const runtimeVars = { skill: join(home, ".gonext", "skills", name), runtime, python, home, env: envValues };
|
|
309
|
+
const runtimeStatus = !pkg.runtime.size ? "same" : sameTree(pkg.runtime, runtime, runtimeVars) ? "same" : existsSync(runtime) ? "differs" : "new";
|
|
310
|
+
if (pkg.runtime.size) items.push({ kind: "runtime", to: runtime, status: runtimeStatus, files: pkg.runtime.size });
|
|
311
|
+
// The venv is (re)built when it is missing or the runtime (so maybe requirements.txt) changed.
|
|
312
|
+
if (m.python) items.push({ kind: "python", to: runtime, requirements: m.python.requirements, status: runtimeStatus === "same" && existsSync(python) ? "same" : "new" });
|
|
313
|
+
for (const t of use) {
|
|
314
|
+
const dot = t === "gu" ? ".gonext" : ".claude";
|
|
315
|
+
const skillDir = join(home, dot, "skills", name);
|
|
316
|
+
const vars = { skill: skillDir, runtime, python, home, env: envValues };
|
|
317
|
+
items.push({ kind: "skill", target: t, to: skillDir, vars, status: sameTree(pkg.skill, skillDir, vars) ? "same" : existsSync(skillDir) ? "differs" : "new" });
|
|
318
|
+
|
|
319
|
+
const servers = isObj(m.mcpServers) ? m.mcpServers : {};
|
|
320
|
+
if (Object.keys(servers).length) {
|
|
321
|
+
const file = t === "gu" ? join(home, ".gonext", "mcp.json") : join(home, ".claude.json");
|
|
322
|
+
const cur = readJsonFile(file);
|
|
323
|
+
if (cur.error) warnings.push(`${file} is ${cur.error} — its MCP servers are left alone`);
|
|
324
|
+
for (const [sname, entry] of Object.entries(servers)) {
|
|
325
|
+
const want = fillDeep({ command: entry.command, args: entry.args ?? [], ...(isObj(entry.env) && Object.keys(entry.env).length ? { env: entry.env } : {}) }, vars);
|
|
326
|
+
if (t === "claude") want.type = "stdio";
|
|
327
|
+
const have = cur.data?.mcpServers?.[sname];
|
|
328
|
+
items.push({ kind: "server", target: t, name: sname, file, entry: want, blocked: !!cur.error, status: !have ? "new" : sameJson(have, want) ? "same" : "differs" });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const hookDefs = isObj(m.hooks) ? m.hooks : {};
|
|
333
|
+
if (Object.keys(hookDefs).length) {
|
|
334
|
+
const file = join(home, dot, "settings.json");
|
|
335
|
+
const cur = readJsonFile(file);
|
|
336
|
+
if (cur.error) warnings.push(`${file} is ${cur.error} — no hooks are added to it`);
|
|
337
|
+
let hooks = cur.data?.hooks ?? {};
|
|
338
|
+
const replaced = [];
|
|
339
|
+
let added = 0;
|
|
340
|
+
const events = [];
|
|
341
|
+
for (const [ev, groups] of Object.entries(hookDefs)) {
|
|
342
|
+
if (t === "gu" && !(ev in CLAUDE_TO_GU)) continue; // a Claude-only event: gu never fires it
|
|
343
|
+
events.push(ev);
|
|
344
|
+
const filled = (Array.isArray(groups) ? groups : []).map((g) => ({
|
|
345
|
+
matcher: g.matcher ?? "",
|
|
346
|
+
hooks: fillDeep(g.hooks ?? [], vars),
|
|
347
|
+
keys: (g.hooks ?? []).map(hookKey),
|
|
348
|
+
}));
|
|
349
|
+
const r = mergeHooks(hooks, t === "gu" ? CLAUDE_TO_GU[ev] : ev, filled, t === "gu" ? { dialect: "claude" } : {});
|
|
350
|
+
hooks = r.hooks;
|
|
351
|
+
replaced.push(...r.replaced);
|
|
352
|
+
added += r.added;
|
|
353
|
+
}
|
|
354
|
+
items.push({ kind: "hooks", target: t, file, hooks, events, replaced, blocked: !!cur.error, status: added ? (replaced.length ? "differs" : "new") : "same" });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { name, manifest: m, pkg, home, base, runtime, python, targets: use, items, missingEnv, warnings, notes: Array.isArray(m.notes) ? m.notes.map(String) : [] };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---- doing it ----------------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
function writeFiles(dir, files, vars) {
|
|
363
|
+
for (const [rel, f] of files) {
|
|
364
|
+
const p = join(dir, ...rel.split("/"));
|
|
365
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
366
|
+
writeFileSync(p, fillFile(f, vars));
|
|
367
|
+
if (f.mode & 0o111) chmodSync(p, f.mode | 0o600);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function copyTree(from, to, skip = new Set()) {
|
|
371
|
+
mkdirSync(to, { recursive: true });
|
|
372
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
373
|
+
if (skip.has(e.name)) continue;
|
|
374
|
+
const a = join(from, e.name), b = join(to, e.name);
|
|
375
|
+
if (e.isDirectory()) copyTree(a, b);
|
|
376
|
+
else if (e.isFile()) copyFileSync(a, b);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A Python 3.9+ on this machine → [command, ...args] or null. Windows' Store stub fails the check. */
|
|
381
|
+
export function findPython({ platform = process.platform, run = spawnSync } = {}) {
|
|
382
|
+
// platform-ok: this IS the resolver — install.mjs ships alone and cannot import platform-tools.mjs. Each candidate is run and version-checked, so the Store stub fails.
|
|
383
|
+
const tries = platform === "win32" ? [["py", "-3"], ["python"], ["python3"]] : [["python3"], ["python"]];
|
|
384
|
+
for (const [cmd, ...args] of tries) {
|
|
385
|
+
const r = run(cmd, [...args, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"], { encoding: "utf8" });
|
|
386
|
+
const v = String(r.stdout ?? "").trim().match(/^(\d+)\.(\d+)$/);
|
|
387
|
+
if (r.status === 0 && v && (+v[1] > 3 || (+v[1] === 3 && +v[2] >= 9))) return [cmd, ...args];
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Carry out a plan. `say(line)` reports progress. → { done: [lines], errors: [lines], backups }.
|
|
394
|
+
* Order: runtime, its venv, then each tool's skill, MCP servers and hooks. A failed venv stops
|
|
395
|
+
* before anything points at it.
|
|
396
|
+
*/
|
|
397
|
+
export function applyInstall(plan, { say = () => {}, run = spawnSync, now = new Date() } = {}) {
|
|
398
|
+
const done = [];
|
|
399
|
+
const errors = [];
|
|
400
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
401
|
+
const backupDir = join(plan.base, "skill-backups", `${plan.name}-${stamp}`);
|
|
402
|
+
let backedUp = false;
|
|
403
|
+
const backup = (p, label) => {
|
|
404
|
+
if (!existsSync(p)) return;
|
|
405
|
+
const dest = join(backupDir, label);
|
|
406
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
407
|
+
if (statSync(p).isDirectory()) copyTree(p, dest, new Set([".venv", "__pycache__"]));
|
|
408
|
+
else copyFileSync(p, dest);
|
|
409
|
+
backedUp = true;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
for (const it of plan.items) {
|
|
413
|
+
try {
|
|
414
|
+
if (it.kind === "runtime" && it.status !== "same") {
|
|
415
|
+
backup(it.to, "runtime");
|
|
416
|
+
writeFiles(it.to, plan.pkg.runtime, { skill: join(plan.home, ".gonext", "skills", plan.name), runtime: plan.runtime, python: plan.python, home: plan.home, env: {} });
|
|
417
|
+
done.push(`runtime → ${it.to}`);
|
|
418
|
+
} else if (it.kind === "python" && it.status !== "same") {
|
|
419
|
+
const py = findPython({ run });
|
|
420
|
+
if (!py) throw new Error("Python 3.9 or newer is needed for this skill and none was found (install it, then run this again)");
|
|
421
|
+
if (!existsSync(plan.python)) {
|
|
422
|
+
say(` making a Python venv in ${join(it.to, ".venv")} …`);
|
|
423
|
+
const v = run(py[0], [...py.slice(1), "-m", "venv", join(it.to, ".venv")], { encoding: "utf8" });
|
|
424
|
+
if (v.status !== 0) {
|
|
425
|
+
const why = String(v.stderr || v.stdout || "").trim().split("\n").pop();
|
|
426
|
+
throw new Error(`couldn't make the venv: ${why}${process.platform === "linux" ? " (on Ubuntu: sudo apt install python3-venv)" : ""}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
say(` installing ${it.requirements} …`);
|
|
430
|
+
const p = run(plan.python, ["-m", "pip", "install", "--quiet", "--disable-pip-version-check", "-r", join(it.to, it.requirements)], { encoding: "utf8" });
|
|
431
|
+
if (p.status !== 0) throw new Error(`pip install failed: ${String(p.stderr || p.stdout || "").trim().split("\n").slice(-2).join(" ")}`);
|
|
432
|
+
done.push(`Python packages from ${it.requirements}`);
|
|
433
|
+
} else if (it.kind === "skill" && it.status !== "same") {
|
|
434
|
+
backup(it.to, `${it.target}-skill`);
|
|
435
|
+
writeFiles(it.to, plan.pkg.skill, it.vars);
|
|
436
|
+
done.push(`${it.target}: skill → ${it.to}`);
|
|
437
|
+
} else if (it.kind === "server" && it.status !== "same" && !it.blocked) {
|
|
438
|
+
const cur = readJsonFile(it.file);
|
|
439
|
+
if (cur.error) throw new Error(`${it.file} is ${cur.error}`);
|
|
440
|
+
if (it.status === "differs") backup(it.file, `${it.target}-${it.file.split(/[\\/]/).pop()}`);
|
|
441
|
+
const data = cur.data;
|
|
442
|
+
data.mcpServers = isObj(data.mcpServers) ? data.mcpServers : {};
|
|
443
|
+
data.mcpServers[it.name] = it.entry;
|
|
444
|
+
writeJsonFile(it.file, data);
|
|
445
|
+
done.push(`${it.target}: MCP server ${it.name} → ${it.file}`);
|
|
446
|
+
} else if (it.kind === "hooks" && it.status !== "same" && !it.blocked) {
|
|
447
|
+
const cur = readJsonFile(it.file);
|
|
448
|
+
if (cur.error) throw new Error(`${it.file} is ${cur.error}`);
|
|
449
|
+
backup(it.file, `${it.target}-settings.json`);
|
|
450
|
+
writeJsonFile(it.file, { ...cur.data, hooks: it.hooks });
|
|
451
|
+
done.push(`${it.target}: hooks (${it.events.join(", ")}) → ${it.file}`);
|
|
452
|
+
}
|
|
453
|
+
} catch (e) {
|
|
454
|
+
errors.push(`${it.kind}${it.target ? ` (${it.target})` : ""}: ${e.message}`);
|
|
455
|
+
if (it.kind === "runtime" || it.kind === "python") break; // the rest would point at a broken runtime
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { done, errors, backups: backedUp ? backupDir : null };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** What to do after installing, for the tools that got it. */
|
|
462
|
+
export const nextStep = (plan) =>
|
|
463
|
+
plan.targets.length === 2 ? "Start a new gu or Claude Code session to use it." : plan.targets[0] === "gu" ? "Start a new gu session (/reset) to use it." : "Start a new Claude Code session to use it.";
|
|
464
|
+
|
|
465
|
+
/** Would applyInstall change anything? */
|
|
466
|
+
export const planHasWork = (plan) => plan.items.some((i) => i.status && i.status !== "same" && !i.blocked);
|
|
467
|
+
|
|
468
|
+
// ---- describing a plan (shared by the CLI and gu) ----------------------------------------------
|
|
469
|
+
|
|
470
|
+
/** The plan as lines of text. `tilde` shortens paths. */
|
|
471
|
+
export function describePlan(plan, tilde = (p) => p) {
|
|
472
|
+
const word = { new: "new", same: "already there", differs: "replaces what is there (backed up)" };
|
|
473
|
+
const out = [`${plan.name} — for ${plan.targets.map((t) => (t === "gu" ? "gu" : "Claude Code")).join(" and ")}`];
|
|
474
|
+
for (const it of plan.items) {
|
|
475
|
+
if (it.kind === "runtime") out.push(` runtime (${it.files} files) → ${tilde(it.to)} ${word[it.status]}`);
|
|
476
|
+
if (it.kind === "python") out.push(` Python venv in the runtime, packages from ${it.requirements} ${it.status === "same" ? word.same : "built here"}`);
|
|
477
|
+
if (it.kind === "skill") out.push(` ${it.target}: skill → ${tilde(it.to)} ${word[it.status]}`);
|
|
478
|
+
if (it.kind === "server") out.push(` ${it.target}: MCP server ${it.name} → ${tilde(it.file)} ${it.blocked ? "SKIPPED (file is broken)" : word[it.status]}`);
|
|
479
|
+
if (it.kind === "hooks") {
|
|
480
|
+
out.push(` ${it.target}: hooks ${it.events.join(", ")} → ${tilde(it.file)} ${it.blocked ? "SKIPPED (file is broken)" : it.status === "same" ? word.same : "added"}`);
|
|
481
|
+
for (const r of it.replaced) out.push(` replaces an older copy: ${tilde(r)}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const k of plan.missingEnv) out.push(` ! ${k} is not set here — set it and install again`);
|
|
485
|
+
for (const w of plan.warnings) out.push(` ! ${w}`);
|
|
486
|
+
for (const n of plan.notes) out.push(` note: ${n}`);
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- run as a script: node install.mjs ---------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
async function main(argv) {
|
|
493
|
+
const has = (f) => argv.includes(f);
|
|
494
|
+
const at = argv.indexOf("--home");
|
|
495
|
+
const home = at >= 0 ? argv[at + 1] : homedir();
|
|
496
|
+
const src = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--home") ?? dirname(fileURLToPath(import.meta.url));
|
|
497
|
+
if (has("--help") || has("-h")) {
|
|
498
|
+
console.log("node install.mjs [package] [--gu] [--claude] [--home <dir>] [--dry-run] [--yes]\n Installs a gu skill package. With neither --gu nor --claude, installs for every tool that is set up here.");
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
if (Number(process.versions.node.split(".")[0]) < 18) {
|
|
502
|
+
console.error(`Node.js 18 or newer is needed (this is ${process.version}).`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
const targets = TARGETS.filter((t) => has(`--${t}`));
|
|
506
|
+
let plan;
|
|
507
|
+
try {
|
|
508
|
+
plan = planInstall(loadPackage(resolve(src)), { home, targets });
|
|
509
|
+
} catch (e) {
|
|
510
|
+
console.error(`Not installed: ${e.message}`);
|
|
511
|
+
return 1;
|
|
512
|
+
}
|
|
513
|
+
const tilde = (p) => String(p).split(home).join("~");
|
|
514
|
+
console.log(describePlan(plan, tilde).join("\n"));
|
|
515
|
+
const todo = planHasWork(plan);
|
|
516
|
+
if (!todo) {
|
|
517
|
+
console.log("\nNothing to do — it is already installed.");
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
if (has("--dry-run")) {
|
|
521
|
+
console.log("\n--dry-run: nothing was written.");
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
if (!has("--yes")) {
|
|
525
|
+
if (!process.stdin.isTTY) {
|
|
526
|
+
console.error("\nAdd --yes to install without asking.");
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
const { createInterface } = await import("node:readline/promises");
|
|
530
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
531
|
+
const a = (await rl.question("\nInstall? [y/N] ")).trim().toLowerCase();
|
|
532
|
+
rl.close();
|
|
533
|
+
if (a !== "y" && a !== "yes") {
|
|
534
|
+
console.log("Cancelled — nothing written.");
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const r = applyInstall(plan, { say: (l) => console.log(l) });
|
|
539
|
+
for (const d of r.done) console.log(`✓ ${tilde(d)}`);
|
|
540
|
+
for (const e of r.errors) console.error(`✗ ${e}`);
|
|
541
|
+
if (r.backups) console.log(`Old copies: ${tilde(r.backups)}`);
|
|
542
|
+
if (!r.errors.length) console.log(`Done. ${nextStep(plan)}`);
|
|
543
|
+
return r.errors.length ? 1 : 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Run directly (node install.mjs), not imported? Compared as REAL paths: a folder reached
|
|
547
|
+
* through a symlink (macOS /var → /private/var) must still count, or the installer silently
|
|
548
|
+
* does nothing. */
|
|
549
|
+
export function isMainModule(argv1, url = import.meta.url) {
|
|
550
|
+
if (!argv1) return false;
|
|
551
|
+
const real = (p) => {
|
|
552
|
+
try {
|
|
553
|
+
return realpathSync(p);
|
|
554
|
+
} catch {
|
|
555
|
+
return resolve(p);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
return real(resolve(argv1)) === real(fileURLToPath(url));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (isMainModule(process.argv[1])) {
|
|
562
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
563
|
+
}
|