mdk-skills 2.3.1 → 2.3.3

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.
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>mdk-skills 管理面板</title>
7
- <script type="module" crossorigin src="/assets/index-CuiL4aIa.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Cj7FAJgB.css">
7
+ <script type="module" crossorigin src="/assets/index-MFtHKGfF.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DgZ4NBMO.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="app"></div>
@@ -2,7 +2,7 @@ const fs = require("fs");
2
2
  const path = require("path");
3
3
  const http = require("http");
4
4
  const crypto = require("crypto");
5
- const { execSync } = require("child_process");
5
+ const { execSync, exec } = require("child_process");
6
6
  const os = require("os");
7
7
  const core = require("../core");
8
8
 
@@ -167,6 +167,60 @@ function writeSkillMeta(dest, wasUpdated) {
167
167
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8");
168
168
  }
169
169
 
170
+ // ---------- 异步 npx 管理:支持取消正在执行的进程 ----------
171
+
172
+ const runningTasks = new Map();
173
+
174
+ function runAsync(cmd, options = {}) {
175
+ const { cwd, timeout = 120000, taskId = "default" } = options;
176
+ return new Promise((resolve, reject) => {
177
+ // 同名任务已存在则先取消
178
+ if (runningTasks.has(taskId)) {
179
+ const old = runningTasks.get(taskId);
180
+ old.kill();
181
+ runningTasks.delete(taskId);
182
+ }
183
+
184
+ const proc = exec(cmd, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
185
+ clearTimeout(timer);
186
+ runningTasks.delete(taskId);
187
+ if (err) {
188
+ const e = new Error(err.killed ? "已取消" : (stderr || err.message));
189
+ e.stdout = stdout || "";
190
+ e.stderr = stderr || "";
191
+ e.killed = err.killed;
192
+ reject(e);
193
+ } else {
194
+ resolve({ stdout: stdout || "", stderr: stderr || "" });
195
+ }
196
+ });
197
+
198
+ const timer = setTimeout(() => {
199
+ proc.kill();
200
+ runningTasks.delete(taskId);
201
+ const e = new Error("操作超时");
202
+ e.stdout = ""; e.stderr = ""; e.killed = true;
203
+ reject(e);
204
+ }, timeout);
205
+
206
+ proc.on("error", () => { clearTimeout(timer); runningTasks.delete(taskId); });
207
+ runningTasks.set(taskId, proc);
208
+ });
209
+ }
210
+
211
+ function cancelTask(taskId) {
212
+ if (taskId) {
213
+ const proc = runningTasks.get(taskId);
214
+ if (proc) { proc.kill(); runningTasks.delete(taskId); }
215
+ } else {
216
+ // 不传 taskId 取消全部
217
+ for (const [id, proc] of runningTasks) {
218
+ proc.kill();
219
+ runningTasks.delete(id);
220
+ }
221
+ }
222
+ }
223
+
170
224
  // 读取 profiles.json
