dsh-skill-picker 0.5.21 → 0.5.23

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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/index.js
2
- import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises";
2
+ import { mkdir as mkdir2, readFile as readFile3, readdir as readdir3, rename as rename3, writeFile as writeFile3 } from "node:fs/promises";
3
3
  import os2 from "node:os";
4
- import path3 from "node:path";
4
+ import path4 from "node:path";
5
5
 
6
6
  // src/dir-entry.js
7
7
  import { stat } from "node:fs/promises";
@@ -242,17 +242,160 @@ async function healUiSkillPatches() {
242
242
  return { files: filesReport, errors };
243
243
  }
244
244
 
245
+ // src/skill-ops.js
246
+ import { spawn } from "node:child_process";
247
+ import { mkdir, readFile as readFile2, readdir as readdir2, rename as rename2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
248
+ import path3 from "node:path";
249
+ var ENTRY = "SKILL.md";
250
+ var DISABLED_ENTRY = `${ENTRY}.disabled`;
251
+ var CLIENT_HEADER = "x-dsh-skill-picker";
252
+ function skillBackupDir() {
253
+ return path3.join(dshHome(), "skill-backups");
254
+ }
255
+ function resolveSkillPath(candidate, roots) {
256
+ if (typeof candidate !== "string" || candidate === "") return void 0;
257
+ const list = Array.isArray(roots) ? roots : [];
258
+ const resolved = path3.resolve(candidate);
259
+ for (const root of list) {
260
+ const base = path3.resolve(root);
261
+ const relative = path3.relative(base, resolved);
262
+ if (relative === "" || relative.startsWith("..") || path3.isAbsolute(relative)) continue;
263
+ if (relative.split(path3.sep).filter(Boolean).length !== 1) continue;
264
+ return resolved;
265
+ }
266
+ return void 0;
267
+ }
268
+ async function pathKind(target) {
269
+ try {
270
+ return (await stat2(target)).isDirectory() ? "dir" : "file";
271
+ } catch {
272
+ return void 0;
273
+ }
274
+ }
275
+ async function exists(candidate) {
276
+ try {
277
+ await stat2(candidate);
278
+ return true;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+ async function setSkillDisabled(target, disabled) {
284
+ const kind = await pathKind(target);
285
+ if (kind === void 0) throw new Error(`skill not found: ${target}`);
286
+ const dir = kind === "dir" ? target : path3.dirname(target);
287
+ const base = kind === "dir" ? ENTRY : path3.basename(target);
288
+ const plain = path3.join(dir, base);
289
+ const off = path3.join(dir, `${base}.disabled`);
290
+ const hasPlain = await exists(plain);
291
+ const hasOff = await exists(off);
292
+ if (disabled) {
293
+ if (hasOff && !hasPlain) throw new Error("already disabled");
294
+ if (!hasPlain) throw new Error(`cannot disable: ${base} is not there`);
295
+ if (hasOff) throw new Error(`cannot disable: ${base} and ${base}.disabled both exist`);
296
+ await rename2(plain, off);
297
+ return { entry: `${base}.disabled`, disabled: true };
298
+ }
299
+ if (hasPlain && !hasOff) throw new Error("already enabled");
300
+ if (!hasOff) throw new Error(`cannot enable: ${base}.disabled is not there`);
301
+ if (hasPlain) throw new Error(`cannot enable: ${base} and ${base}.disabled both exist`);
302
+ await rename2(off, plain);
303
+ return { entry: base, disabled: false };
304
+ }
305
+ async function uninstallSkill(target) {
306
+ const kind = await pathKind(target);
307
+ if (kind === void 0) throw new Error(`skill not found: ${target}`);
308
+ const name = path3.basename(target);
309
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
310
+ const backupPath = path3.join(skillBackupDir(), `${stamp}-${name}`);
311
+ await mkdir(backupPath, { recursive: true });
312
+ await rename2(target, path3.join(backupPath, name));
313
+ await writeFile2(path3.join(backupPath, "manifest.json"), `${JSON.stringify({
314
+ name,
315
+ kind,
316
+ originalPath: target,
317
+ movedAt: (/* @__PURE__ */ new Date()).toISOString()
318
+ }, null, 2)}
319
+ `, "utf8");
320
+ return { backupPath, restoreTarget: target };
321
+ }
322
+ async function listSkillBackups(roots) {
323
+ let entries;
324
+ try {
325
+ entries = await readdir2(skillBackupDir(), { withFileTypes: true });
326
+ } catch {
327
+ return [];
328
+ }
329
+ const backups = [];
330
+ for (const entry of entries) {
331
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
332
+ const dir = path3.join(skillBackupDir(), entry.name);
333
+ try {
334
+ const manifest = JSON.parse(await readFile2(path3.join(dir, "manifest.json"), "utf8"));
335
+ backups.push({
336
+ id: entry.name,
337
+ name: typeof manifest.name === "string" ? manifest.name : entry.name,
338
+ originalPath: typeof manifest.originalPath === "string" ? manifest.originalPath : void 0,
339
+ movedAt: typeof manifest.movedAt === "string" ? manifest.movedAt : void 0,
340
+ restorable: resolveSkillPath(manifest.originalPath, roots) !== void 0
341
+ });
342
+ } catch {
343
+ }
344
+ }
345
+ return backups.sort((a, b) => String(b.movedAt ?? "").localeCompare(String(a.movedAt ?? "")));
346
+ }
347
+ async function restoreSkillBackup(id, roots) {
348
+ if (typeof id !== "string" || id === "" || id.includes("/") || id.includes("\\") || id.includes("..")) {
349
+ throw new Error("invalid backup id");
350
+ }
351
+ const dir = path3.join(skillBackupDir(), id);
352
+ let manifest;
353
+ try {
354
+ manifest = JSON.parse(await readFile2(path3.join(dir, "manifest.json"), "utf8"));
355
+ } catch {
356
+ throw new Error("that backup is gone");
357
+ }
358
+ const original = resolveSkillPath(manifest.originalPath, roots);
359
+ if (original === void 0) throw new Error("that backup no longer points inside a skill root");
360
+ if (await exists(original)) {
361
+ throw new Error(`${path3.basename(original)} is occupied again \u2014 move it away first`);
362
+ }
363
+ await rename2(path3.join(dir, path3.basename(original)), original);
364
+ const done = path3.join(skillBackupDir(), ".restored");
365
+ await mkdir(done, { recursive: true });
366
+ await rename2(dir, path3.join(done, id)).catch(() => {
367
+ });
368
+ return { restored: original };
369
+ }
370
+ function revealSkill(target, revealPath) {
371
+ const show = typeof revealPath === "string" && revealPath !== "" ? revealPath : target;
372
+ try {
373
+ if (process.platform === "win32") {
374
+ spawn("explorer.exe", [`/select,${show}`], { detached: true, stdio: "ignore" }).unref();
375
+ return true;
376
+ }
377
+ if (process.platform === "darwin") {
378
+ spawn("open", ["-R", show], { detached: true, stdio: "ignore" }).unref();
379
+ return true;
380
+ }
381
+ spawn("xdg-open", [path3.dirname(show)], { detached: true, stdio: "ignore" }).unref();
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+
245
388
  // src/index.js
246
389
  var inject = ["webServer", "systemPrompt"];
247
390
  var SECTION_ORDER = 215;
248
391
  var SKILL_PICKER_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-skill-picker \u63D2\u4EF6\uFF08Web GUI \u7684\u6280\u80FD\u9009\u62E9\u5668\uFF09\uFF1A\u8F93\u5165\u6846\u65C1\u6709\u6280\u80FD\u6309\u94AE\uFF0C\u7528\u6237\u70B9\u9009\u6280\u80FD\u540E\u4F1A\u628A `/\u6280\u80FD\u540D`\uFF08\u5982 /duo-xuan-pi-gai\uFF09\u63D2\u5165\u53D1\u9001\u6846\u5E76\u968F\u6D88\u606F\u53D1\u51FA\u3002DSH \u5B98\u65B9\u673A\u5236\u4F1A\u628A\u7528\u6237\u6D88\u606F\u91CC\u7684 `/\u6280\u80FD\u540D` \u624B\u52BF\u5F53\u4F5C\u6280\u80FD\u76F4\u63A5\u8C03\u7528\u5E76\u81EA\u52A8\u52A0\u8F7D\u6280\u80FD\u5185\u5BB9\u2014\u2014\u4F60\u7167\u5E38\u6309\u52A0\u8F7D\u540E\u7684\u6280\u80FD\u6307\u4EE4\u6267\u884C\u5373\u53EF\uFF0C\u65E0\u9700\u989D\u5916\u64CD\u4F5C\u3002\u7528\u6237\u8BF4\u300C\u6280\u80FD\u9009\u62E9\u5668 / \u9009\u4E2A\u6280\u80FD / \u6280\u80FD\u5217\u8868\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\u3002";
249
392
  function userSkillsDir() {
250
- const home = process.env.DSH_HOME ?? path3.join(os2.homedir(), ".dsh");
251
- return path3.join(home, "skills");
393
+ const home = process.env.DSH_HOME ?? path4.join(os2.homedir(), ".dsh");
394
+ return path4.join(home, "skills");
252
395
  }
253
396
  function userAgentsSkillsDir() {
254
- const agentsHome = process.env.DSH_AGENTS_HOME ?? path3.join(os2.homedir(), ".agents");
255
- return path3.join(agentsHome, "skills");
397
+ const agentsHome = process.env.DSH_AGENTS_HOME ?? path4.join(os2.homedir(), ".agents");
398
+ return path4.join(agentsHome, "skills");
256
399
  }
257
400
  function frontmatterBlock(content) {
258
401
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -312,25 +455,35 @@ function isUserInvocableSkill(meta, content) {
312
455
  async function scanSkillsDirInto(map, dir) {
313
456
  let entries;
314
457
  try {
315
- entries = await readdir2(dir, { withFileTypes: true });
458
+ entries = await readdir3(dir, { withFileTypes: true });
316
459
  } catch {
317
460
  return;
318
461
  }
319
462
  for (const entry of entries) {
320
463
  if (!await isDirectoryEntry(dir, entry)) continue;
321
- const skillDir = path3.join(dir, entry.name);
464
+ const skillDir = path4.join(dir, entry.name);
322
465
  let content;
466
+ let entryFile = ENTRY;
467
+ let disabled = false;
323
468
  try {
324
- content = await readFile2(path3.join(skillDir, "SKILL.md"), "utf8");
469
+ content = await readFile3(path4.join(skillDir, ENTRY), "utf8");
325
470
  } catch {
326
- continue;
471
+ try {
472
+ content = await readFile3(path4.join(skillDir, DISABLED_ENTRY), "utf8");
473
+ entryFile = DISABLED_ENTRY;
474
+ disabled = true;
475
+ } catch {
476
+ continue;
477
+ }
327
478
  }
328
479
  const meta = parseFrontmatter(content);
329
480
  if (!isUserInvocableSkill(meta, content)) continue;
330
481
  map.set(meta.name ?? entry.name, {
331
482
  name: meta.name ?? entry.name,
332
483
  description: meta.description ?? "",
333
- path: skillDir
484
+ path: skillDir,
485
+ entry: entryFile,
486
+ disabled
334
487
  });
335
488
  }
336
489
  }
@@ -339,8 +492,8 @@ async function scanSkills(cwd) {
339
492
  await scanSkillsDirInto(map, userAgentsSkillsDir());
340
493
  await scanSkillsDirInto(map, userSkillsDir());
341
494
  if (typeof cwd === "string" && cwd !== "") {
342
- await scanSkillsDirInto(map, path3.join(cwd, ".agents", "skills"));
343
- await scanSkillsDirInto(map, path3.join(cwd, ".dsh", "skills"));
495
+ await scanSkillsDirInto(map, path4.join(cwd, ".agents", "skills"));
496
+ await scanSkillsDirInto(map, path4.join(cwd, ".dsh", "skills"));
344
497
  }
345
498
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
346
499
  }
@@ -361,11 +514,166 @@ function reportUiSkillPatches(report, io = console) {
361
514
  io.warn("[dsh-skill-picker] ui-skill patch errors:", JSON.stringify(errors));
362
515
  }
363
516
  }
517
+ function sharedStateFile() {
518
+ return path4.join(dshHome(), "dsh-skill-picker-state.json");
519
+ }
520
+ function normalizeSharedState(value) {
521
+ const source = value !== null && typeof value === "object" ? value : {};
522
+ const pinned = Array.isArray(source.pinned) ? [...new Set(source.pinned.filter((name) => typeof name === "string" && name !== ""))] : [];
523
+ const usage = {};
524
+ const rawUsage = source.usage !== null && typeof source.usage === "object" ? source.usage : {};
525
+ for (const [name, entry] of Object.entries(rawUsage)) {
526
+ if (typeof name !== "string" || name === "") continue;
527
+ const record = entry !== null && typeof entry === "object" ? entry : {};
528
+ const count = Number.isFinite(record.count) ? Math.max(0, Math.trunc(record.count)) : 0;
529
+ const lastUsed = Number.isFinite(record.lastUsed) ? Math.max(0, Math.trunc(record.lastUsed)) : 0;
530
+ if (count === 0 && lastUsed === 0) continue;
531
+ usage[name] = { count, lastUsed };
532
+ }
533
+ return { pinned, usage };
534
+ }
535
+ async function readSharedState() {
536
+ try {
537
+ return normalizeSharedState(JSON.parse(await readFile3(sharedStateFile(), "utf8")));
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+ async function writeSharedState(state) {
543
+ const normal = normalizeSharedState(state);
544
+ const file = sharedStateFile();
545
+ await mkdir2(path4.dirname(file), { recursive: true });
546
+ const tmp = `${file}.tmp`;
547
+ await writeFile3(tmp, `${JSON.stringify(normal, null, 2)}
548
+ `, "utf8");
549
+ await rename3(tmp, file);
550
+ return normal;
551
+ }
552
+ function mergeSharedState(a, b) {
553
+ const left = normalizeSharedState(a);
554
+ const right = normalizeSharedState(b);
555
+ const pinned = [...left.pinned];
556
+ for (const name of right.pinned) if (!pinned.includes(name)) pinned.push(name);
557
+ const usage = { ...left.usage };
558
+ for (const [name, entry] of Object.entries(right.usage)) {
559
+ const previous = usage[name];
560
+ usage[name] = previous === void 0 ? entry : { count: Math.max(previous.count, entry.count), lastUsed: Math.max(previous.lastUsed, entry.lastUsed) };
561
+ }
562
+ return normalizeSharedState({ pinned, usage });
563
+ }
564
+ async function readJsonBody(req) {
565
+ const chunks = [];
566
+ let size = 0;
567
+ for await (const chunk of req) {
568
+ size += chunk.length;
569
+ if (size > 1e6) throw new Error("shared state body too large");
570
+ chunks.push(chunk);
571
+ }
572
+ if (chunks.length === 0) return {};
573
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
574
+ }
575
+ async function handleSharedState(req, res, url) {
576
+ const json = (code, payload) => {
577
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
578
+ res.end(JSON.stringify(payload));
579
+ };
580
+ try {
581
+ if (req.method === "PUT" || req.method === "POST") {
582
+ const incoming = normalizeSharedState(await readJsonBody(req));
583
+ const migrate = url.searchParams.get("migrate") === "1";
584
+ const stored = migrate ? await readSharedState() : null;
585
+ const next = stored === null ? await writeSharedState(incoming) : await writeSharedState(mergeSharedState(stored, incoming));
586
+ json(200, { ok: true, state: next });
587
+ return;
588
+ }
589
+ json(200, { ok: true, state: await readSharedState() });
590
+ } catch (error) {
591
+ json(500, { ok: false, error: String(error?.message ?? error) });
592
+ }
593
+ }
594
+ function allSkillRoots(cwd) {
595
+ const roots = [userAgentsSkillsDir(), userSkillsDir()];
596
+ if (typeof cwd === "string" && cwd !== "") {
597
+ roots.push(path4.join(cwd, ".agents", "skills"), path4.join(cwd, ".dsh", "skills"));
598
+ }
599
+ return roots;
600
+ }
601
+ async function handleSkillOp(req, res, url) {
602
+ const json = (code, payload) => {
603
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
604
+ res.end(JSON.stringify(payload));
605
+ };
606
+ if (req.method !== "POST") {
607
+ json(405, { ok: false, error: "POST only" });
608
+ return;
609
+ }
610
+ if (req.headers[CLIENT_HEADER] !== "1") {
611
+ json(403, { ok: false, error: "missing client header" });
612
+ return;
613
+ }
614
+ try {
615
+ const body = await readJsonBody(req);
616
+ const cwd = url.searchParams.get("cwd") ?? void 0;
617
+ const roots = allSkillRoots(cwd);
618
+ const action = String(body?.action ?? "");
619
+ if (action === "backups") {
620
+ json(200, { ok: true, backups: await listSkillBackups(roots) });
621
+ return;
622
+ }
623
+ if (action === "restore") {
624
+ json(200, { ok: true, ...await restoreSkillBackup(body?.id, roots) });
625
+ return;
626
+ }
627
+ const name = String(body?.name ?? "");
628
+ if (name === "") {
629
+ json(400, { ok: false, error: "name is required" });
630
+ return;
631
+ }
632
+ const known = (await scanSkills(cwd)).find((skill) => skill.name === name);
633
+ if (known === void 0) {
634
+ json(404, { ok: false, error: `no skill named "${name}"` });
635
+ return;
636
+ }
637
+ const target = resolveSkillPath(known.path, roots);
638
+ if (target === void 0) {
639
+ json(403, { ok: false, error: "that skill lives outside the skill roots" });
640
+ return;
641
+ }
642
+ if (action === "disable") {
643
+ json(200, { ok: true, ...await setSkillDisabled(target, true) });
644
+ return;
645
+ }
646
+ if (action === "enable") {
647
+ json(200, { ok: true, ...await setSkillDisabled(target, false) });
648
+ return;
649
+ }
650
+ if (action === "reveal") {
651
+ json(200, { ok: true, revealed: revealSkill(target) });
652
+ return;
653
+ }
654
+ if (action === "uninstall") {
655
+ json(200, { ok: true, ...await uninstallSkill(target) });
656
+ return;
657
+ }
658
+ json(400, { ok: false, error: `unknown action: ${action}` });
659
+ } catch (error) {
660
+ json(500, { ok: false, error: String(error?.message ?? error) });
661
+ }
662
+ }
364
663
  function apply(ctx) {
365
664
  ctx.effect(() => {
366
665
  const handler = async (req, res) => {
367
666
  try {
368
- const cwd = req.url !== void 0 ? new URL(req.url, "http://dsh").searchParams.get("cwd") ?? void 0 : void 0;
667
+ const url = new URL(req.url ?? "/", "http://dsh");
668
+ if (url.pathname === "/dsh-skill-picker/state") {
669
+ await handleSharedState(req, res, url);
670
+ return;
671
+ }
672
+ if (url.pathname === "/dsh-skill-picker/skill") {
673
+ await handleSkillOp(req, res, url);
674
+ return;
675
+ }
676
+ const cwd = url.searchParams.get("cwd") ?? void 0;
369
677
  const skills = await scanSkills(cwd);
370
678
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
371
679
  res.end(JSON.stringify({ ok: true, complete: true, skills }));