@tiens.nguyen/gu-cli 1.0.883 → 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 +34 -18
- package/gu-repl.mjs +156 -1
- 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,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
|
+
}
|