@tiens.nguyen/gu-cli 1.0.872 → 1.0.874

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.872",
3
+ "version": "1.0.874",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -134,6 +134,7 @@
134
134
  "update-verify.mjs",
135
135
  "skills.mjs",
136
136
  "skill-source.mjs",
137
+ "skills-copy.mjs",
137
138
  "shell-details.mjs",
138
139
  "proc-identity.mjs",
139
140
  "win-path.mjs",
@@ -0,0 +1,266 @@
1
+ /**
2
+ * /skills-copy-to-claude — copy gu's skills into Claude Code's folders, under Claude's names (#241).
3
+ *
4
+ * ~/.gonext/skills/<name>/ → ~/.claude/skills/<claude-name>/
5
+ * <project>/.gonext/skills/<name>/ → <project>/.claude/skills/<claude-name>/
6
+ * the skill's MCP server in ~/.gonext/mcp.json → ~/.claude.json
7
+ * (user scope for a home skill, projects["<folder>"] for a project skill)
8
+ *
9
+ * The web-v2 Skills page's installer (#208, src/skills/email/install.mjs) puts a skill's files into
10
+ * ~/.gonext/skills/<name>/ and its MCP server into ~/.gonext/mcp.json. This is the same move in the
11
+ * other direction, so a skill set up for gu also works in Claude Code.
12
+ *
13
+ * NAMES. gu's own skills start with "gu-" where Claude's start with "claude-" (the memory skill is
14
+ * gu-session-memory-orchestration here and claude-session-memory-orchestration there). So "gu-"
15
+ * becomes "claude-" in the folder name, the frontmatter `name:` and the text. Other names stay.
16
+ *
17
+ * TEXT. Only SKILL.md is rewritten, and only what is certain: paths into the skill's own folder
18
+ * (they now point at the copy) and the old name. Other mentions of .gonext (gu's memory CLI, say)
19
+ * may have no Claude equivalent, so they are left as they are and REPORTED, never guessed.
20
+ *
21
+ * A COPY. Nothing under ~/.gonext changes. Claude's file ~/.claude.json is changed only through
22
+ * updateClaudeConfig, and only its mcpServers. A Claude skill that differs is kept unless the user
23
+ * chose "replace"; a replaced folder goes to ~/.gonext/skills-copy-backups/<time>/, never next to
24
+ * the others in .claude/skills, where Claude would load it as a second skill.
25
+ */
26
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { join, relative, resolve, sep } from "node:path";
29
+ import { scanSkillsDir } from "./skills.mjs";
30
+ import { readClaudeServers, readUserConfig, updateClaudeConfig } from "./mcp-config.mjs";
31
+
32
+ export const BACKUP_DIR_NAME = "skills-copy-backups";
33
+
34
+ /** Claude's name for a gu skill: "gu-" → "claude-", else the same. Kept within Claude's 64. */
35
+ export function claudeSkillName(name) {
36
+ const n = String(name);
37
+ return n.startsWith("gu-") ? `claude-${n.slice(3)}`.slice(0, 64) : n;
38
+ }
39
+
40
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+
42
+ /**
43
+ * SKILL.md for Claude: its own-folder paths and its old name rewritten. → { text, guLines }
44
+ * guLines = line numbers (1-based) that still mention .gonext after the rewrite — for the user to check.
45
+ */
46
+ export function rewriteSkillText(text, { from, to }) {
47
+ let out = String(text);
48
+ // ".gonext/skills/<from>" in any form (~/, $HOME/, an absolute home, ./, Windows backslashes).
49
+ const pathRe = new RegExp(`\\.gonext([\\\\/])skills\\1${escapeRe(from)}(?![a-z0-9-])`, "g");
50
+ out = out.replace(pathRe, (_m, s) => `.claude${s}skills${s}${to}`);
51
+ if (to !== from) {
52
+ // The name itself: in the frontmatter, as /<name>, in use_skill("<name>")…
53
+ const nameRe = new RegExp(`(?<![a-z0-9-])${escapeRe(from)}(?![a-z0-9-])`, "g");
54
+ out = out.replace(nameRe, to);
55
+ }
56
+ const guLines = out
57
+ .split("\n")
58
+ .map((l, i) => (/\.gonext\b/.test(l) ? i + 1 : 0))
59
+ .filter(Boolean);
60
+ return { text: out, guLines };
61
+ }
62
+
63
+ /**
64
+ * `p` inside `dir` → the same path inside `toDir`, else null. Slashes and (on Windows) case are
65
+ * ignored when comparing, and a leading ~ counts as the home folder.
66
+ */
67
+ export function movePath(p, dir, toDir, { home = homedir(), platform = process.platform } = {}) {
68
+ if (typeof p !== "string" || !p) return null;
69
+ const expanded = /^~(?=$|[\\/])/.test(p) ? home + p.slice(1) : p;
70
+ const norm = (s) => {
71
+ const x = s.replace(/\\/g, "/").replace(/\/+$/, "");
72
+ return platform === "win32" ? x.toLowerCase() : x;
73
+ };
74
+ const a = norm(expanded);
75
+ const d = norm(dir);
76
+ if (a !== d && !a.startsWith(d + "/")) return null;
77
+ const rest = expanded.replace(/\\/g, "/").replace(/\/+$/, "").slice(d.length).replace(/^\//, "");
78
+ const s = platform === "win32" ? "\\" : "/";
79
+ const base = toDir.replace(/[\\/]+$/, "");
80
+ return rest ? base + s + rest.split("/").join(s) : base;
81
+ }
82
+
83
+ /** An MCP entry with every path into the skill folder moved to the copy. → { entry, linked } */
84
+ export function moveServerEntry(entry, fromDir, toDir, opts) {
85
+ let linked = false;
86
+ const mv = (v) => {
87
+ const m = movePath(v, fromDir, toDir, opts);
88
+ if (m == null) return v;
89
+ linked = true;
90
+ return m;
91
+ };
92
+ const out = { ...entry };
93
+ if (typeof out.command === "string") out.command = mv(out.command);
94
+ if (Array.isArray(out.args)) out.args = out.args.map((a) => (typeof a === "string" ? mv(a) : a));
95
+ if (out.env && typeof out.env === "object") {
96
+ out.env = Object.fromEntries(Object.entries(out.env).map(([k, v]) => [k, typeof v === "string" ? mv(v) : v]));
97
+ }
98
+ if (typeof out.cwd === "string") out.cwd = mv(out.cwd);
99
+ return { entry: out, linked };
100
+ }
101
+
102
+ /** Every file of a folder (following links), as relative path → { data, mode }. .DS_Store left out. */
103
+ function readTree(dir) {
104
+ const files = new Map();
105
+ const walk = (d) => {
106
+ for (const name of readdirSync(d)) {
107
+ if (name === ".DS_Store") continue;
108
+ const p = join(d, name);
109
+ let st;
110
+ try {
111
+ st = statSync(p);
112
+ } catch {
113
+ continue; // a dangling link
114
+ }
115
+ if (st.isDirectory()) walk(p);
116
+ else if (st.isFile()) files.set(relative(dir, p).split(sep).join("/"), { data: readFileSync(p), mode: st.mode & 0o777 });
117
+ }
118
+ };
119
+ walk(dir);
120
+ return files;
121
+ }
122
+
123
+ function sameTree(a, b) {
124
+ if (a.size !== b.size) return false;
125
+ for (const [k, v] of a) {
126
+ const w = b.get(k);
127
+ if (!w || !w.data.equals(v.data)) return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
133
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
134
+
135
+ /**
136
+ * What would be copied. Reads only. `names` (gu's or Claude's names) narrows it to those skills.
137
+ *
138
+ * → { items: [{ name, newName, scope, from, to, status: "new"|"same"|"differs", guLines, files,
139
+ * servers: [{ name, scope, entry, status }] }],
140
+ * warnings, missing: names asked for that gu does not have }
141
+ */
142
+ export function planSkillsCopy({ cwd = process.cwd(), home = homedir(), names = [], platform = process.platform } = {}) {
143
+ const dirs = [{ scope: "user", from: join(home, ".gonext", "skills"), to: join(home, ".claude", "skills") }];
144
+ if (cwd && resolve(cwd) !== resolve(home)) {
145
+ dirs.push({ scope: "project", from: join(cwd, ".gonext", "skills"), to: join(cwd, ".claude", "skills") });
146
+ }
147
+ const gu = readUserConfig(home);
148
+ const claude = readClaudeServers({ cwd, home });
149
+ const warnings = [];
150
+ if (gu.error) warnings.push(`~/.gonext/mcp.json is ${gu.error} — no MCP servers are copied with the skills`);
151
+ if (claude.error) warnings.push(`~/.claude.json is ${claude.error} — no MCP servers can be copied into it`);
152
+
153
+ const wanted = new Set(names.map((n) => String(n).replace(/^\//, "")));
154
+ const items = [];
155
+ for (const d of dirs) {
156
+ const scan = scanSkillsDir(d.from, d.scope);
157
+ warnings.push(...scan.warnings);
158
+ for (const s of scan.skills) {
159
+ const newName = claudeSkillName(s.name);
160
+ if (wanted.size && !wanted.has(s.name) && !wanted.has(newName)) continue;
161
+ const folder = s.dir.split(/[\\/]/).pop();
162
+ const to = join(d.to, newName);
163
+ const files = readTree(s.dir);
164
+ const md = files.get("SKILL.md");
165
+ const rewritten = rewriteSkillText(md.data.toString("utf8"), { from: s.name, to: newName });
166
+ // A folder named differently from its frontmatter name: rewrite its paths too.
167
+ const text = folder !== s.name ? rewriteSkillText(rewritten.text, { from: folder, to: newName }) : rewritten;
168
+ files.set("SKILL.md", { data: Buffer.from(text.text, "utf8"), mode: md.mode });
169
+
170
+ let status = "new";
171
+ if (existsSync(to)) status = sameTree(files, readTree(to)) ? "same" : "differs";
172
+
173
+ const servers = [];
174
+ if (!gu.error && !claude.error) {
175
+ const have = d.scope === "project" ? claude.local : claude.user;
176
+ for (const [sname, entry] of Object.entries(gu.mcpServers)) {
177
+ if (!isObj(entry)) continue;
178
+ const moved = moveServerEntry(entry, s.dir, to, { home, platform });
179
+ if (!moved.linked) continue;
180
+ const st = !(sname in have) ? "new" : sameJson(have[sname], moved.entry) ? "same" : "differs";
181
+ servers.push({ name: sname, scope: d.scope === "project" ? "local" : "user", entry: moved.entry, status: st });
182
+ }
183
+ }
184
+ items.push({ name: s.name, newName, scope: d.scope, from: s.dir, to, status, guLines: text.guLines, files, servers });
185
+ }
186
+ }
187
+ const have = new Set(items.flatMap((i) => [i.name, i.newName]));
188
+ const missing = [...wanted].filter((n) => !have.has(n));
189
+ return { items, warnings, missing };
190
+ }
191
+
192
+ function writeTree(dir, files) {
193
+ for (const [rel, f] of files) {
194
+ const p = join(dir, ...rel.split("/"));
195
+ mkdirSync(join(p, ".."), { recursive: true });
196
+ writeFileSync(p, f.data, { mode: f.mode || 0o644 });
197
+ }
198
+ }
199
+
200
+ /** Move a folder; across disks, copy then remove. */
201
+ function moveDir(from, to) {
202
+ mkdirSync(join(to, ".."), { recursive: true });
203
+ try {
204
+ renameSync(from, to);
205
+ } catch {
206
+ cpSync(from, to, { recursive: true });
207
+ rmSync(from, { recursive: true, force: true });
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Do it. `replace` = also overwrite skills and servers Claude has in a different version (the old
213
+ * folder is backed up first). → { done: [{ name, newName, to, action, backup? }],
214
+ * servers: [{ name, scope, action }], errors: string[] }
215
+ * action: "copied" | "replaced" | "kept" | "already"
216
+ */
217
+ export function applySkillsCopy(plan, { replace = false, cwd = process.cwd(), home = homedir(), now = new Date() } = {}) {
218
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
219
+ const backupRoot = join(home, ".gonext", BACKUP_DIR_NAME, stamp);
220
+ const done = [];
221
+ const servers = [];
222
+ const errors = [];
223
+ for (const it of plan.items) {
224
+ try {
225
+ if (it.status === "same") done.push({ name: it.name, newName: it.newName, to: it.to, action: "already" });
226
+ else if (it.status === "differs" && !replace) done.push({ name: it.name, newName: it.newName, to: it.to, action: "kept" });
227
+ else {
228
+ let backup;
229
+ if (it.status === "differs") {
230
+ backup = join(backupRoot, it.scope, it.newName);
231
+ moveDir(it.to, backup);
232
+ }
233
+ mkdirSync(it.to, { recursive: true });
234
+ writeTree(it.to, it.files);
235
+ done.push({ name: it.name, newName: it.newName, to: it.to, action: backup ? "replaced" : "copied", ...(backup ? { backup } : {}) });
236
+ }
237
+ } catch (err) {
238
+ errors.push(`${it.name}: ${err.message}`);
239
+ continue;
240
+ }
241
+ const write = it.servers.filter((s) => s.status === "new" || (s.status === "differs" && replace));
242
+ for (const s of it.servers) {
243
+ if (!write.includes(s)) servers.push({ name: s.name, scope: s.scope, action: s.status === "same" ? "already" : "kept" });
244
+ }
245
+ if (!write.length) continue;
246
+ try {
247
+ updateClaudeConfig((raw) => {
248
+ for (const s of write) {
249
+ let into;
250
+ if (s.scope === "local") {
251
+ raw.projects = isObj(raw.projects) ? raw.projects : {};
252
+ const key = resolve(cwd);
253
+ raw.projects[key] = isObj(raw.projects[key]) ? raw.projects[key] : {};
254
+ into = raw.projects[key];
255
+ } else into = raw;
256
+ into.mcpServers = isObj(into.mcpServers) ? into.mcpServers : {};
257
+ into.mcpServers[s.name] = s.entry;
258
+ }
259
+ }, home);
260
+ for (const s of write) servers.push({ name: s.name, scope: s.scope, action: s.status === "differs" ? "replaced" : "copied" });
261
+ } catch (err) {
262
+ errors.push(`MCP ${write.map((s) => s.name).join(", ")}: ${err.message}`);
263
+ }
264
+ }
265
+ return { done, servers, errors, backupRoot };
266
+ }
package/skills.mjs CHANGED
@@ -94,6 +94,9 @@ function isDir(p) {
94
94
  }
95
95
 
96
96
  /** One skills folder → { skills, warnings }. A folder that is not there is simply empty. */
97
+ export function scanSkillsDir(dir, scope) {
98
+ return scanDir(dir, scope);
99
+ }
97
100
  function scanDir(dir, scope) {
98
101
  const skills = [];
99
102
  const warnings = [];