@tiens.nguyen/gu-cli 1.0.884 → 1.0.885

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 CHANGED
@@ -126,6 +126,9 @@ Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab
126
126
  | `/mcp [list\|tools\|add\|remove\|enable\|disable\|approve\|allow\|disallow\|reconnect]` | MCP servers, and which of their tools run without asking |
127
127
  | `/hooks [list\|approve\|unapprove]` | commands gu runs on `onStart`, `onAsk`, `onToolDone`, `onTurnEnd` |
128
128
  | `/switch-skill [claude\|gu]` | use Claude Code's (`.claude`) or gu's (`.gonext`) skills, MCP servers and hooks in this folder |
129
+ | `/skill-pack <name> [--out <file>] [--runtime <dir>]` | pack a skill with its MCP servers, hooks and code into one zip for another machine (no venvs, databases or secrets) |
130
+ | `/skill-install <zip\|folder> [--gu] [--claude] [--dry-run]` | install a packed skill for gu and Claude Code; without gu: `node <name>/install.mjs` |
131
+ | `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's: shows its plan, last command and next step, then asks (needs the memory skill) |
129
132
  | `/skills-copy-to-claude [--dry-run] [name…]` | copy gu's skills into Claude Code's folders |
130
133
  | `/proxy [show\|set\|on\|off\|clear\|test]` | route model calls through an HTTP proxy (local calls go direct) |
131
134
  | `/s3 [test]` | use your own S3 bucket for the RAG index and generated files |
package/gu-repl.mjs CHANGED
@@ -1051,6 +1051,9 @@ const COMMANDS = [
1051
1051
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
1052
1052
  { name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
1053
1053
  { name: "/switch-skill", usage: "/switch-skill [claude|gu]", desc: "whose skills, MCP servers and hooks this folder uses — Claude Code's (.claude) or gu's own (.gonext)" },
1054
+ { name: "/resume", desc: "carry on the last unfinished task in this folder — Claude Code's or gu's — from where it stopped (memory skill)" },
1055
+ { name: "/skill-pack", usage: "/skill-pack <name> [--out <file>] [--runtime <dir>]", desc: "pack a skill (gu's or Claude's) with its MCP servers, hooks and code into one zip for another machine" },
1056
+ { name: "/skill-install", usage: "/skill-install <zip|folder> [--gu] [--claude] [--dry-run]", desc: "install a packed skill for gu and Claude Code (whichever is set up here)" },
1054
1057
  { name: "/skills-copy-to-claude", usage: "/skills-copy-to-claude [--dry-run] [name…]", desc: "copy gu's skills (.gonext) into Claude Code's folders (.claude), renamed to Claude's names, with their MCP servers" },
1055
1058
  { name: "/help", desc: "list these commands" },
1056
1059
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
@@ -7787,6 +7790,140 @@ async function runSwitchSkillCommand(line) {
7787
7790
  if (barPinned()) paintBar();
7788
7791
  }
7789
7792
 
7793
+ /** "--out x" style option from a command line's words → value, or undefined. */
7794
+ function optionValue(words, flag) {
7795
+ const i = words.indexOf(flag);
7796
+ return i >= 0 ? words[i + 1] : undefined;
7797
+ }
7798
+
7799
+ /**
7800
+ * /skill-pack <name> [--out <file>] [--runtime <dir>] — one skill, with its MCP servers, hooks and
7801
+ * the code they run, into one zip another machine installs (#281, skill-package.mjs). Shows what
7802
+ * it found, then writes; a secret-looking line refuses the pack.
7803
+ */
7804
+ async function runResumeCommand() {
7805
+ const rm = await import("./resume-memory.mjs");
7806
+ const { hooksForTurn } = await import("./hooks-config.mjs");
7807
+ const say = (l) => console.log(GUTTER + l);
7808
+ console.log("");
7809
+ const r = rm.readResume({ cwd: process.cwd(), found: rm.findMemoryCli({ cwd: process.cwd(), hooksFor: hooksForTurn }) });
7810
+ if (r.error) {
7811
+ say(yellow(r.error) + "\n");
7812
+ return null;
7813
+ }
7814
+ if (!r.resume) {
7815
+ say(dim("Nothing to resume here: every recent session finished its plan.\n"));
7816
+ return null;
7817
+ }
7818
+ for (const l of r.block.split("\n").filter((l) => !l.startsWith("If the user wants"))) say(l.startsWith("## ") ? bold(l.slice(3)) : dim(l));
7819
+ console.log("");
7820
+ if (!(await askYesNo(rm.resumeQuestion(r.resume), true))) {
7821
+ console.log("");
7822
+ return null;
7823
+ }
7824
+ return rm.resumePrompt(r.block, r.resume);
7825
+ }
7826
+
7827
+ async function runSkillPackCommand(line) {
7828
+ const sp = await import("./skill-package.mjs");
7829
+ const words = line.trim().split(/\s+/).slice(1);
7830
+ const out = optionValue(words, "--out");
7831
+ const runtime = optionValue(words, "--runtime");
7832
+ const name = words.find((w, i) => !w.startsWith("--") && !["--out", "--runtime"].includes(words[i - 1]));
7833
+ const home = homedir();
7834
+ const cwd = resolve(process.cwd());
7835
+ const tilde = (p) => (String(p).startsWith(home) ? "~" + String(p).slice(home.length) : p);
7836
+ const say = (l) => console.log(GUTTER + l);
7837
+ console.log("");
7838
+ if (!name) {
7839
+ say(dim("usage: /skill-pack <name> [--out <file>] [--runtime <dir>]\n"));
7840
+ return;
7841
+ }
7842
+ let plan;
7843
+ try {
7844
+ plan = sp.planSkillPack(name, { cwd, home, runtime });
7845
+ } catch (err) {
7846
+ say(red(`${err.message}\n`));
7847
+ return;
7848
+ }
7849
+ const f = plan.found;
7850
+ say(bold(`Pack ${plan.manifest.name}`) + dim(` (${f.source === "gu" ? "gu's" : "Claude Code's"} ${f.scope === "project" ? "project" : "user"} skill)`));
7851
+ say(dim(` skill ${tilde(f.skillDir)} ${plan.skill.size} file${plan.skill.size === 1 ? "" : "s"}`));
7852
+ if (f.runtimeDir) say(dim(` runtime ${tilde(f.runtimeDir)} ${plan.runtime.size} files${f.python ? " · venv rebuilt from requirements.txt" : ""}`));
7853
+ for (const s of f.servers) say(dim(` MCP ${s}`));
7854
+ if (f.hooks.length) say(dim(` hooks ${f.hooks.join(", ")}`));
7855
+ if (plan.manifest.env?.length) say(dim(` asked on install, not packed: ${plan.manifest.env.join(", ")}`));
7856
+ if (plan.skipped.length) say(dim(` left out ${plan.skipped.join(", ")}`));
7857
+ for (const w of plan.warnings) say(yellow(` ${w}`));
7858
+ if (plan.secrets.length) {
7859
+ for (const s of plan.secrets) say(red(` ✗ ${s.file}:${s.line} looks like ${s.kind}`));
7860
+ say(red(" Not packed — remove those and run /skill-pack again.\n"));
7861
+ return;
7862
+ }
7863
+ const file = resolve(out ?? `${plan.manifest.name}.zip`);
7864
+ try {
7865
+ const r = sp.writeSkillPack(plan, file);
7866
+ say(`${accent("✓")} ${tilde(r.file)} ${dim(`${Math.ceil(r.bytes / 1024)} KB, ${r.count} files`)}`);
7867
+ say(dim(` On the other machine: /skill-install ${file.split(/[\\/]/).pop()}`));
7868
+ // platform-ok: shown to the user — the folder inside the zip, which unzip makes with "/" names.
7869
+ say(dim(` Without gu: unzip it, then node ${plan.manifest.name}/install.mjs\n`));
7870
+ } catch (err) {
7871
+ say(red(`couldn't write the zip — ${err.message}\n`));
7872
+ }
7873
+ }
7874
+
7875
+ /**
7876
+ * /skill-install <zip|folder> [--gu] [--claude] [--dry-run] — a packed skill onto this machine
7877
+ * (skill-install.mjs, the same code as the package's own install.mjs). Shows the plan, then asks.
7878
+ */
7879
+ async function runSkillInstallCommand(line) {
7880
+ const si = await import("./skill-install.mjs");
7881
+ const words = line.trim().split(/\s+/).slice(1);
7882
+ const src = words.find((w) => !w.startsWith("--"));
7883
+ const targets = si.TARGETS.filter((t) => words.includes(`--${t}`));
7884
+ const home = homedir();
7885
+ const tilde = (p) => String(p).split(home).join("~");
7886
+ const say = (l) => console.log(GUTTER + l);
7887
+ console.log("");
7888
+ if (!src) {
7889
+ say(dim("usage: /skill-install <zip|folder> [--gu] [--claude] [--dry-run]\n"));
7890
+ return;
7891
+ }
7892
+ let plan;
7893
+ try {
7894
+ plan = si.planInstall(si.loadPackage(resolve(src.replace(/^~(?=\/|$)/, home))), { home, targets });
7895
+ } catch (err) {
7896
+ say(red(`Not installed — ${err.message}\n`));
7897
+ return;
7898
+ }
7899
+ const lines = si.describePlan(plan, tilde);
7900
+ say(bold(lines[0]));
7901
+ for (const l of lines.slice(1)) say(l.trimStart().startsWith("!") ? yellow(l) : dim(l));
7902
+ if (!si.planHasWork(plan)) {
7903
+ say(dim("\n Nothing to do — it is already installed.\n"));
7904
+ return;
7905
+ }
7906
+ if (words.includes("--dry-run")) {
7907
+ say(dim("\n --dry-run: nothing was written.\n"));
7908
+ return;
7909
+ }
7910
+ console.log(dim(`\n ${PICK_HINT}:`));
7911
+ const options = ["Install", "Cancel"];
7912
+ const idx = await pickFromList(options, 0);
7913
+ if (idx !== 0) {
7914
+ console.log(dim(" (cancelled — nothing installed)\n"));
7915
+ paintBar();
7916
+ return;
7917
+ }
7918
+ const r = si.applyInstall(plan, { say: (l) => say(dim(l)) });
7919
+ for (const d of r.done) say(`${accent("✓")} ${dim(tilde(d))}`);
7920
+ for (const e of r.errors) say(red(`✗ ${e}`));
7921
+ if (r.backups) say(dim(` Old copies: ${tilde(r.backups)}`));
7922
+ if (!r.errors.length) say(dim(` ${si.nextStep(plan)}`));
7923
+ console.log("");
7924
+ paintBar();
7925
+ }
7926
+
7790
7927
  /**
7791
7928
  * /skills-copy-to-claude [--dry-run] [name…] — gu's skills into Claude Code's folders (#241):
7792
7929
  * ~/.gonext/skills → ~/.claude/skills and ./.gonext/skills → ./.claude/skills, "gu-" names become
@@ -14808,6 +14945,13 @@ async function main() {
14808
14945
  console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
14809
14946
  lineRaw = recapQuestion(entries);
14810
14947
  }
14948
+ // /resume (#285): show where the last unfinished session stopped; on "yes" the line becomes the
14949
+ // prompt that carries it on, and the turn runs as if it had been typed.
14950
+ if (lineRaw.trim() === "/resume") {
14951
+ const prompt = await runResumeCommand();
14952
+ if (!prompt) continue;
14953
+ lineRaw = prompt;
14954
+ }
14811
14955
  const line = lineRaw.trim();
14812
14956
  // #302: what was typed for THIS line (before expansion), and whether it was sent from a
14813
14957
  // stopped turn's restored question.
@@ -14995,6 +15139,14 @@ async function main() {
14995
15139
  await runSwitchSkillCommand(line);
14996
15140
  continue;
14997
15141
  }
15142
+ if (line === "/skill-pack" || line.startsWith("/skill-pack ")) {
15143
+ await runSkillPackCommand(line);
15144
+ continue;
15145
+ }
15146
+ if (line === "/skill-install" || line.startsWith("/skill-install ")) {
15147
+ await runSkillInstallCommand(line);
15148
+ continue;
15149
+ }
14998
15150
  if (line === "/skills-copy-to-claude" || line.startsWith("/skills-copy-to-claude ")) {
14999
15151
  await runSkillsCopyCommand(line);
15000
15152
  continue;
package/hooks-config.mjs CHANGED
@@ -147,7 +147,9 @@ export function cleanHooks(raw, where, source = "gu") {
147
147
  ...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
148
148
  ...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
149
149
  }));
150
- if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs });
150
+ // #281: a group marked dialect "claude" (a packed skill's hook, installed for both tools) is
151
+ // spoken to in Claude's vocabulary, as if it had come from Claude Code's settings.
152
+ if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs, ...(g.dialect === "claude" ? { dialect: "claude" } : {}) });
151
153
  }
152
154
  if (kept.length) hooks[event] = kept;
153
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.884",
3
+ "version": "1.0.885",
4
4
  "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama, MLX or any OpenAI-compatible provider). macOS, Linux and Windows.",
5
5
  "keywords": [
6
6
  "ai",
@@ -146,6 +146,9 @@
146
146
  "skills.mjs",
147
147
  "skill-source.mjs",
148
148
  "skills-copy.mjs",
149
+ "skill-package.mjs",
150
+ "resume-memory.mjs",
151
+ "skill-install.mjs",
149
152
  "shell-details.mjs",
150
153
  "proc-identity.mjs",
151
154
  "win-path.mjs",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * gu /resume (#285): where the last unfinished session in this project stopped — Claude Code's or
3
+ * gu's — read from the session-memory skill's database, so gu can carry on Claude's work.
4
+ *
5
+ * The memory skill is found through the hooks this folder uses (its memory_hook.py sits next to
6
+ * memory_cli.py), so whichever copy is installed — ~/.claude/memory, a /skill-install runtime —
7
+ * is the one asked. Nothing here writes the database.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+
14
+ const SKILL = "claude-session-memory-orchestration";
15
+
16
+ /** {python, cli} of the memory skill, or null when it is not installed. */
17
+ export function findMemoryCli({ cwd = process.cwd(), home = homedir(), hooksFor } = {}) {
18
+ for (const source of [undefined, "claude", "gu"]) {
19
+ let hooks = {};
20
+ try {
21
+ hooks = hooksFor ? hooksFor({ cwd, home, source }) : {};
22
+ } catch {
23
+ continue;
24
+ }
25
+ for (const groups of Object.values(hooks)) {
26
+ for (const g of groups ?? []) {
27
+ for (const h of g.hooks ?? []) {
28
+ const script = [h.command, ...(h.args ?? [])].find((a) => /memory_hook\.py$/.test(String(a)));
29
+ if (!script) {continue;}
30
+ const cli = join(dirname(dirname(String(script))), "memory_cli.py");
31
+ if (existsSync(cli)) {return { python: String(h.command).endsWith(".py") ? pythonFor(cli) : String(h.command), cli };}
32
+ }
33
+ }
34
+ }
35
+ }
36
+ for (const dir of [join(home, ".gonext", "skill-runtime", SKILL), join(home, ".claude", "skill-runtime", SKILL), join(home, ".claude", "memory")]) {
37
+ const cli = join(dir, "memory_cli.py");
38
+ if (existsSync(cli)) {return { python: pythonFor(cli), cli };}
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function pythonFor(cli) {
44
+ const dir = dirname(cli);
45
+ for (const p of [join(dir, ".venv", "bin", "python"), join(dir, ".venv", "Scripts", "python.exe")]) {if (existsSync(p)) {return p;}}
46
+ return process.platform === "win32" ? "python" : "python3"; // platform-ok: the venv is preferred above
47
+ }
48
+
49
+ /** {block, resume} from `memory_cli.py resume --json`, or {error}. */
50
+ export function readResume({ cwd = process.cwd(), found, run = spawnSync } = {}) {
51
+ if (!found) {return { error: "the memory skill is not installed here — /skill-install claude-session-memory-orchestration.zip" };}
52
+ const r = run(found.python, [found.cli, "resume", "--json"], { cwd, encoding: "utf8", timeout: 20000 });
53
+ if (r.error || r.status !== 0) {return { error: `memory_cli resume failed: ${(r.error?.message || r.stderr || "").trim().split("\n").pop()}` };}
54
+ try {
55
+ const d = JSON.parse(r.stdout);
56
+ return { block: d.block || "", resume: d.resume ?? null };
57
+ } catch {
58
+ return { error: "memory_cli resume printed something that is not JSON" };
59
+ }
60
+ }
61
+
62
+ /** The step to offer: the saved next action, else the first plan step not done. */
63
+ export function nextStepOf(resume) {
64
+ if (!resume) {return "";}
65
+ const open = (resume.plan ?? []).find((p) => p.status !== "completed");
66
+ return String(resume.next_action || open?.text || resume.task?.next_action || "").trim();
67
+ }
68
+
69
+ /** The question asked before carrying on. */
70
+ export function resumeQuestion(resume) {
71
+ const step = nextStepOf(resume);
72
+ const what = resume?.task?.id ? `task #${resume.task.id}` : "this work";
73
+ return step ? `Continue ${what} from "${step.length > 60 ? step.slice(0, 59) + "…" : step}"?` : `Continue ${what}?`;
74
+ }
75
+
76
+ /** What the agent is sent on "yes": the block itself, and what to do with it. */
77
+ export function resumePrompt(block, resume) {
78
+ const step = nextStepOf(resume);
79
+ return [
80
+ `Continue the unfinished work below${resume?.task?.id ? ` (task #${resume.task.id})` : ""}. It was saved by the memory skill${resume?.tool === "claude" ? " from a Claude Code session" : ""}.`,
81
+ step ? `Start from: ${step}. Check the files it lists first; do not redo steps marked ✓.` : "Check the files it lists first; do not redo steps marked ✓.",
82
+ "",
83
+ block,
84
+ ].join("\n");
85
+ }
@@ -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
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * PACK A SKILL FOR ANOTHER MACHINE (#281) — /skill-pack <name>.
3
+ *
4
+ * A skill is often more than its SKILL.md: an MCP server it calls, hooks that run on every turn,
5
+ * and the code both of those run, which may live outside the skill folder (the session-memory
6
+ * skill keeps its code in ~/.claude/memory and its venv inside that). This file finds all of it on
7
+ * this machine and writes one zip that skill-install.mjs (shipped inside as install.mjs) puts back
8
+ * on any machine, for gu and/or Claude Code.
9
+ *
10
+ * What it finds, for the skill folder S (gu's or Claude's, yours or this project's):
11
+ * MCP servers in ~/.gonext/mcp.json and ~/.claude.json whose command/args point inside S or the
12
+ * runtime, or that SKILL.md names
13
+ * the runtime R = the folder the skill's outside files share: absolute paths SKILL.md names
14
+ * plus the servers' files (or --runtime <dir>). Refused when that is too broad
15
+ * (home, ~/.claude, ~/.gonext) or too big
16
+ * hooks in ~/.claude/settings.json and ~/.gonext/settings.json whose files are in S or R
17
+ * Python a command that is R's own venv python becomes {{PYTHON}}; R/requirements.txt is
18
+ * then required — the venv itself is never packed, the new machine builds its own
19
+ *
20
+ * What it never packs: venvs, node_modules, .git, caches, .env files, databases (.db/.sqlite —
21
+ * a project's memory stays with the project), .claude-memory folders. MCP server and hook env
22
+ * values that look secret (by name or shape) become {{ENV:NAME}}, read on the new machine from its
23
+ * terminal. Every packed text file is scanned for secret shapes; one hit refuses the pack.
24
+ *
25
+ * Paths: S → {{SKILL}}, R → {{RUNTIME}}, R's venv python → {{PYTHON}}, then home → {{HOME}}, in
26
+ * every text file and every server/hook entry. A path still outside all of those is a warning.
27
+ */
28
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { deflateRawSync } from "node:zlib";
33
+ import { scanSkillsDir } from "./skills.mjs";
34
+ import { CLAUDE_ONLY_EVENTS, CLAUDE_TO_GU, crc32, FORMAT, MANIFEST, manifestProblems } from "./skill-install.mjs";
35
+
36
+ export const LIMITS = { files: 2000, bytes: 25 * 1024 * 1024 };
37
+ const SKIP_DIRS = new Set([".venv", "venv", "env", "node_modules", ".git", "__pycache__", ".claude-memory", ".pytest_cache", ".mypy_cache", ".ruff_cache"]);
38
+ const SKIP_FILE = (n) => n === ".DS_Store" || /\.(pyc|pyo|db|sqlite3?|db-wal|db-shm|db-journal|log)$/i.test(n) || n === ".env" || n.startsWith(".env.");
39
+ // platform-ok: names recognised in a packed command, never run here.
40
+ const INTERPRETERS = new Set(["node", "python", "python3", "npx", "uv", "uvx", "bun", "deno", "bash", "sh", "pwsh", "powershell"]);
41
+ const GU_TO_CLAUDE = Object.fromEntries(Object.entries(CLAUDE_TO_GU).map(([c, g]) => [g, c]));
42
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
43
+
44
+ /** Secret shapes (a subset of api/scripts/scan-bundle.mjs plus the common API-key forms). */
45
+ export const SECRET_SHAPES = [
46
+ { kind: "a private key (PEM)", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
47
+ { kind: "an AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, unless: (m) => /0{5,}|EXAMPLE/.test(m) },
48
+ { kind: "a signed gu token", re: /\bgu1\.[A-Za-z0-9_-]{40,}\.[A-Za-z0-9_-]{40,}/g },
49
+ { kind: "an API key (sk-…)", re: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}/g },
50
+ { kind: "a GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{30,}/g },
51
+ { kind: "a Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
52
+ { kind: "a Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
53
+ {
54
+ kind: "a database address with a password",
55
+ re: /\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis):\/\/[^\s"'`/:@${}]+:[^\s"'`@${}]+@[^\s"'`/]+/g,
56
+ unless: (m) => /@(127\.0\.0\.1|localhost|host|example\.com|\[::1\])(:|\/|$)/.test(m),
57
+ },
58
+ ];
59
+ /** Findings in one text → [{ line, kind }], never the value. */
60
+ export function scanSecrets(text) {
61
+ const t = String(text ?? "");
62
+ const out = [];
63
+ for (const s of SECRET_SHAPES) {
64
+ for (const m of t.matchAll(s.re)) {
65
+ if (s.unless?.(m[0])) continue;
66
+ out.push({ line: t.slice(0, m.index).split("\n").length, kind: s.kind });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ const SECRET_NAME = /KEY|TOKEN|SECRET|PASS|AUTH|CRED|PRIVATE|COOKIE|SESSION/i;
72
+
73
+ // ---- reading this machine ----------------------------------------------------------------------
74
+
75
+ function readJson(file) {
76
+ try {
77
+ const d = JSON.parse(readFileSync(file, "utf8"));
78
+ return isObj(d) ? d : {};
79
+ } catch {
80
+ return {};
81
+ }
82
+ }
83
+ const inside = (p, dir) => {
84
+ const a = resolve(p), b = resolve(dir);
85
+ return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
86
+ };
87
+ const isText = (buf) => !buf.subarray(0, 8192).includes(0);
88
+ const expand = (p, home) => (p.startsWith("~/") || p === "~" ? join(home, p.slice(2)) : p);
89
+
90
+ /** Every file under dir that is packed → { files: Map<rel, {data, mode}>, skipped: [rel] }. */
91
+ export function packTree(dir) {
92
+ const files = new Map();
93
+ const skipped = [];
94
+ let bytes = 0;
95
+ const walk = (d) => {
96
+ for (const e of readdirSync(d, { withFileTypes: true })) {
97
+ const p = join(d, e.name);
98
+ const rel = p.slice(dir.length + 1).split(sep).join("/");
99
+ if (e.isDirectory()) {
100
+ if (SKIP_DIRS.has(e.name)) skipped.push(rel + "/");
101
+ else walk(p);
102
+ } else if (e.isFile()) {
103
+ if (SKIP_FILE(e.name)) {
104
+ if (e.name !== ".DS_Store" && !e.name.endsWith(".pyc")) skipped.push(rel);
105
+ continue;
106
+ }
107
+ const st = statSync(p);
108
+ bytes += st.size;
109
+ if (files.size >= LIMITS.files || bytes > LIMITS.bytes) {
110
+ throw new Error(`${dir} is too big to pack (over ${LIMITS.files} files or ${LIMITS.bytes / 1024 / 1024} MB)`);
111
+ }
112
+ files.set(rel, { data: readFileSync(p), mode: st.mode & 0o777 });
113
+ }
114
+ }
115
+ };
116
+ walk(dir);
117
+ return { files, skipped };
118
+ }
119
+
120
+ /** The skill named `name` → { name, dir, source, scope } | null. This project's first, then yours; gu's, then Claude's. */
121
+ export function findSkill(name, { cwd = process.cwd(), home = homedir() } = {}) {
122
+ const want = String(name).replace(/^\//, "");
123
+ const dirs = [];
124
+ for (const source of ["gu", "claude"]) {
125
+ const dot = source === "gu" ? ".gonext" : ".claude";
126
+ if (cwd && resolve(cwd) !== resolve(home)) dirs.push({ dir: join(cwd, dot, "skills"), source, scope: "project" });
127
+ dirs.push({ dir: join(home, dot, "skills"), source, scope: "user" });
128
+ }
129
+ for (const d of dirs) {
130
+ const hit = scanSkillsDir(d.dir, d.scope).skills.find((s) => s.name === want || s.dir.split(/[\\/]/).pop() === want);
131
+ if (hit) return { name: hit.name, description: hit.description, dir: hit.dir, source: d.source, scope: d.scope };
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** Absolute paths that exist, named in a text (/abs/…, ~/…, C:\…). */
137
+ export function pathsIn(text, home) {
138
+ const out = new Set();
139
+ const re = /(?:~\/|\/(?:Users|home|opt|usr|var|srv|root)\/|[A-Za-z]:\\)[^\s"'`)<>\]|,;]*/g;
140
+ for (const m of String(text).matchAll(re)) {
141
+ let p = expand(m[0].replace(/[.:]+$/, ""), home);
142
+ // Walk up to the longest prefix that exists (a doc may name a file that is made later).
143
+ while (p && !existsSync(p) && dirname(p) !== p) p = dirname(p);
144
+ if (p && existsSync(p) && isAbsolute(p)) out.add(resolve(p));
145
+ }
146
+ return [...out];
147
+ }
148
+
149
+ function commonDir(paths) {
150
+ if (!paths.length) return null;
151
+ let parts = resolve(paths[0]).split(sep);
152
+ for (const p of paths.slice(1)) {
153
+ const q = resolve(p).split(sep);
154
+ let i = 0;
155
+ while (i < parts.length && i < q.length && parts[i] === q[i]) i++;
156
+ parts = parts.slice(0, i);
157
+ }
158
+ return parts.join(sep) || sep;
159
+ }
160
+
161
+ /** Is this folder too broad to be one skill's runtime? */
162
+ function tooBroad(dir, home) {
163
+ const d = resolve(dir);
164
+ return [home, join(home, ".claude"), join(home, ".gonext"), join(home, ".config"), dirname(home), sep].some((x) => resolve(x) === d) || inside(home, d);
165
+ }
166
+
167
+ const refsOf = (entry, home) =>
168
+ [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])]
169
+ .map((a) => String(a ?? ""))
170
+ .filter((a) => isAbsolute(a) || a.startsWith("~/"))
171
+ .map((a) => resolve(expand(a, home)));
172
+
173
+ // ---- the plan ----------------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Everything /skill-pack would write → { manifest, skill, runtime, found, warnings, secrets, skipped }.
177
+ * Throws when the skill is missing or the runtime cannot be told apart from your home folder.
178
+ */
179
+ export function planSkillPack(name, { cwd = process.cwd(), home = homedir(), runtime: runtimeOpt, platform = process.platform } = {}) {
180
+ const sk = findSkill(name, { cwd, home });
181
+ if (!sk) throw new Error(`no skill named "${name}" in ./.gonext/skills, ~/.gonext/skills, ./.claude/skills or ~/.claude/skills`);
182
+ const warnings = [];
183
+ const skillTree = packTree(sk.dir);
184
+ const md = skillTree.files.get("SKILL.md").data.toString("utf8");
185
+
186
+ // MCP servers everywhere this machine keeps them.
187
+ const servers = [];
188
+ const guMcp = readJson(join(home, ".gonext", "mcp.json"));
189
+ const claude = readJson(join(home, ".claude.json"));
190
+ const proj = isObj(claude.projects) ? claude.projects[resolve(cwd)] : null;
191
+ for (const [where, obj] of [["~/.gonext/mcp.json", guMcp.mcpServers], ["~/.claude.json", claude.mcpServers], ["~/.claude.json (this folder)", proj?.mcpServers], ["./.mcp.json", readJson(join(cwd, ".mcp.json")).mcpServers]]) {
192
+ for (const [n, e] of Object.entries(isObj(obj) ? obj : {})) if (isObj(e) && e.command) servers.push({ name: n, entry: e, where });
193
+ }
194
+ const named = (n) => new RegExp(`(^|[^A-Za-z0-9_-])${n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_-]|$)`).test(md);
195
+
196
+ // The runtime: what the skill's outside files have in common.
197
+ const mdPaths = pathsIn(md, home).filter((p) => !inside(p, sk.dir));
198
+ const firstServers = servers.filter((s) => refsOf(s.entry, home).some((p) => inside(p, sk.dir)) || named(s.name));
199
+ let runtime = runtimeOpt ? resolve(expand(runtimeOpt, home)) : null;
200
+ if (!runtime) {
201
+ const outside = [...mdPaths, ...firstServers.flatMap((s) => refsOf(s.entry, home).filter((p) => existsSync(p) && !inside(p, sk.dir)))];
202
+ const dirs = outside.map((p) => (statSync(p).isDirectory() ? p : dirname(p)));
203
+ // A venv's python names its runtime by where the venv sits, not by bin/.
204
+ const roots = dirs.map((d) => d.replace(new RegExp(`[\\\\/](?:\\.venv|venv)[\\\\/](?:bin|Scripts)$`), ""));
205
+ runtime = commonDir(roots);
206
+ }
207
+ if (runtime && !existsSync(runtime)) throw new Error(`--runtime ${runtime} does not exist`);
208
+ if (runtime && tooBroad(runtime, home)) {
209
+ throw new Error(`the skill's outside files only have ${runtime} in common — too broad to pack. Point at the right folder: /skill-pack ${sk.name} --runtime <dir>`);
210
+ }
211
+ if (runtime && inside(runtime, sk.dir)) runtime = null; // all inside the skill folder: nothing extra
212
+
213
+ const linked = (entry) => refsOf(entry, home).some((p) => inside(p, sk.dir) || (runtime && inside(p, runtime)));
214
+ const useServers = servers.filter((s) => linked(s.entry) || named(s.name));
215
+ const dup = new Map();
216
+ for (const s of useServers) {
217
+ if (dup.has(s.name) && JSON.stringify(dup.get(s.name).entry) !== JSON.stringify(s.entry)) warnings.push(`MCP server ${s.name} is set up differently in ${dup.get(s.name).where} and ${s.where} — packed the one in ${dup.get(s.name).where}`);
218
+ if (!dup.has(s.name)) dup.set(s.name, s);
219
+ }
220
+ // A server SKILL.md names that is set up nowhere here: say so, never invent one.
221
+ for (const m of md.matchAll(/`([a-z0-9][a-z0-9_-]{2,63})`\s+MCP server|MCP server\s+`([a-z0-9][a-z0-9_-]{2,63})`/gi)) {
222
+ const n = m[1] || m[2];
223
+ if (!dup.has(n)) warnings.push(`SKILL.md names the MCP server "${n}", which is not set up on this machine — it is not in the package`);
224
+ }
225
+
226
+ // Hooks: Claude's settings (Claude vocabulary) and gu's (gu vocabulary, or dialect "claude").
227
+ const hooks = {};
228
+ const hookFrom = [];
229
+ const addHook = (claudeEvent, group, where) => {
230
+ const hs = (Array.isArray(group.hooks) ? group.hooks : []).filter((h) => isObj(h) && (h.type ?? "command") === "command" && h.command);
231
+ if (!hs.length || !hs.every((h) => linked(h))) return;
232
+ const g = { matcher: String(group.matcher ?? ""), hooks: hs.map((h) => ({ type: "command", command: h.command, ...(h.args ? { args: h.args } : {}), ...(h.timeout ? { timeout: h.timeout } : {}), ...(isObj(h.env) ? { env: h.env } : {}) })) };
233
+ const list = (hooks[claudeEvent] ??= []);
234
+ if (!list.some((x) => JSON.stringify(x) === JSON.stringify(g))) {
235
+ list.push(g);
236
+ hookFrom.push(`${claudeEvent} (${where})`);
237
+ }
238
+ };
239
+ const claudeSettings = readJson(join(home, ".claude", "settings.json"));
240
+ for (const [ev, groups] of Object.entries(isObj(claudeSettings.hooks) ? claudeSettings.hooks : {})) {
241
+ if (!(ev in CLAUDE_TO_GU) && !CLAUDE_ONLY_EVENTS.includes(ev)) {
242
+ if ((Array.isArray(groups) ? groups : []).some((g) => (g.hooks ?? []).some((h) => linked(h)))) warnings.push(`a ${ev} hook belongs to this skill, but no installer handles ${ev} — not packed`);
243
+ continue;
244
+ }
245
+ for (const g of Array.isArray(groups) ? groups : []) addHook(ev, g, "Claude Code");
246
+ }
247
+ const guSettings = readJson(join(home, ".gonext", "settings.json"));
248
+ for (const [ev, groups] of Object.entries(isObj(guSettings.hooks) ? guSettings.hooks : {})) {
249
+ for (const g of Array.isArray(groups) ? groups : []) {
250
+ if (!GU_TO_CLAUDE[ev]) continue;
251
+ if (g.dialect !== "claude") {
252
+ if ((g.hooks ?? []).some((h) => linked(h))) warnings.push(`gu's ${ev} hook for this skill speaks gu's own input format — not packed (only Claude-format hooks run in both tools)`);
253
+ continue;
254
+ }
255
+ addHook(GU_TO_CLAUDE[ev], g, "gu");
256
+ }
257
+ }
258
+
259
+ // Python: a command that is the runtime's own venv python.
260
+ let python = null;
261
+ let venvPy = null;
262
+ const isVenvPy = (c) => runtime && inside(c, runtime) && /[\\/](?:\.venv|venv)[\\/](?:bin[\\/]python[0-9.]*|Scripts[\\/]python(?:\.exe)?)$/.test(c);
263
+ for (const e of [...[...dup.values()].map((s) => s.entry), ...Object.values(hooks).flat().flatMap((g) => g.hooks)]) {
264
+ const c = String(e.command);
265
+ if (isAbsolute(c) && isVenvPy(c)) venvPy = c;
266
+ }
267
+ const runtimeTree = runtime ? packTree(runtime) : { files: new Map(), skipped: [] };
268
+ if (venvPy) {
269
+ if (!runtimeTree.files.has("requirements.txt")) throw new Error(`${venvPy} is a venv, but ${runtime} has no requirements.txt to rebuild it from — add one (pip freeze > requirements.txt) and pack again`);
270
+ python = { requirements: "requirements.txt" };
271
+ }
272
+
273
+ // Path → placeholder, longest first.
274
+ const subs = [];
275
+ const addSub = (from, to) => {
276
+ if (!from) return;
277
+ subs.push([from, to]);
278
+ if (from.startsWith(home + sep)) subs.push(["~" + from.slice(home.length).split(sep).join("/"), to]);
279
+ };
280
+ if (venvPy) addSub(venvPy, "{{PYTHON}}");
281
+ addSub(sk.dir, "{{SKILL}}");
282
+ if (runtime) addSub(runtime, "{{RUNTIME}}");
283
+ subs.push([home, "{{HOME}}"]);
284
+ subs.sort((a, b) => b[0].length - a[0].length);
285
+ const rewrite = (s) => {
286
+ let t = String(s);
287
+ for (const [from, to] of subs) t = t.split(from).join(to);
288
+ return t;
289
+ };
290
+ const env = new Set();
291
+ const cleanEntry = (e, label) => {
292
+ let command = String(e.command);
293
+ if (isAbsolute(command) && !(runtime && inside(command, runtime)) && !inside(command, sk.dir)) {
294
+ const base = command.split(/[\\/]/).pop().replace(/\.exe$/i, "");
295
+ if (INTERPRETERS.has(base.replace(/[0-9.]+$/, "")) || INTERPRETERS.has(base)) command = base;
296
+ else warnings.push(`${label} runs ${command}, which the other machine may not have`);
297
+ }
298
+ const out = { command: rewrite(command), args: (Array.isArray(e.args) ? e.args : []).map((a) => rewrite(a)) };
299
+ if (isObj(e.env)) {
300
+ out.env = {};
301
+ for (const [k, v] of Object.entries(e.env)) {
302
+ if (SECRET_NAME.test(k) || scanSecrets(String(v)).length) {
303
+ out.env[k] = `{{ENV:${k}}}`;
304
+ env.add(k);
305
+ } else out.env[k] = rewrite(v);
306
+ }
307
+ }
308
+ for (const a of [out.command, ...out.args]) {
309
+ if ((isAbsolute(a) || a.startsWith("~/")) && !a.startsWith("{{")) warnings.push(`${label} names ${a}, which is outside the skill and its runtime — it may not exist on the other machine`);
310
+ }
311
+ return out;
312
+ };
313
+ const mcpServers = Object.fromEntries([...dup.values()].map((s) => [s.name, cleanEntry(s.entry, `MCP server ${s.name}`)]));
314
+ const outHooks = Object.fromEntries(
315
+ Object.entries(hooks).map(([ev, groups]) => [ev, groups.map((g) => ({ matcher: g.matcher, hooks: g.hooks.map((h) => ({ type: "command", ...cleanEntry(h, `the ${ev} hook`), ...(h.timeout ? { timeout: h.timeout } : {}) })) }))])
316
+ );
317
+
318
+ // Rewrite and scan every text file.
319
+ const secrets = [];
320
+ const finish = (tree, prefix) => {
321
+ const out = new Map();
322
+ for (const [rel, f] of tree.files) {
323
+ if (!isText(f.data)) {
324
+ out.set(rel, f);
325
+ continue;
326
+ }
327
+ const text = rewrite(f.data.toString("utf8"));
328
+ for (const s of scanSecrets(text)) secrets.push({ file: `${prefix}/${rel}`, ...s });
329
+ out.set(rel, { data: Buffer.from(text, "utf8"), mode: f.mode });
330
+ }
331
+ return out;
332
+ };
333
+ const skill = finish(skillTree, "skill");
334
+ const runtimeFiles = finish(runtimeTree, "runtime");
335
+
336
+ const manifest = {
337
+ format: FORMAT,
338
+ name: sk.name,
339
+ description: sk.description,
340
+ packedAt: new Date().toISOString().slice(0, 10),
341
+ from: sk.source === "gu" ? "gu" : "Claude Code",
342
+ ...(python ? { python } : {}),
343
+ ...(Object.keys(mcpServers).length ? { mcpServers } : {}),
344
+ ...(Object.keys(outHooks).length ? { hooks: outHooks } : {}),
345
+ ...(env.size ? { env: [...env] } : {}),
346
+ };
347
+ const problems = manifestProblems(manifest);
348
+ if (problems.length) throw new Error(problems.join("; "));
349
+ return {
350
+ manifest,
351
+ skill,
352
+ runtime: runtimeFiles,
353
+ found: { skillDir: sk.dir, source: sk.source, scope: sk.scope, runtimeDir: runtime, servers: [...dup.values()].map((s) => `${s.name} (${s.where})`), hooks: hookFrom, python: !!python },
354
+ warnings: [...new Set(warnings)],
355
+ secrets,
356
+ skipped: [...skillTree.skipped.map((s) => `skill/${s}`), ...runtimeTree.skipped.map((s) => `runtime/${s}`)],
357
+ };
358
+ }
359
+
360
+ // ---- the zip -----------------------------------------------------------------------------------
361
+
362
+ const DOS_TIME = 0; // 00:00:00
363
+ const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1; // fixed: the same skill packs to the same bytes
364
+
365
+ /** Map<path, {data, mode}> → zip bytes (deflated; unix modes kept). */
366
+ export function writeZip(files) {
367
+ const locals = [];
368
+ const central = [];
369
+ let offset = 0;
370
+ for (const [name, f] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
371
+ const nameBuf = Buffer.from(name, "utf8");
372
+ const packed = deflateRawSync(f.data, { level: 9 });
373
+ const crc = crc32(f.data);
374
+ const h = Buffer.alloc(30);
375
+ h.writeUInt32LE(0x04034b50, 0);
376
+ h.writeUInt16LE(20, 4);
377
+ h.writeUInt16LE(0x0800, 6); // utf-8 names
378
+ h.writeUInt16LE(8, 8);
379
+ h.writeUInt16LE(DOS_TIME, 10);
380
+ h.writeUInt16LE(DOS_DATE, 12);
381
+ h.writeUInt32LE(crc, 14);
382
+ h.writeUInt32LE(packed.length, 18);
383
+ h.writeUInt32LE(f.data.length, 22);
384
+ h.writeUInt16LE(nameBuf.length, 26);
385
+ locals.push(h, nameBuf, packed);
386
+ const c = Buffer.alloc(46);
387
+ c.writeUInt32LE(0x02014b50, 0);
388
+ c.writeUInt16LE((3 << 8) | 20, 4); // made on unix: external attrs carry the mode
389
+ c.writeUInt16LE(20, 6);
390
+ c.writeUInt16LE(0x0800, 8);
391
+ c.writeUInt16LE(8, 10);
392
+ c.writeUInt16LE(DOS_TIME, 12);
393
+ c.writeUInt16LE(DOS_DATE, 14);
394
+ c.writeUInt32LE(crc, 16);
395
+ c.writeUInt32LE(packed.length, 20);
396
+ c.writeUInt32LE(f.data.length, 24);
397
+ c.writeUInt16LE(nameBuf.length, 28);
398
+ c.writeUInt32LE((((f.mode || 0o644) | 0o100000) << 16) >>> 0, 38);
399
+ c.writeUInt32LE(offset, 42);
400
+ central.push(c, nameBuf);
401
+ offset += 30 + nameBuf.length + packed.length;
402
+ }
403
+ const cd = Buffer.concat(central);
404
+ const end = Buffer.alloc(22);
405
+ end.writeUInt32LE(0x06054b50, 0);
406
+ end.writeUInt16LE(files.size, 8);
407
+ end.writeUInt16LE(files.size, 10);
408
+ end.writeUInt32LE(cd.length, 12);
409
+ end.writeUInt32LE(offset, 16);
410
+ return Buffer.concat([...locals, cd, end]);
411
+ }
412
+
413
+ /** A zip entry's name: "/" always, whatever this machine uses (the zip format says so). */
414
+ const zipPath = (...parts) => parts.join("/");
415
+
416
+ /** The README that rides in the zip. */
417
+ export function readmeFor(manifest) {
418
+ const n = manifest.name;
419
+ return [
420
+ `${n} — a skill packed by gu`,
421
+ "",
422
+ manifest.description,
423
+ "",
424
+ "Install (Node.js 18+; Python 3.9+ too if the skill uses Python):",
425
+ ` in gu: /skill-install ${n}.zip`,
426
+ ` without gu: unzip ${n}.zip && node ${zipPath(n, "install.mjs")}`,
427
+ "",
428
+ "It installs for gu (~/.gonext) and Claude Code (~/.claude), whichever is set up on the machine;",
429
+ "--gu or --claude picks one, --dry-run shows the plan without writing anything.",
430
+ ...(manifest.env?.length ? ["", `Set these in the terminal first (they were not packed): ${manifest.env.join(", ")}`] : []),
431
+ "",
432
+ ].join("\n");
433
+ }
434
+
435
+ /** The whole package as zip entries under one top folder. */
436
+ export function packageFiles(plan) {
437
+ const n = plan.manifest.name;
438
+ const files = new Map();
439
+ const installer = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "skill-install.mjs"));
440
+ files.set(zipPath(n, "install.mjs"), { data: installer, mode: 0o755 });
441
+ files.set(zipPath(n, MANIFEST), { data: Buffer.from(JSON.stringify(plan.manifest, null, 2) + "\n"), mode: 0o644 });
442
+ files.set(zipPath(n, "README.txt"), { data: Buffer.from(readmeFor(plan.manifest)), mode: 0o644 });
443
+ for (const [rel, f] of plan.skill) files.set(zipPath(n, "skill", rel), f);
444
+ for (const [rel, f] of plan.runtime) files.set(zipPath(n, "runtime", rel), f);
445
+ return files;
446
+ }
447
+
448
+ /** Write the zip → { file, bytes, count }. Refuses while the plan has secret findings. */
449
+ export function writeSkillPack(plan, outFile) {
450
+ if (plan.secrets.length) throw new Error(`secret-looking text in ${plan.secrets.map((s) => `${s.file}:${s.line}`).join(", ")} — remove it and pack again`);
451
+ const files = packageFiles(plan);
452
+ const zip = writeZip(files);
453
+ writeFileSync(outFile, zip);
454
+ return { file: outFile, bytes: zip.length, count: files.size };
455
+ }