dk-frontend-skills 1.1.3 → 2.0.0

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": "dk-frontend-skills",
3
- "version": "1.1.3",
3
+ "version": "2.0.0",
4
4
  "description": "dk-engineer - 幽默沉稳靠谱的前端开发助手 Claude Skills 配置包,一键注入 .claude/ 技能目录和 CLAUDE.md 人设配置",
5
5
  "author": "XiaoMa",
6
6
  "license": "MIT",
@@ -10,6 +10,9 @@
10
10
  "CLAUDE.md",
11
11
  "scripts/"
12
12
  ],
13
+ "bin": {
14
+ "dk-skills": "./scripts/cli.js"
15
+ },
13
16
  "scripts": {
14
17
  "postinstall": "node scripts/copy-skills.js"
15
18
  }
package/scripts/cli.js ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const readline = require("readline");
6
+ const { getPackageSkills, getUserSkills } = require("./core");
7
+
8
+ // 定位项目根目录和包目录
9
+ const projectRoot = path.resolve(__dirname, "..", "..", "..");
10
+ const packageDir = path.join(__dirname, "..");
11
+ const claudeDest = path.join(projectRoot, ".claude");
12
+ const settingsPath = path.join(claudeDest, "settings.json");
13
+
14
+ const command = process.argv[2];
15
+ const args = process.argv.slice(3);
16
+
17
+ // ---------- 辅助函数 ----------
18
+
19
+ function readSettings() {
20
+ if (!fs.existsSync(settingsPath)) return { skills: {}, always_apply_skills: [] };
21
+ try {
22
+ return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
23
+ } catch {
24
+ return { skills: {}, always_apply_skills: [] };
25
+ }
26
+ }
27
+
28
+ function writeSettings(settings) {
29
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
30
+ }
31
+
32
+ /**
33
+ * 保存技能选择结果到 settings.json
34
+ */
35
+ function saveSkillSelection(choices) {
36
+ const settings = readSettings();
37
+ if (!settings.skills) settings.skills = {};
38
+
39
+ for (const { name, enabled } of choices) {
40
+ if (!settings.skills[name]) {
41
+ settings.skills[name] = {};
42
+ }
43
+ settings.skills[name].enabled = enabled;
44
+ }
45
+
46
+ writeSettings(settings);
47
+ }
48
+
49
+ // ---------- 命令:list ----------
50
+
51
+ function cmdList() {
52
+ const pkgSkills = getPackageSkills(packageDir);
53
+ const userSkills = getUserSkills(claudeDest);
54
+
55
+ // 合并包内和用户已安装的技能状态
56
+ const skillMap = new Map();
57
+ for (const s of userSkills) skillMap.set(s.name, s);
58
+ for (const s of pkgSkills) {
59
+ if (!skillMap.has(s.name)) {
60
+ skillMap.set(s.name, { ...s, enabled: false, installed: false });
61
+ }
62
+ }
63
+
64
+ const allSkills = [...skillMap.values()];
65
+
66
+ console.log("\n📋 dk-frontend-skills 技能清单\n");
67
+ console.log(" 状态 技能名 版本 描述");
68
+ console.log(" ─────────────────────────────────────────────────────");
69
+
70
+ for (const skill of allSkills) {
71
+ const status = skill.enabled ? "✅ 启用" : "⏸️ 停用";
72
+ const name = skill.name.padEnd(20);
73
+ const ver = skill.version.padEnd(7);
74
+ const desc = skill.description || "(无描述)";
75
+ console.log(` ${status} ${name} ${ver} ${desc}`);
76
+ }
77
+
78
+ console.log(`\n 💡 运行 npx dk-skills 进入交互选择模式\n`);
79
+ }
80
+
81
+ // ---------- 命令:enable / disable ----------
82
+
83
+ function cmdToggle(enable, names) {
84
+ if (names.length === 0) {
85
+ console.log(` 用法: npx dk-skills ${enable ? "enable" : "disable"} <技能名...>`);
86
+ return;
87
+ }
88
+
89
+ const pkgSkills = getPackageSkills(packageDir);
90
+ const pkgNames = new Set(pkgSkills.map((s) => s.name));
91
+ const userSkills = getUserSkills(claudeDest);
92
+ const userNames = new Set(userSkills.map((s) => s.name));
93
+
94
+ const notFound = names.filter((n) => !pkgNames.has(n));
95
+ if (notFound.length > 0) {
96
+ console.log(` ⚠️ 未知技能: ${notFound.join(", ")}`);
97
+ console.log(` 可用: ${[...pkgNames].join(", ")}\n`);
98
+ return;
99
+ }
100
+
101
+ const notInstalled = names.filter((n) => !userNames.has(n));
102
+ if (notInstalled.length > 0) {
103
+ console.log(` ⚠️ 以下技能尚未安装: ${notInstalled.join(", ")}`);
104
+ console.log(` 请先执行 npm i dk-frontend-skills 安装完整技能包\n`);
105
+ return;
106
+ }
107
+
108
+ const choices = userSkills.map((s) => ({
109
+ name: s.name,
110
+ enabled: names.includes(s.name) ? enable : s.enabled,
111
+ }));
112
+
113
+ saveSkillSelection(choices);
114
+
115
+ const action = enable ? "启用" : "停用";
116
+ console.log(` ✅ 已${action}: ${names.join(", ")}\n`);
117
+ }
118
+
119
+ // ---------- 交互式 TUI 菜单 ----------
120
+
121
+ function startInteractiveMenu() {
122
+ const pkgSkills = getPackageSkills(packageDir);
123
+
124
+ if (pkgSkills.length === 0) {
125
+ console.log(" ⚠️ 包内没有可用技能\n");
126
+ process.exit(0);
127
+ }
128
+
129
+ // 读取当前 settings 中的启用状态
130
+ const userSkills = getUserSkills(claudeDest);
131
+ const userMap = new Map(userSkills.map((s) => [s.name, s.enabled]));
132
+
133
+ // choices 只包含包内有的技能
134
+ const skills = pkgSkills.map((s) => ({
135
+ ...s,
136
+ enabled: userMap.has(s.name) ? userMap.get(s.name) : false,
137
+ }));
138
+
139
+ let cursor = 0;
140
+ const toggled = skills.map((s) => s.enabled);
141
+ const stdin = process.stdin;
142
+ const stdout = process.stdout;
143
+
144
+ function render() {
145
+ stdout.write("\x1Bc"); // 清屏
146
+ stdout.write("╔══════════════════════════════════════════════╗\n");
147
+ stdout.write("║ dk-frontend-skills 技能选择 ║\n");
148
+ stdout.write("╠══════════════════════════════════════════════╣\n");
149
+ stdout.write("║ ↑↓ 导航 ␣ 开关 Enter 确认 q 退出 ║\n");
150
+ stdout.write("╚══════════════════════════════════════════════╝\n\n");
151
+
152
+ for (let i = 0; i < skills.length; i++) {
153
+ const s = skills[i];
154
+ const arrow = i === cursor ? "❯" : " ";
155
+ const check = toggled[i] ? "✓" : " ";
156
+ const name = s.name.padEnd(20);
157
+ const desc = s.description || "";
158
+ stdout.write(` ${arrow} [${check}] ${name} ${desc}\n`);
159
+ }
160
+
161
+ stdout.write("\n 选中: ");
162
+ const selectedNames = skills.filter((_, i) => toggled[i]).map((s) => s.name);
163
+ if (selectedNames.length === 0) {
164
+ stdout.write("(无)");
165
+ } else if (selectedNames.length <= 3) {
166
+ stdout.write(selectedNames.join(", "));
167
+ } else {
168
+ stdout.write(`${selectedNames.length} 个技能`);
169
+ }
170
+ stdout.write("\n");
171
+ }
172
+
173
+ function exitMenu(saved) {
174
+ stdin.setRawMode(false);
175
+ stdin.pause();
176
+ if (saved) {
177
+ console.log("\n ✅ 技能选择已保存\n");
178
+ }
179
+ process.exit(0);
180
+ }
181
+
182
+ render();
183
+
184
+ readline.emitKeypressEvents(stdin);
185
+ if (stdin.isTTY) stdin.setRawMode(true);
186
+ stdin.resume();
187
+
188
+ stdin.on("keypress", (str, key) => {
189
+ if (!key) return;
190
+
191
+ if (key.name === "up") {
192
+ cursor = Math.max(0, cursor - 1);
193
+ render();
194
+ } else if (key.name === "down") {
195
+ cursor = Math.min(skills.length - 1, cursor + 1);
196
+ render();
197
+ } else if (key.name === "space") {
198
+ toggled[cursor] = !toggled[cursor];
199
+ render();
200
+ } else if (key.name === "return") {
201
+ const choices = skills.map((s, i) => ({ name: s.name, enabled: toggled[i] }));
202
+ saveSkillSelection(choices);
203
+ exitMenu(true);
204
+ } else if (key.name === "q" || (key.ctrl && key.name === "c")) {
205
+ exitMenu(false);
206
+ }
207
+ });
208
+ }
209
+
210
+ // ---------- 主入口 ----------
211
+
212
+ const COMMANDS = ["list", "enable", "disable"];
213
+
214
+ if (!command) {
215
+ // 无参数 → 启动交互菜单
216
+ startInteractiveMenu();
217
+ } else if (command === "--help" || command === "-h") {
218
+ console.log(`
219
+ dk-frontend-skills CLI
220
+
221
+ 用法:
222
+ npx dk-skills 交互式技能选择菜单
223
+ npx dk-skills list 查看技能清单
224
+ npx dk-skills enable <技能名...> 启用指定技能
225
+ npx dk-skills disable <技能名...> 停用指定技能
226
+ npx dk-skills --help 显示帮助
227
+
228
+ 示例:
229
+ npx dk-skills list
230
+ npx dk-skills enable vue fe-biz-patterns
231
+ npx dk-skills disable moai-framework-electron
232
+ `);
233
+ } else if (command === "list") {
234
+ cmdList();
235
+ } else if (command === "enable") {
236
+ cmdToggle(true, args);
237
+ } else if (command === "disable") {
238
+ cmdToggle(false, args);
239
+ } else {
240
+ console.log(` 未知命令: ${command}\n 可用: ${COMMANDS.join(", ")}\n`);
241
+ process.exit(1);
242
+ }
@@ -1,6 +1,12 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
- const { execSync, spawn } = require("child_process");
3
+ const {
4
+ backupTimestamp,
5
+ appendLog,
6
+ backupDir,
7
+ installSkills,
8
+ installSettings,
9
+ } = require("./core");
4
10
 