171
225
  function loadProfiles() {
172
226
  const profilesPath = path.join(skillsSource, ".claude", "profiles.json");
@@ -392,15 +446,12 @@ async function handleApi(req, res) {
392
446
  const tmpDir = path.join(os.tmpdir(), "mdk-pull-" + Date.now());
393
447
  fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
394
448
  try {
395
- execSync("npx --yes skills add \"" + url + "\" --copy -y -a claude-code", {
396
- cwd: tmpDir,
397
- stdio: "pipe",
398
- timeout: 120000,
399
- });
400
- } catch {
449
+ await runAsync("npx --yes skills add \"" + url + "\" --copy -y -a claude-code", { cwd: tmpDir, taskId: "npx-pull" });
450
+ } catch (e) {
401
451
  cleanNpxTemp();
402
452
  fs.rmSync(tmpDir, { recursive: true, force: true });
403
- return sendJSON(res, { error: "拉取失败,请检查地址或网络连接" }, 400);
453
+ if (e.killed) return sendJSON(res, { cancelled: true }, 499);
454
+ return sendJSON(res, { error: "拉取失败:" + (e.stderr || e.message) }, 400);
404
455
  }
405
456
  cleanNpxTemp();
406
457
  const imported = [];
@@ -457,13 +508,12 @@ async function handleApi(req, res) {
457
508
  const tmpDir = path.join(os.tmpdir(), "mdk-update-" + Date.now());
458
509
  fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
459
510
  try {
460
- execSync("npx --yes skills add \"" + source.url + "\" --copy -y -a claude-code", {
461
- cwd: tmpDir, stdio: "pipe", timeout: 120000,
462
- });
463
- } catch {
511
+ await runAsync("npx --yes skills add \"" + source.url + "\" --copy -y -a claude-code", { cwd: tmpDir, taskId: "npx-update-" + name });
512
+ } catch (e) {
464
513
  cleanNpxTemp();
465
514
  fs.rmSync(tmpDir, { recursive: true, force: true });
466
- return sendJSON(res, { error: "重新拉取失败,请检查网络连接" }, 400);
515
+ if (e.killed) return sendJSON(res, { cancelled: true }, 499);
516
+ return sendJSON(res, { error: "重新拉取失败:" + (e.stderr || e.message) }, 400);
467
517
  }
468
518
 
469
519
  cleanNpxTemp();
@@ -519,13 +569,12 @@ async function handleApi(req, res) {
519
569
  const tmpDir = path.join(os.tmpdir(), "mdk-batch-" + Date.now());
520
570
  fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
521
571
  try {
522
- execSync("npx --yes skills add \"" + url + "\" --copy -y -a claude-code", {
523
- cwd: tmpDir, stdio: "pipe", timeout: 120000,
524
- });
525
- } catch {
572
+ await runAsync("npx --yes skills add \"" + url + "\" --copy -y -a claude-code", { cwd: tmpDir, taskId: "npx-batch" });
573
+ } catch (e) {
526
574
  cleanNpxTemp();
527
575
  fs.rmSync(tmpDir, { recursive: true, force: true });
528
- return sendJSON(res, { error: "拉取失败,请检查网络连接" }, 400);
576
+ if (e.killed) return sendJSON(res, { cancelled: true }, 499);
577
+ return sendJSON(res, { error: "拉取失败:" + (e.stderr || e.message) }, 400);
529
578
  }
530
579
 
531
580
  cleanNpxTemp();
@@ -1144,6 +1193,13 @@ ${skillsList}
1144
1193
  return sendJSON(res, results);
1145
1194
  }
1146
1195
 
1196
+ // POST /api/tasks/cancel — 取消正在执行的 npx 操作
1197
+ if (method === "POST" && pathname === "/api/tasks/cancel") {
1198
+ const body = await parseBody(req);
1199
+ cancelTask(body.taskId);
1200
+ return sendJSON(res, { ok: true });
1201
+ }
1202
+
1147
1203
  // 404
1148
1204
  sendJSON(res, { error: "Not Found: " + pathname }, 404);
1149
1205
  } catch (err) {
@@ -151,3 +151,7 @@ export function createReadme(data) {
151
151
  body: JSON.stringify(data),
152
152
  });
153
153
  }
154
+
155
+ export function cancelTask() {
156
+ return fetch("/api/tasks/cancel", { method: "POST" }).catch(() => {});
157
+ }
@@ -99,6 +99,7 @@
99
99
  style="width: 720px"
100
100
  content-style="padding: 0;"
101
101
  :mask-closable="true"
102
+ @update:show="(v) => { if (!v) { updatingSkill = false; detailLoading = false; cancelTask(); } }"
102
103
  >
103
104
  <div class="detail-body">
104
105
  <!-- 编辑面板 -->
@@ -174,6 +175,7 @@
174
175
  preset="card"
175
176
  style="width: 600px"
176
177
  :mask-closable="true"
178
+ @update:show="(v) => { if (!v) { pulling = false; cancelTask(); } }"
177
179
  >
178
180
  <div class="pull-modal-body">
179
181
  <div class="pull-input-row">
@@ -241,6 +243,57 @@
241
243
  </div>
242
244
  </div>
243
245
  </n-modal>
246
+
247
+ <!-- 全部更新弹窗 -->
248
+ <n-modal
249
+ v-model:show="showBatchModal"
250
+ :title="batchPhase === 'confirm' ? '批量更新确认' : batchPhase === 'executing' ? '正在更新...' : batchPhase === 'done' ? '更新完成' : '更新失败'"
251
+ preset="card"
252
+ style="width: 480px"
253
+ :mask-closable="batchPhase !== 'executing'"
254
+ @update:show="(v) => { if (!v && batchPhase === 'executing') cancelTask(); }"
255
+ >
256
+ <div class="batch-modal-body">
257
+ <template v-if="batchPhase === 'confirm'">
258
+ <p class="batch-desc">确定更新 "{{ batchGroup?.url }}" 下的 {{ batchGroup?.skills?.length }} 个技能吗?</p>
259
+ <div class="batch-actions">
260
+ <n-button size="small" @click="showBatchModal = false">取消</n-button>
261
+ <n-button size="small" type="primary" @click="doBatchUpdate">开始更新</n-button>
262
+ </div>
263
+ </template>
264
+ <template v-if="batchPhase === 'executing'">
265
+ <div class="batch-executing">
266
+ <n-spin size="small" />
267
+ <span>正在更新,请稍候...</span>
268
+ </div>
269
+ </template>
270
+ <template v-if="batchPhase === 'done'">
271
+ <div class="batch-result" v-if="batchResult">
272
+ <div v-if="batchResult.updated?.length > 0" class="batch-result-line">
273
+ <span class="batch-result-label">已更新:</span>
274
+ <n-tag v-for="name in batchResult.updated" :key="name" size="small" type="success">{{ name }}</n-tag>
275
+ </div>
276
+ <div v-if="batchResult.unchanged?.length > 0" class="batch-result-line">
277
+ <span class="batch-result-label">已是最新:</span>
278
+ <n-tag v-for="name in batchResult.unchanged" :key="name" size="small">{{ name }}</n-tag>
279
+ </div>
280
+ <div v-if="batchResult.notFound?.length > 0" class="batch-result-line">
281
+ <span class="batch-result-label">未找到:</span>
282
+ <n-tag v-for="name in batchResult.notFound" :key="name" size="small" type="warning">{{ name }}</n-tag>
283
+ </div>
284
+ </div>
285
+ <div class="batch-actions">
286
+ <n-button size="small" type="primary" @click="showBatchModal = false">完成</n-button>
287
+ </div>
288
+ </template>
289
+ <template v-if="batchPhase === 'error'">
290
+ <n-alert type="error" :bordered="false">{{ batchError }}</n-alert>
291
+ <div class="batch-actions" style="margin-top: 12px;">
292
+ <n-button size="small" type="primary" @click="showBatchModal = false">关闭</n-button>
293
+ </div>
294
+ </template>
295
+ </div>
296
+ </n-modal>
244
297
  </div>
245
298
  </template>
246
299
 
@@ -251,7 +304,7 @@ import { RefreshOutline } from "@vicons/ionicons5";
251
304
  import { marked } from "marked";
252
305
  import hljs from "highlight.js";
253
306
  import SkillCard from "../components/SkillCard.vue";
254
- import { getSkills, getReadme, getSkillReadme, updateSkillMeta, deleteSkill, pullSkills, installSkills, getSkillSource, updateSkill, batchUpdateSkills, openSkillDir } from "../api/skills";
307
+ import { getSkills, getReadme, getSkillReadme, updateSkillMeta, deleteSkill, pullSkills, installSkills, getSkillSource, updateSkill, batchUpdateSkills, openSkillDir, cancelTask } from "../api/skills";
255
308
  import { sortSkills, getUsageMap } from "../utils/usage";
256
309
 
257
310
  // marked 配置:代码高亮 + 外链安全
@@ -316,6 +369,13 @@ const updatedSiblings = ref(null);
316
369
  const siblingCheck = ref({});
317
370
  const updatingSiblings = ref(false);
318
371
 
372
+ // 全部更新弹窗
373
+ const showBatchModal = ref(false);
374
+ const batchPhase = ref("confirm");
375
+ const batchGroup = ref(null);
376
+ const batchResult = ref(null);
377
+ const batchError = ref("");
378
+
319
379
  async function handleSiblingUpdate() {
320
380
  const selected = Object.keys(siblingCheck.value).filter((k) => siblingCheck.value[k]);
321
381
  if (selected.length === 0) {
@@ -374,6 +434,7 @@ async function handlePull() {
374
434
  pullError.value = "";
375
435
  try {
376
436
  const res = await pullSkills(pullUrl.value.trim());
437
+ if (!showPullModal.value) return;
377
438
  if (res.ok) {
378
439
  pullResult.value = { imported: res.imported, skipped: res.skipped };
379
440
  // 默认全选
@@ -385,6 +446,7 @@ async function handlePull() {
385
446
  pullError.value = res.error || "拉取失败";
386
447
  }
387
448
  } catch (e) {
449
+ if (!showPullModal.value) return;
388
450
  pullError.value = "拉取失败,请检查地址或网络";
389
451
  } finally {
390
452
  pulling.value = false;
@@ -541,6 +603,7 @@ async function handleUpdate() {
541
603
  updatingSkill.value = true;
542
604
  try {
543
605
  const res = await updateSkill(detailSkill.value.name);
606
+ if (!detailVisible.value) return;
544
607
  if (res.ok) {
545
608
  if (res.updated === false) {
546
609
  message.info("已是最新版本,无需更新");
@@ -565,6 +628,7 @@ async function handleUpdate() {
565
628
  message.error(res.error || "更新失败");
566
629
  }
567
630
  } catch {
631
+ if (!detailVisible.value) return;
568
632
  message.error("更新失败");
569
633
  } finally {
570
634
  updatingSkill.value = false;
@@ -572,25 +636,29 @@ async function handleUpdate() {
572
636
  }
573
637
 
574
638
  async function batchUpdateGroup(group) {
575
- const ok = window.confirm(`确定更新 "${group.url}" 下的 ${group.skills.length} 个技能吗?`);
576
- if (!ok) return;
639
+ batchGroup.value = group;
640
+ batchResult.value = null;
641
+ batchError.value = "";
642
+ batchPhase.value = "confirm";
643
+ showBatchModal.value = true;
644
+ }
645
+
646
+ async function doBatchUpdate() {
647
+ batchPhase.value = "executing";
577
648
  try {
578
- const names = group.skills.map((s) => s.name);
579
- const res = await batchUpdateSkills(names, group.url);
649
+ const names = batchGroup.value.skills.map((s) => s.name);
650
+ const res = await batchUpdateSkills(names, batchGroup.value.url);
580
651
  if (res.ok) {
581
- message.success(`${res.updated.length} 个技能已更新`);
582
- if (res.unchanged && res.unchanged.length > 0) {
583
- message.info(`${res.unchanged.length} 个技能已是最新,跳过`);
584
- }
585
- if (res.notFound && res.notFound.length > 0) {
586
- message.warning(`${res.notFound.length} 个技能未找到,已跳过`);
587
- }
652
+ batchResult.value = { updated: res.updated, unchanged: res.unchanged, notFound: res.notFound };
653
+ batchPhase.value = "done";
588
654
  await loadSkills();
589
655
  } else {
590
- message.error(res.error || "批量更新失败");
656
+ batchError.value = res.error || "批量更新失败";
657
+ batchPhase.value = "error";
591
658
  }
592
659
  } catch {
593
- message.error("批量更新失败");
660
+ batchError.value = "批量更新失败";
661
+ batchPhase.value = "error";
594
662
  }
595
663
  }
596
664
 
@@ -931,4 +999,49 @@ onUnmounted(() => document.removeEventListener("visibilitychange", onFocus));
931
999
  justify-content: flex-end;
932
1000
  gap: 8px;
933
1001
  }
1002
+
1003
+ /* 全部更新弹窗 */
1004
+ .batch-modal-body {
1005
+ min-height: 60px;
1006
+ }
1007
+
1008
+ .batch-desc {
1009
+ font-size: 13px;
1010
+ line-height: 1.6;
1011
+ margin-bottom: 16px;
1012
+ }
1013
+
1014
+ .batch-actions {
1015
+ display: flex;
1016
+ justify-content: flex-end;
1017
+ gap: 8px;
1018
+ }
1019
+
1020
+ .batch-executing {
1021
+ display: flex;
1022
+ align-items: center;
1023
+ gap: 12px;
1024
+ padding: 24px 0;
1025
+ justify-content: center;
1026
+ color: #666;
1027
+ font-size: 13px;
1028
+ }
1029
+
1030
+ .batch-result {
1031
+ margin-bottom: 16px;
1032
+ }
1033
+
1034
+ .batch-result-line {
1035
+ display: flex;
1036
+ align-items: center;
1037
+ flex-wrap: wrap;
1038
+ gap: 6px;
1039
+ margin-bottom: 10px;
1040
+ font-size: 13px;
1041
+ }
1042
+
1043
+ .batch-result-label {
1044
+ color: #888;
1045
+ flex-shrink: 0;
1046
+ }
934
1047
  </style>