5
11
  // 获取用户项目根目录
6
12
  const projectRoot = path.resolve(__dirname, "..", "..", "..");
@@ -17,214 +23,6 @@ const mdDest = path.join(projectRoot, "CLAUDE.md");
17
23
  // 日志文件
18
24
  const logFile = path.join(claudeDest, ".install.log");
19
25
 
20
- // 时间戳格式:YYYY-MM-DD HH:mm:ss
21
- function timestamp() {
22
- const d = new Date();
23
- const pad = (n) => String(n).padStart(2, "0");
24
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
25
- }
26
-
27
- // 紧凑时间戳用于备份目录名:YYYYMMDD.HHmmss
28
- function backupTimestamp() {
29
- const d = new Date();
30
- const pad = (n) => String(n).padStart(2, "0");
31
- return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}.${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
32
- }
33
-
34
- /**
35
- * 追加日志到 .claude/.install.log
36
- * 如果 .claude 目录还不存在,先创建
37
- */
38
- function appendLog(level, message) {
39
- const dir = path.dirname(logFile);
40
- if (!fs.existsSync(dir)) {
41
- fs.mkdirSync(dir, { recursive: true });
42
- }
43
- const line = `[${timestamp()}] [${level}] ${message}\n`;
44
- fs.appendFileSync(logFile, line, "utf-8");
45
- }
46
-
47
- /**
48
- * 备份已有目录到 .claude/backups/<timestamp>/
49
- * 注意:排除 backups/ 自身,避免复制到子目录
50
- * 清理超过 3 份的旧备份
51
- */
52
- function backupDir(dir) {
53
- if (!fs.existsSync(dir)) return null;
54
-
55
- const backupsDir = path.join(dir, "backups");
56
- if (!fs.existsSync(backupsDir)) {
57
- fs.mkdirSync(backupsDir, { recursive: true });
58
- }
59
-
60
- const ts = backupTimestamp();
61
- const bakPath = path.join(backupsDir, ts);
62
- fs.mkdirSync(bakPath, { recursive: true });
63
-
64
- // 逐项复制,排除 backups/ 避免自引用
65
- for (const item of fs.readdirSync(dir)) {
66
- if (item === "backups") continue;
67
- fs.cpSync(path.join(dir, item), path.join(bakPath, item), { recursive: true });
68
- }
69
-
70
- appendLog("BACKUP", `backups/${ts}/ created`);
71
- console.log(` 💾 备份 → .claude/backups/${ts}/`);
72
-
73
- // 清理旧备份,只保留最近 3 份
74
- const backups = fs
75
- .readdirSync(backupsDir)
76
- .filter((name) => /^\d{8}\.\d{6}$/.test(name))
77
- .sort()
78
- .reverse();
79
-
80
- if (backups.length > 3) {
81
- for (const old of backups.slice(3)) {
82
- const oldPath = path.join(backupsDir, old);
83
- fs.rmSync(oldPath, { recursive: true, force: true });
84
- appendLog("CLEAN", `清除旧备份 backups/${old}/`);
85
- }
86
- }
87
-
88
- return bakPath;
89
- }
90
-
91
- /**
92
- * 递归深合并
93
- * 用户值优先(原样保留),目标新增字段补充
94
- * 只有纯对象才递归合并,数组和原始值直接取用户的值
95
- */
96
- function deepMerge(userObj, pkgObj) {
97
- const result = { ...userObj };
98
-
99
- for (const key of Object.keys(pkgObj)) {
100
- if (!(key in result)) {
101
- // 用户没有这个 key,直接用包的
102
- result[key] = pkgObj[key];
103
- } else if (
104
- isPlainObject(pkgObj[key]) &&
105
- isPlainObject(result[key])
106
- ) {
107
- // 双方都是纯对象,递归合并
108
- result[key] = deepMerge(result[key], pkgObj[key]);
109
- }
110
- // 其他情况:用户值优先,不动
111
- }
112
-
113
- return result;
114
- }
115
-
116
- function isPlainObject(val) {
117
- return Object.prototype.toString.call(val) === "[object Object]";
118
- }
119
-
120
- /**
121
- * 读取技能目录下的 .meta.json
122
- * 没有则返回 null
123
- */
124
- function readMeta(skillDir) {
125
- const metaPath = path.join(skillDir, ".meta.json");
126
- if (fs.existsSync(metaPath)) {
127
- try {
128
- return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
129
- } catch {
130
- return null;
131
- }
132
- }
133
- return null;
134
- }
135
-
136
- /**
137
- * 安装技能
138
- * 逐个复制技能目录,补缺不覆盖,记录日志
139
- */
140
- function installSkills(skillsSource, skillsDest) {
141
- if (!fs.existsSync(skillsSource)) return [];
142
-
143
- if (!fs.existsSync(skillsDest)) {
144
- fs.mkdirSync(skillsDest, { recursive: true });
145
- }
146
-
147
- const skillDirs = fs.readdirSync(skillsSource);
148
- const results = [];
149
-
150
- for (const dir of skillDirs) {
151
- const srcSkill = path.join(skillsSource, dir);
152
- const destSkill = path.join(skillsDest, dir);
153
- const meta = readMeta(srcSkill);
154
- const version = meta ? meta.version : "?";
155
-
156
- if (!fs.existsSync(destSkill)) {
157
- // 新安装
158
- fs.cpSync(srcSkill, destSkill, { recursive: true });
159
- appendLog("SKILL", `${dir} (${version}) → installed`);
160
- results.push({ name: dir, version, action: "installed" });
161
- } else {
162
- // 补版本:检测元信息,有更新才覆盖
163
- const destMeta = readMeta(destSkill);
164
- const needUpdate = meta && destMeta && meta.version !== destMeta.version;
165
-
166
- if (needUpdate) {
167
- // 先备份旧技能
168
- const bakName = `${dir}.bak.${backupTimestamp()}`;
169
- const bakPath = path.join(skillsDest, bakName);
170
- fs.cpSync(destSkill, bakPath, { recursive: true });
171
-
172
- // 删旧的,复制新的
173
- fs.rmSync(destSkill, { recursive: true, force: true });
174
- fs.cpSync(srcSkill, destSkill, { recursive: true });
175
-
176
- appendLog("SKILL", `${dir} (${destMeta.version} → ${meta.version}) → updated`);
177
- results.push({ name: dir, version: meta.version, action: "updated" });
178
- } else {
179
- appendLog("SKILL", `${dir} (${version}) → skipped (exists)`);
180
- results.push({ name: dir, version, action: "skipped" });
181
- }
182
- }
183
- }
184
-
185
- return results;
186
- }
187
-
188
- /**
189
- * 安装 settings.json
190
- * 用深度合并策略,用户自定义优先
191
- */
192
- function installSettings(sourceDir, destDir) {
193
- const settingsSource = path.join(sourceDir, "settings.json");
194
- const settingsDest = path.join(destDir, "settings.json");
195
- const localSource = path.join(sourceDir, "settings.local.json");
196
- const localDest = path.join(destDir, "settings.local.json");
197
-
198
- if (fs.existsSync(settingsSource)) {
199
- if (fs.existsSync(settingsDest)) {
200
- // 深度合并
201
- const userSettings = JSON.parse(fs.readFileSync(settingsDest, "utf-8"));
202
- const pkgSettings = JSON.parse(fs.readFileSync(settingsSource, "utf-8"));
203
- const merged = deepMerge(userSettings, pkgSettings);
204
- fs.writeFileSync(settingsDest, JSON.stringify(merged, null, 2) + "\n", "utf-8");
205
- appendLog("SETTINGS", "settings.json merged");
206
- } else {
207
- // 用户没有,直接复制
208
- fs.copyFileSync(settingsSource, settingsDest);
209
- appendLog("SETTINGS", "settings.json created");
210
- }
211
- }
212
-
213
- // settings.local.json 同样处理
214
- if (fs.existsSync(localSource)) {
215
- if (fs.existsSync(localDest)) {
216
- const userLocal = JSON.parse(fs.readFileSync(localDest, "utf-8"));
217
- const pkgLocal = JSON.parse(fs.readFileSync(localSource, "utf-8"));
218
- const merged = deepMerge(userLocal, pkgLocal);
219
- fs.writeFileSync(localDest, JSON.stringify(merged, null, 2) + "\n", "utf-8");
220
- appendLog("SETTINGS", "settings.local.json merged");
221
- } else {
222
- fs.copyFileSync(localSource, localDest);
223
- appendLog("SETTINGS", "settings.local.json created");
224
- }
225
- }
226
- }
227
-
228
26
  // ===================== 主流程 =====================
229
27
 
230
28
  console.log("\n📦 dk-frontend-skills 技能包安装开始\n");
@@ -232,30 +30,28 @@ console.log("\n📦 dk-frontend-skills 技能包安装开始\n");
232
30
  if (fs.existsSync(claudeSource)) {
233
31
  if (fs.existsSync(claudeDest)) {
234
32
  // .claude 已存在 → 备份后再操作
235
- appendLog("INFO", "Install started (upgrade)");
33
+ appendLog(logFile, "INFO", "Install started (upgrade)");
236
34
  backupDir(claudeDest);
237
35
 
238
36
  const skillsSource = path.join(claudeSource, "skills");
239
37
  const skillsDest = path.join(claudeDest, "skills");
240
38
 
241
- installSkills(skillsSource, skillsDest);
242
- installSettings(claudeSource, claudeDest);
39
+ installSkills(skillsSource, skillsDest, logFile);
40
+ installSettings(claudeSource, claudeDest, logFile);
243
41
  } else {
244
42
  // 全新安装
245
- appendLog("INFO", "Install started (fresh)");
43
+ appendLog(logFile, "INFO", "Install started (fresh)");
246
44
  fs.cpSync(claudeSource, claudeDest, { recursive: true });
247
- appendLog("INFO", `.claude/ directory created`);
45
+ appendLog(logFile, "INFO", `.claude/ directory created`);
248
46
  }
249
47
  }
250
48
 
251
49
  // 安装 CLAUDE.md
252
- // 用户已有则只备份不覆盖,防止冲掉自定义配置
253
50
  if (fs.existsSync(mdSource)) {
254
51
  if (!fs.existsSync(mdDest)) {
255
52
  fs.copyFileSync(mdSource, mdDest);
256
- appendLog("INFO", "CLAUDE.md installed");
53
+ appendLog(logFile, "INFO", "CLAUDE.md installed");
257
54
  } else {
258
- // 仅备份用户版本到 .claude/backups/,方便查阅
259
55
  const backupsDir = path.join(claudeDest, "backups");
260
56
  if (!fs.existsSync(backupsDir)) {
261
57
  fs.mkdirSync(backupsDir, { recursive: true });
@@ -263,14 +59,13 @@ if (fs.existsSync(mdSource)) {
263
59
  const bakName = `CLAUDE.md.${backupTimestamp()}`;
264
60
  const bakPath = path.join(backupsDir, bakName);
265
61
  fs.copyFileSync(mdDest, bakPath);
266
- appendLog("BACKUP", `backups/${bakName} (CLAUDE.md user version preserved)`);
62
+ appendLog(logFile, "BACKUP", `backups/${bakName} (CLAUDE.md user version preserved)`);
267
63
  }
268
64
  }
269
65
 
270
66
  // 输出摘要
271
67
  console.log("✅ dk-frontend-skills 安装完成!\n");
272
68
 
273
- // 读取日志输出最后几行
274
69
  if (fs.existsSync(logFile)) {
275
70
  const logs = fs.readFileSync(logFile, "utf-8").trim().split("\n");
276
71
  const recent = logs.slice(-10);
@@ -281,25 +76,4 @@ if (fs.existsSync(logFile)) {
281
76
  console.log("");
282
77
  }
283
78
 
284
- // 自动卸载
285
- // 用 detached 子进程延迟卸载,避免在 npm 生命周期中自卸导致冲突
286
- console.log("🧹 自动卸载安装包...");
287
-
288
- const uninstallCmd = `
289
- const { execSync } = require("child_process");
290
- setTimeout(() => {
291
- execSync("npm uninstall dk-frontend-skills", {
292
- cwd: ${JSON.stringify(projectRoot)},
293
- windowsHide: true,
294
- stdio: "ignore",
295
- });
296
- }, 500);
297
- `;
298
-
299
- const child = spawn(process.execPath, ["-e", uninstallCmd], {
300
- cwd: projectRoot,
301
- detached: true,
302
- stdio: "ignore",
303
- windowsHide: process.platform === "win32",
304
- });
305
- child.unref();
79
+ console.log("💡 提示:运行 npx dk-skills 可交互选择启用/禁用技能\n");
@@ -0,0 +1,239 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ // 时间戳格式:YYYY-MM-DD HH:mm:ss
5
+ function timestamp() {
6
+ const d = new Date();
7
+ const pad = (n) => String(n).padStart(2, "0");
8
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
9
+ }
10
+
11
+ // 紧凑时间戳用于备份目录名:YYYYMMDD.HHmmss
12
+ function backupTimestamp() {
13
+ const d = new Date();
14
+ const pad = (n) => String(n).padStart(2, "0");
15
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}.${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
16
+ }
17
+
18
+ /**
19
+ * 追加日志到 .claude/.install.log
20
+ * 需要传入 logFile 路径
21
+ */
22
+ function appendLog(logFile, level, message) {
23
+ const dir = path.dirname(logFile);
24
+ if (!fs.existsSync(dir)) {
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ }
27
+ const line = `[${timestamp()}] [${level}] ${message}\n`;
28
+ fs.appendFileSync(logFile, line, "utf-8");
29
+ }
30
+
31
+ /**
32
+ * 备份已有目录到 dir/backups/<timestamp>/
33
+ * 排除 backups/ 自身,清理超过 3 份的旧备份
34
+ */
35
+ function backupDir(dir) {
36
+ if (!fs.existsSync(dir)) return null;
37
+
38
+ const backupsDir = path.join(dir, "backups");
39
+ if (!fs.existsSync(backupsDir)) {
40
+ fs.mkdirSync(backupsDir, { recursive: true });
41
+ }
42
+
43
+ const ts = backupTimestamp();
44
+ const bakPath = path.join(backupsDir, ts);
45
+ fs.mkdirSync(bakPath, { recursive: true });
46
+
47
+ for (const item of fs.readdirSync(dir)) {
48
+ if (item === "backups") continue;
49
+ fs.cpSync(path.join(dir, item), path.join(bakPath, item), { recursive: true });
50
+ }
51
+
52
+ // 清理旧备份,只保留最近 3 份
53
+ const backups = fs
54
+ .readdirSync(backupsDir)
55
+ .filter((name) => /^\d{8}\.\d{6}$/.test(name))
56
+ .sort()
57
+ .reverse();
58
+
59
+ if (backups.length > 3) {
60
+ for (const old of backups.slice(3)) {
61
+ const oldPath = path.join(backupsDir, old);
62
+ fs.rmSync(oldPath, { recursive: true, force: true });
63
+ }
64
+ }
65
+
66
+ return bakPath;
67
+ }
68
+
69
+ function isPlainObject(val) {
70
+ return Object.prototype.toString.call(val) === "[object Object]";
71
+ }
72
+
73
+ /**
74
+ * 递归深合并,用户值优先,目标新增字段补充
75
+ */
76
+ function deepMerge(userObj, pkgObj) {
77
+ const result = { ...userObj };
78
+
79
+ for (const key of Object.keys(pkgObj)) {
80
+ if (!(key in result)) {
81
+ result[key] = pkgObj[key];
82
+ } else if (isPlainObject(pkgObj[key]) && isPlainObject(result[key])) {
83
+ result[key] = deepMerge(result[key], pkgObj[key]);
84
+ }
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * 读取技能目录下的 .meta.json,没有则返回 null
92
+ */
93
+ function readMeta(skillDir) {
94
+ const metaPath = path.join(skillDir, ".meta.json");
95
+ if (fs.existsSync(metaPath)) {
96
+ try {
97
+ return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * 安装技能:逐个复制,补缺不覆盖
107
+ */
108
+ function installSkills(skillsSource, skillsDest, logFile) {
109
+ if (!fs.existsSync(skillsSource)) return [];
110
+
111
+ if (!fs.existsSync(skillsDest)) {
112
+ fs.mkdirSync(skillsDest, { recursive: true });
113
+ }
114
+
115
+ const skillDirs = fs.readdirSync(skillsSource);
116
+ const results = [];
117
+
118
+ for (const dir of skillDirs) {
119
+ const srcSkill = path.join(skillsSource, dir);
120
+ const destSkill = path.join(skillsDest, dir);
121
+ const meta = readMeta(srcSkill);
122
+ const version = meta ? meta.version : "?";
123
+
124
+ if (!fs.existsSync(destSkill)) {
125
+ fs.cpSync(srcSkill, destSkill, { recursive: true });
126
+ appendLog(logFile, "SKILL", `${dir} (${version}) → installed`);
127
+ results.push({ name: dir, version, action: "installed" });
128
+ } else {
129
+ const destMeta = readMeta(destSkill);
130
+ const needUpdate = meta && destMeta && meta.version !== destMeta.version;
131
+
132
+ if (needUpdate) {
133
+ const bakName = `${dir}.bak.${backupTimestamp()}`;
134
+ const bakPath = path.join(skillsDest, bakName);
135
+ fs.cpSync(destSkill, bakPath, { recursive: true });
136
+ fs.rmSync(destSkill, { recursive: true, force: true });
137
+ fs.cpSync(srcSkill, destSkill, { recursive: true });
138
+ appendLog(logFile, "SKILL", `${dir} (${destMeta.version} → ${meta.version}) → updated`);
139
+ results.push({ name: dir, version: meta.version, action: "updated" });
140
+ } else {
141
+ appendLog(logFile, "SKILL", `${dir} (${version}) → skipped (exists)`);
142
+ results.push({ name: dir, version, action: "skipped" });
143
+ }
144
+ }
145
+ }
146
+
147
+ return results;
148
+ }
149
+
150
+ /**
151
+ * 安装 settings.json,深度合并
152
+ */
153
+ function installSettings(sourceDir, destDir, logFile) {
154
+ const settingsSource = path.join(sourceDir, "settings.json");
155
+ const settingsDest = path.join(destDir, "settings.json");
156
+ const localSource = path.join(sourceDir, "settings.local.json");
157
+ const localDest = path.join(destDir, "settings.local.json");
158
+
159
+ for (const { src, dest, name } of [
160
+ { src: settingsSource, dest: settingsDest, name: "settings.json" },
161
+ { src: localSource, dest: localDest, name: "settings.local.json" },
162
+ ]) {
163
+ if (!fs.existsSync(src)) continue;
164
+
165
+ if (fs.existsSync(dest)) {
166
+ const userData = JSON.parse(fs.readFileSync(dest, "utf-8"));
167
+ const pkgData = JSON.parse(fs.readFileSync(src, "utf-8"));
168
+ const merged = deepMerge(userData, pkgData);
169
+ fs.writeFileSync(dest, JSON.stringify(merged, null, 2) + "\n", "utf-8");
170
+ appendLog(logFile, "SETTINGS", `${name} merged`);
171
+ } else {
172
+ fs.copyFileSync(src, dest);
173
+ appendLog(logFile, "SETTINGS", `${name} created`);
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 获取包内的技能列表(带元信息)
180
+ */
181
+ function getPackageSkills(packageDir) {
182
+ const skillsSource = path.join(packageDir, ".claude", "skills");
183
+ if (!fs.existsSync(skillsSource)) return [];
184
+
185
+ return fs.readdirSync(skillsSource).map((name) => {
186
+ const meta = readMeta(path.join(skillsSource, name));
187
+ return {
188
+ name,
189
+ version: meta ? meta.version : "?",
190
+ description: meta ? meta.description : "",
191
+ tags: meta ? meta.tags : [],
192
+ };
193
+ });
194
+ }
195
+
196
+ /**
197
+ * 获取用户已安装的技能及其 settings 中的 enabled 状态
198
+ */
199
+ function getUserSkills(claudeDest) {
200
+ const skillsDest = path.join(claudeDest, "skills");
201
+ if (!fs.existsSync(skillsDest)) return [];
202
+
203
+ const settingsPath = path.join(claudeDest, "settings.json");
204
+ let settings = {};
205
+ if (fs.existsSync(settingsPath)) {
206
+ try {
207
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
208
+ } catch {
209
+ settings = {};
210
+ }
211
+ }
212
+
213
+ const skillConfigs = settings.skills || {};
214
+
215
+ return fs.readdirSync(skillsDest).map((name) => {
216
+ const meta = readMeta(path.join(skillsDest, name));
217
+ const config = skillConfigs[name] || {};
218
+ return {
219
+ name,
220
+ version: meta ? meta.version : "?",
221
+ description: meta ? meta.description : config.description || "",
222
+ tags: meta ? meta.tags : [],
223
+ enabled: config.enabled !== false,
224
+ };
225
+ });
226
+ }
227
+
228
+ module.exports = {
229
+ timestamp,
230
+ backupTimestamp,
231
+ appendLog,
232
+ backupDir,
233
+ deepMerge,
234
+ readMeta,
235
+ installSkills,
236
+ installSettings,
237
+ getPackageSkills,
238
+ getUserSkills,
239
+ };