dsh-plugin-tool-management 0.2.0 → 0.5.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.
@@ -71,6 +71,22 @@ export function userRoots() {
71
71
  native: true,
72
72
  rank: 400,
73
73
  },
74
+ {
75
+ // v0.4:插件**新建/导入**的技能落到 hub 内(用户要求:插件产生的文件收在
76
+ // $DSH_HOME/tool-management/)。rank 高于 DSH 技能,同名时 hub 版本遮蔽官方目录里的;
77
+ // 官方 ~/.dsh/skills/ 仍作为可切换来源列出(不搬走、不删)。
78
+ //
79
+ // 界面名「导入技能」:它是插件导入/新建技能的落点,不是"管理器自己的一类技能"。
80
+ // **用户级来源不可删**(dsh / hub 都是):技能只能停用,删除留给项目级来源。
81
+ key: "hub",
82
+ path: join(resolveDshHome(), "tool-management", "skills"),
83
+ label: "导入技能",
84
+ localeKey: "hub",
85
+ mutable: true,
86
+ toggleable: true,
87
+ native: false,
88
+ rank: 350,
89
+ },
74
90
  {
75
91
  key: "agents",
76
92
  path: join(resolveAgentsHome(), "skills"),
@@ -317,6 +333,9 @@ export async function projectRoots(projectCwds = [], diagnostics) {
317
333
  const id = projectIdentity(project.root);
318
334
  const common = {
319
335
  mutable: false,
336
+ // 项目级 DSH 根是**唯一**允许删除技能文件的来源;用户级来源(dsh / hub)一律不可删。
337
+ // 用「作用域」推导而不是再加一个平行开关:这是项目根与用户根的本质差别。
338
+ deletable: false,
320
339
  toggleable: false,
321
340
  native: true,
322
341
  scope: "project",
@@ -334,6 +353,7 @@ export async function projectRoots(projectCwds = [], diagnostics) {
334
353
  label: "Project DSH",
335
354
  rank: 100,
336
355
  mutable: true,
356
+ deletable: true,
337
357
  toggleable: true,
338
358
  },
339
359
  {
@@ -464,6 +484,14 @@ export async function browseDirectories(inputPath) {
464
484
  function dshRootPath() {
465
485
  return userRoots().find((root) => root.key === "dsh").path;
466
486
  }
487
+ /**
488
+ * v0.4 新建/导入技能的落点:hub 内 `tool-management/skills/`(用户要求插件产物集中)。
489
+ * hub 根缺失(旧版本状态文件/异常)时退回官方 DSH 技能目录,保证创建功能永不因布局变化而失效。
490
+ */
491
+ function skillCreateRootPath() {
492
+ const hub = userRoots().find((root) => root.key === "hub");
493
+ return hub && hub.path ? hub.path : dshRootPath();
494
+ }
467
495
  /** 只读来源的拒绝结果;action 为可翻译语义值(toggle/delete)。 */
468
496
  function readonlyError(action) {
469
497
  return {
@@ -475,6 +503,19 @@ function readonlyError(action) {
475
503
  : "该技能来源不允许启用或停用",
476
504
  };
477
505
  }
506
+ /**
507
+ * 来源可写但**不允许删除**时的拒绝结果(用户级 DSH 技能 / 导入技能)。
508
+ * 与「只读来源」区分开:这两种来源可以创建、可以停用,只是不提供删除 —— 提示要能说清
509
+ * 「你还能做什么」,否则用户会以为是权限坏了。
510
+ */
511
+ function notDeletableError(definition) {
512
+ return {
513
+ ok: false,
514
+ code: "error.skill.notDeletable",
515
+ params: { root: definition && definition.key ? definition.key : "" },
516
+ error: "该来源的技能不能删除(技能只能停用):只在项目级来源(<项目>/.dsh/skills)提供删除",
517
+ };
518
+ }
478
519
  function rootDefinition(root) {
479
520
  if (root && typeof root === "object" && typeof root.key === "string")
480
521
  return root;
@@ -486,7 +527,7 @@ function rootDefinition(root) {
486
527
  function rootByKey(key) {
487
528
  return userRoots().find((item) => item.key === key) || null;
488
529
  }
489
- /** 只允许用户 DSH 根,或由活动 Session 推导出的项目 DSH 根参与文件写入。 */
530
+ /** 只允许用户 DSH 根 / hub 根,或由活动 Session 推导出的项目 DSH 根参与文件写入。 */
490
531
  function writableRootDefinition(root) {
491
532
  const definition = rootDefinition(root);
492
533
  if (!definition || definition.mutable !== true)
@@ -495,6 +536,11 @@ function writableRootDefinition(root) {
495
536
  return resolve(definition.path) === resolve(dshRootPath())
496
537
  ? rootByKey("dsh")
497
538
  : null;
539
+ // v0.4:hub 内技能目录同为用户级可写根(插件新建/导入的落点)。
540
+ if (definition.key === "hub")
541
+ return resolve(definition.path) === resolve(skillCreateRootPath())
542
+ ? rootByKey("hub")
543
+ : null;
498
544
  if (definition.scope !== "project" ||
499
545
  definition.kind !== "project-dsh" ||
500
546
  typeof definition.projectRoot !== "string" ||
@@ -1048,11 +1094,36 @@ function defaultManagerState() {
1048
1094
  disabledSkills[root.key] = [];
1049
1095
  enabledSkills[root.key] = [];
1050
1096
  }
1051
- return { version: 1, sources, disabledSkills, enabledSkills, customRoots: [] };
1097
+ return {
1098
+ version: 1,
1099
+ sources,
1100
+ disabledSkills,
1101
+ enabledSkills,
1102
+ customRoots: [],
1103
+ // 同名技能「首选来源」:技能名 → 来源 key。缺键 = 按来源 rank 取最高优先级者。
1104
+ preferredSkills: Object.create(null),
1105
+ // 用户从技能页「移除」的来源:插件**不再读取**这些目录(技能不出现在列表里,
1106
+ // 也不参与 provider 候选)。与「停用来源」不同——停用仍列出技能、只是不可调用。
1107
+ // 源文件一个字节都不动;清空这个数组即可恢复。
1108
+ removedSources: [],
1109
+ };
1052
1110
  }
1053
1111
  function validStateSkillName(name) {
1054
1112
  return validDiscoveryName(name);
1055
1113
  }
1114
+ /**
1115
+ * 首选表的键是技能**声明名**(不是文件名)——声明名允许大小写与空格(此时技能本身带
1116
+ * name.invalid 诊断,但仍可被选择)。这里只做「能当 JSON 键、不像路径」的宽松校验,
1117
+ * 避免一个脏键把整份状态文件判为非法(那会让全部技能 fail-closed 停用)。
1118
+ */
1119
+ function validPreferredSkillName(name) {
1120
+ return (typeof name === "string" &&
1121
+ name.length > 0 &&
1122
+ name.length <= MAX_ENTRY_NAME_LENGTH &&
1123
+ !name.includes("\0") &&
1124
+ !name.includes("/") &&
1125
+ !name.includes("\\"));
1126
+ }
1056
1127
  /** 状态文件已存在但不可用时一律关闭外部来源,避免损坏配置重新暴露技能。 */
1057
1128
  function failClosedManagerState() {
1058
1129
  const state = defaultManagerState();
@@ -1090,6 +1161,22 @@ function validManagerStateDocument(value) {
1090
1161
  // 自定义来源列表:可选字段;容器必须是数组(条目级合法性由 normalize 逐条裁剪)。
1091
1162
  if (value.customRoots !== undefined && !Array.isArray(value.customRoots))
1092
1163
  return false;
1164
+ // 已移除的来源:可选字段;数组里的每一项必须能当来源 key 用。
1165
+ if (value.removedSources !== undefined) {
1166
+ if (!Array.isArray(value.removedSources) ||
1167
+ value.removedSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
1168
+ return false;
1169
+ }
1170
+ // 同名首选表:可选字段;只校验容器与值类型,键值逐条在 normalize 里裁剪(见 validPreferredSkillName)。
1171
+ if (value.preferredSkills !== undefined) {
1172
+ if (!value.preferredSkills ||
1173
+ typeof value.preferredSkills !== "object" ||
1174
+ Array.isArray(value.preferredSkills))
1175
+ return false;
1176
+ for (const key of Object.values(value.preferredSkills))
1177
+ if (typeof key !== "string")
1178
+ return false;
1179
+ }
1093
1180
  for (const root of userRoots()) {
1094
1181
  if (root.key === "dsh")
1095
1182
  continue;
@@ -1153,8 +1240,50 @@ function normalizeManagerState(value) {
1153
1240
  }
1154
1241
  }
1155
1242
  }
1243
+ // 同名首选表:键必须是技能声明名、值必须指向一个已知来源(用户级 / 自定义 / 项目级),
1244
+ // 否则丢弃该条(源目录被移除后残留的首选会自然失效)。
1245
+ const preferred = value.preferredSkills;
1246
+ if (preferred && typeof preferred === "object" && !Array.isArray(preferred)) {
1247
+ for (const [name, rootKey] of Object.entries(preferred)) {
1248
+ if (!validPreferredSkillName(name) || typeof rootKey !== "string")
1249
+ continue;
1250
+ if (!userRoots().some((root) => root.key === rootKey) &&
1251
+ !customKeys.has(rootKey) &&
1252
+ !PROJECT_ROOT_KEY_RE.test(rootKey))
1253
+ continue;
1254
+ normalized.preferredSkills[name] = rootKey;
1255
+ }
1256
+ }
1257
+ // 已移除的来源:只保留「确实是已知来源 key」的条目,其余丢弃(避免脏键让整份状态非法)。
1258
+ {
1259
+ const raw = Array.isArray(value.removedSources) ? value.removedSources : [];
1260
+ const known = new Set([
1261
+ ...userRoots().map((root) => root.key),
1262
+ ...customKeys,
1263
+ ]);
1264
+ normalized.removedSources = [
1265
+ ...new Set(raw.filter((key) => typeof key === "string" &&
1266
+ known.has(key) &&
1267
+ key !== "dsh" &&
1268
+ key !== "hub")),
1269
+ ].sort();
1270
+ }
1156
1271
  return normalized;
1157
1272
  }
1273
+ /**
1274
+ * 参与扫描的来源 = 全部来源 − 用户已移除的。
1275
+ *
1276
+ * 「移除来源」与「停用来源」是两件事:
1277
+ * - 停用(`sources[key] = false`):仍然读取并列出技能,只是不可调用;
1278
+ * - 移除(出现在 `removedSources`):**连目录都不读**,技能不出现在列表里,
1279
+ * 也不参与 provider 候选。源文件不动,清掉这个标记即恢复。
1280
+ */
1281
+ export function activeUserRoots(stateValue) {
1282
+ const removed = new Set(stateValue && Array.isArray(stateValue.removedSources)
1283
+ ? stateValue.removedSources
1284
+ : []);
1285
+ return userRoots().filter((root) => !removed.has(root.key));
1286
+ }
1158
1287
  /** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
1159
1288
  function normalizeCustomRoots(value) {
1160
1289
  if (!Array.isArray(value))
@@ -1177,14 +1306,51 @@ function normalizeCustomRoots(value) {
1177
1306
  }
1178
1307
  return out;
1179
1308
  }
1309
+ /**
1310
+ * 读状态文件。
1311
+ *
1312
+ * **校验的是「归一化之后」的文档,不是磁盘上的原始 JSON** —— 这一条是踩出来的:
1313
+ * 校验器要求每个 `userRoots()` 来源都在 `sources` 里有布尔值、在 `disabledSkills` 里有数组,
1314
+ * 而来源列表会随版本增加(v0.4 加了 `hub` 来源)。于是**版本升级本身**就会把一份完好的旧
1315
+ * 状态文件判成「非法」,紧接着 fail-closed:所有来源停用、写入锁定,Skills 页弹
1316
+ * 「状态文件不可读,已拒绝覆盖」。文件其实一个字节都没坏。
1317
+ *
1318
+ * 归一化会把缺失的键补成默认值(新来源默认启用),所以「我们后来加过键」与「文件真的坏了」
1319
+ * 由此区分开:前者修复后通过,后者(不是对象、version 不是 1、字段类型不对)仍然拒绝。
1320
+ */
1180
1321
  export async function readManagerState() {
1181
1322
  try {
1182
1323
  const raw = await fs.readFile(managerStatePath(), "utf8");
1183
1324
  const parsed = JSON.parse(raw);
1184
- if (!validManagerStateDocument(parsed))
1325
+ // 版本号在归一化时不会被保留(恒为 1),所以要在解析结果上直接判:
1326
+ // 缺失按 1 处理(更早的文档没有这个字段),存在但不是 1 才判非法。
1327
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)
1328
+ && parsed.version !== undefined && parsed.version !== 1)
1329
+ throw codedError("invalid manager state schema", "error.state.invalid");
1330
+ // 结构守卫:字段**可以缺**(旧文档),但**一旦存在就必须形状正确**。
1331
+ // 少了这道守卫,归一化会把「类型写错」当成「键缺失」一起补默认值——于是 `sources: []`
1332
+ // 或 `disabledSkills: { dsh: "x" }` 这种真损坏反而被自愈放行,静默丢掉文件里的策略。
1333
+ // 契约:**缺键自愈(我们后来加过的东西),类型错必须报错。**
1334
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1335
+ for (const field of ["sources", "disabledSkills", "enabledSkills"]) {
1336
+ const value = parsed[field];
1337
+ if (value === undefined)
1338
+ continue;
1339
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1340
+ throw codedError("invalid manager state schema", "error.state.invalid");
1341
+ // 策略表的每个桶必须是数组(条目级合法性仍由 normalize 逐条裁剪)。
1342
+ if (field === "disabledSkills" || field === "enabledSkills") {
1343
+ for (const bucket of Object.values(value))
1344
+ if (!Array.isArray(bucket))
1345
+ throw codedError("invalid manager state schema", "error.state.invalid");
1346
+ }
1347
+ }
1348
+ }
1349
+ const normalized = normalizeManagerState(parsed);
1350
+ if (!validManagerStateDocument(normalized))
1185
1351
  throw codedError("invalid manager state schema", "error.state.invalid");
1186
1352
  return {
1187
- state: normalizeManagerState(parsed),
1353
+ state: normalized,
1188
1354
  warning: null,
1189
1355
  writable: true,
1190
1356
  };
@@ -1282,6 +1448,48 @@ export async function setSourceEnabled(rootOrKey, enabled, log) {
1282
1448
  log(enabled ? "source-enable" : "source-disable", `${enabled ? "启用" : "停用"}来源 ${root.key}: ${root.path}`);
1283
1449
  return { root: root.key, enabled: enabled === true };
1284
1450
  }
1451
+ /**
1452
+ * 移除 / 恢复一个来源(用户要求:「是不读取这个文件夹了,不是把文件夹删除」)。
1453
+ *
1454
+ * - `removed = true`:来源进入 `removedSources`,插件**不再读取该目录** —— 技能不出现在
1455
+ * 技能页,也不参与 provider 候选。**源文件与目录一个字节都不动。**
1456
+ * - `removed = false`:清除标记,下一步扫描即恢复。
1457
+ *
1458
+ * 与 `setSourceEnabled(false)` 的区别:停用仍然读目录、仍然列出技能(只是不可调用);
1459
+ * 移除是"当它不存在"。`dsh`(官方技能目录)与 `hub`(导入技能落点)不允许移除 ——
1460
+ * 它们是插件自身的读写根,移除会让创建/导入无处落脚。
1461
+ */
1462
+ export async function setSourceRemoved(rootOrKey, removed, log) {
1463
+ const root = rootOrKey && typeof rootOrKey === "object" && typeof rootOrKey.key === "string"
1464
+ ? rootOrKey
1465
+ : rootByKey(rootOrKey);
1466
+ if (!root)
1467
+ return readonlyError(removed === true ? "remove" : "restore");
1468
+ if (root.key === "dsh" || root.key === "hub")
1469
+ return {
1470
+ ok: false,
1471
+ code: "error.source.reserved",
1472
+ params: { root: root.key },
1473
+ error: root.key === "dsh"
1474
+ ? "DSH 技能目录是官方来源,不能从管理器移除"
1475
+ : "导入技能目录是插件自身的读写落点,不能移除",
1476
+ };
1477
+ if (root.scope === "project")
1478
+ return readonlyError("remove");
1479
+ const current = await readManagerState();
1480
+ if (current.writable === false)
1481
+ return invalidManagerStateWrite();
1482
+ const set = new Set(Array.isArray(current.state.removedSources) ? current.state.removedSources : []);
1483
+ if (removed === true)
1484
+ set.add(root.key);
1485
+ else
1486
+ set.delete(root.key);
1487
+ current.state.removedSources = [...set].sort();
1488
+ await writeManagerState(current.state);
1489
+ if (log)
1490
+ log(removed === true ? "source-remove" : "source-restore", `${removed === true ? "移除(不再读取)" : "恢复读取"}来源 ${root.key}: ${root.path}`);
1491
+ return { root: root.key, removed: removed === true };
1492
+ }
1285
1493
  async function checkedPolicyRootDefinition(root) {
1286
1494
  const definition = rootDefinition(root);
1287
1495
  if (!definition || !definition.toggleable)
@@ -1383,6 +1591,62 @@ async function setPolicySkillEnabled(root, name, enabled, log) {
1383
1591
  export async function setSkillEnabled(root, name, enabled, log) {
1384
1592
  return setPolicySkillEnabled(root, name, enabled, log);
1385
1593
  }
1594
+ /**
1595
+ * 同名技能「首选来源」:默认同名技能按来源 rank 取优先级最高者生效、其余显示为被覆盖;
1596
+ * 这里让用户显式指定哪个同名技能生效(preferred=false 取消,回到 rank 顺序)。
1597
+ * 只写本地策略,不改任何源文件。
1598
+ */
1599
+ export async function setPreferredSkill(root, name, preferred, log) {
1600
+ const definition = await checkedPolicyRootDefinition(root);
1601
+ if (definition && definition.ok === false)
1602
+ return definition;
1603
+ if (!definition)
1604
+ return readonlyError("toggle");
1605
+ const resolved = await resolveEntry(definition, name);
1606
+ if (resolved === null)
1607
+ return {
1608
+ ok: false,
1609
+ error: `技能不存在: ${name}`,
1610
+ code: "error.skill.notFound",
1611
+ params: { name },
1612
+ };
1613
+ let summary;
1614
+ try {
1615
+ summary = entryOf(name, resolved.kind, resolved.docPath, parseSkillDoc(await fs.readFile(resolved.realDocPath || resolved.docPath, "utf8")));
1616
+ }
1617
+ catch {
1618
+ return {
1619
+ ok: false,
1620
+ error: `技能不存在: ${name}`,
1621
+ code: "error.skill.notFound",
1622
+ params: { name },
1623
+ };
1624
+ }
1625
+ if (!summary.loadable)
1626
+ return {
1627
+ ok: false,
1628
+ error: `技能结构不完整,无法设为同名首选: ${name}`,
1629
+ code: "error.skill.notLoadable",
1630
+ params: { name, action: "prefer" },
1631
+ };
1632
+ // 首选表按**声明名**索引(与 groupLoadableSkillsByName 的分组键同源),不是文件名。
1633
+ const canonicalName = summary.declaredName || name;
1634
+ const current = await readManagerState();
1635
+ if (current.writable === false)
1636
+ return invalidManagerStateWrite();
1637
+ const map = { ...(current.state.preferredSkills || {}) };
1638
+ if (preferred === false)
1639
+ delete map[canonicalName];
1640
+ else
1641
+ map[canonicalName] = definition.key;
1642
+ current.state.preferredSkills = map;
1643
+ await writeManagerState(current.state);
1644
+ if (log)
1645
+ log(preferred === false ? "skill-unprefer" : "skill-prefer", preferred === false
1646
+ ? `取消同名首选 ${canonicalName}`
1647
+ : `同名首选 ${canonicalName} → ${definition.key}`);
1648
+ return { name: canonicalName, root: preferred === false ? null : definition.key };
1649
+ }
1386
1650
  async function safeExistingEntryPaths(root, name) {
1387
1651
  const paths = [];
1388
1652
  const bundle = entryPath(root, name);
@@ -1498,13 +1762,17 @@ async function restoreRootDefinition(metadata, options = {}) {
1498
1762
  }
1499
1763
  return checkedWritableRootDefinition(root);
1500
1764
  }
1501
- /** 把用户或活动项目的 DSH 根中的单个技能移入 manager-owned 回收站。 */
1765
+ /** 把项目级 DSH 根中的单个技能移入 manager-owned 回收站。 */
1502
1766
  export async function deleteSkill(root, name, log, options = {}) {
1503
1767
  const definition = await checkedWritableRootDefinition(root);
1504
1768
  if (definition && definition.ok === false)
1505
1769
  return definition;
1506
1770
  if (!definition)
1507
1771
  return readonlyError("delete");
1772
+ // 用户级来源(dsh / hub)**不可删**:技能只能停用。删除会把用户自己放进去的技能
1773
+ // 从磁盘上搬走,代价远大于收益;项目级来源(本仓库自己的 .dsh/skills)才允许删。
1774
+ if (definition.deletable !== true)
1775
+ return notDeletableError(definition);
1508
1776
  const resolved = await resolveEntry(definition, name);
1509
1777
  if (resolved === null)
1510
1778
  return {
@@ -1866,7 +2134,7 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
1866
2134
  * 成功返回 { kind, imported, skipped, failed };失败返回 { ok:false, error }。
1867
2135
  */
1868
2136
  export async function importSkill(source, log, options = {}) {
1869
- const targetRoot = dshRootPath();
2137
+ const targetRoot = skillCreateRootPath();
1870
2138
  const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
1871
2139
  const dryRun = options.dryRun === true;
1872
2140
  const analysis = await analyzeSource(source);
@@ -2210,9 +2478,11 @@ function yamlString(value) {
2210
2478
  return JSON.stringify(String(value));
2211
2479
  }
2212
2480
  export async function createSkill(input, log, options = {}) {
2481
+ // v0.4:默认落点由「DSH 技能目录」改为 hub 内的 `tool-management/skills/`;
2482
+ // 调用方显式传 options.root(如项目根)时仍以调用方为准。
2213
2483
  const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root")
2214
2484
  ? options.root
2215
- : rootByKey("dsh");
2485
+ : rootByKey("hub") || rootByKey("dsh");
2216
2486
  const definition = await checkedWritableRootDefinition(requestedRoot);
2217
2487
  if (definition && definition.ok === false)
2218
2488
  return definition;
@@ -2283,13 +2553,15 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
2283
2553
  const root = (keyOrRoot && typeof keyOrRoot === "object" && typeof keyOrRoot.key === "string" ? keyOrRoot : null) ||
2284
2554
  rootByKey(keyOrRoot) ||
2285
2555
  scopedRoots.find((item) => item.key === keyOrRoot);
2286
- if (!root)
2556
+ if (!root) {
2557
+ const unknownRoot = (keyOrRoot && typeof keyOrRoot === "object" && keyOrRoot.key) || keyOrRoot;
2287
2558
  return {
2288
2559
  ok: false,
2289
- error: `未知技能来源: ${key}`,
2560
+ error: `未知技能来源: ${unknownRoot}`,
2290
2561
  code: "error.root.unknown",
2291
- params: { root: key },
2562
+ params: { root: unknownRoot },
2292
2563
  };
2564
+ }
2293
2565
  const entry = await visibleEntryForRoot(root, name);
2294
2566
  if (!entry)
2295
2567
  return {
@@ -2332,7 +2604,7 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
2332
2604
  export async function listProviderCandidates(options = {}) {
2333
2605
  const policyResult = await readManagerState();
2334
2606
  const candidates = [];
2335
- const user = userRoots().concat(customRootsFromState(policyResult.state));
2607
+ const user = activeUserRoots(policyResult.state).concat(customRootsFromState(policyResult.state).filter((root) => !policyResult.state.removedSources.includes(root.key)));
2336
2608
  const userScans = await scanDeduplicatedRoots(user);
2337
2609
  const items = user.flatMap((root) => userScans.get(root.key).entries.map((entry) => ({ root, entry })));
2338
2610
  const cwd = options && typeof options.cwd === "string" ? options.cwd : undefined;
@@ -2450,10 +2722,28 @@ export async function getProviderSkill(candidate, options = {}) {
2450
2722
  function canonicalSkillName(item) {
2451
2723
  return item.entry.declaredName || item.entry.name;
2452
2724
  }
2453
- /** 真实路径去重后按声明名分组;管理页与当前工作区 provider 共用来源优先级。 */
2454
- function groupLoadableSkillsByName(items) {
2725
+ /**
2726
+ * 真实路径去重后按声明名分组;管理页与当前工作区 provider 共用来源优先级。
2727
+ *
2728
+ * `preferred`(技能名 → 来源 key)是用户对同名技能的显式选择:命中的来源排到同名前,
2729
+ * 其余仍按来源 rank 排序;未命中(或键已失效)时退化为纯 rank 顺序。
2730
+ */
2731
+ export function groupLoadableSkillsByName(items, preferred) {
2732
+ const preferredFor = (item) => {
2733
+ if (!preferred)
2734
+ return null;
2735
+ const key = preferred[canonicalSkillName(item)];
2736
+ return typeof key === "string" ? key : null;
2737
+ };
2738
+ const ordered = [...items].sort((a, b) => {
2739
+ const rankA = preferredFor(a) === a.root.key ? 0 : 1;
2740
+ const rankB = preferredFor(b) === b.root.key ? 0 : 1;
2741
+ if (rankA !== rankB)
2742
+ return rankA - rankB;
2743
+ return a.root.rank - b.root.rank;
2744
+ });
2455
2745
  const groups = new Map();
2456
- for (const item of [...items].sort((a, b) => a.root.rank - b.root.rank)) {
2746
+ for (const item of ordered) {
2457
2747
  if (!item.entry.loadable)
2458
2748
  continue;
2459
2749
  const name = canonicalSkillName(item);
@@ -2465,8 +2755,12 @@ function groupLoadableSkillsByName(items) {
2465
2755
  }
2466
2756
  function markWinners(items, options = {}) {
2467
2757
  const winners = new Map();
2468
- for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items)) {
2758
+ const preferred = options.preferred;
2759
+ for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items, preferred)) {
2469
2760
  winners.set(canonicalName, winner);
2761
+ // 只有「首选命中且确实赢了」才算首选;来源被停用/移除时不谎报。
2762
+ if (preferred && preferred[canonicalName] === winner.root.key)
2763
+ winner.view.preferred = true;
2470
2764
  if (options.markShadowed !== false) {
2471
2765
  for (const item of shadowed)
2472
2766
  item.view.shadowedBy = {
@@ -2484,8 +2778,16 @@ function markWinners(items, options = {}) {
2484
2778
  /** DSH、常见 Agent、自定义目录与活动 Session 项目根的技能快照。 */
2485
2779
  export async function state(options = {}) {
2486
2780
  const policyResult = await readManagerState();
2487
- const user = userRoots().concat(customRootsFromState(policyResult.state));
2488
- const userScans = await scanDeduplicatedRoots(user);
2781
+ const removedKeys = new Set(Array.isArray(policyResult.state.removedSources)
2782
+ ? policyResult.state.removedSources
2783
+ : []);
2784
+ // 已移除的来源仍然出现在 roots 里(界面要能显示「已移除」并允许恢复),但**不扫描**:
2785
+ // 技能不入列表、不参与重名决胜 —— 这正是用户要的「不读取这个文件夹」。
2786
+ // 注意这里必须用**全部**来源 + 自定义来源,不能只用 activeUserRoots(),
2787
+ // 否则被移除的来源会连行都不剩,界面就无从提供「恢复读取」。
2788
+ const user = [...userRoots(), ...customRootsFromState(policyResult.state)];
2789
+ const scannable = user.filter((root) => !removedKeys.has(root.key));
2790
+ const userScans = await scanDeduplicatedRoots(scannable);
2489
2791
  const projectWarnings = [];
2490
2792
  const scoped = await projectRoots(options.projectCwds, projectWarnings);
2491
2793
  const trash = await listTrash();
@@ -2513,9 +2815,35 @@ export async function state(options = {}) {
2513
2815
  });
2514
2816
  const all = [];
2515
2817
  for (const root of [...scoped, ...user]) {
2516
- const { exists, entries, truncated } = root.scope === "project"
2517
- ? (await scanDeduplicatedRoots([root])).get(root.key)
2518
- : userScans.get(root.key);
2818
+ const isRemoved = removedKeys.has(root.key);
2819
+ const { exists, entries, truncated } = isRemoved
2820
+ ? { exists: false, entries: [], truncated: false }
2821
+ : root.scope === "project"
2822
+ ? (await scanDeduplicatedRoots([root])).get(root.key)
2823
+ : userScans.get(root.key);
2824
+ if (isRemoved) {
2825
+ // 已移除:登记一行(供界面显示与恢复),但没有任何技能。
2826
+ result.roots.push({
2827
+ key: root.key,
2828
+ ...(root.kind ? { kind: root.kind } : {}),
2829
+ ...(root.localeKey ? { localeKey: root.localeKey } : {}),
2830
+ path: root.path,
2831
+ label: root.label,
2832
+ mutable: root.mutable,
2833
+ deletable: root.deletable === true,
2834
+ removable: root.key !== "dsh" && root.key !== "hub" && root.scope !== "project",
2835
+ toggleable: root.toggleable,
2836
+ native: root.native,
2837
+ rank: root.rank,
2838
+ scope: root.scope || "user",
2839
+ exists: false,
2840
+ truncated: false,
2841
+ removed: true,
2842
+ enabled: false,
2843
+ skills: [],
2844
+ });
2845
+ continue;
2846
+ }
2519
2847
  // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
2520
2848
  if (root.scope === "project" && !exists && root.kind !== "project-dsh")
2521
2849
  continue;
@@ -2563,6 +2891,8 @@ export async function state(options = {}) {
2563
2891
  path: root.path,
2564
2892
  label: root.label,
2565
2893
  mutable: root.mutable,
2894
+ // 是否提供「删除」:只有项目级 DSH 根为 true。用户级 dsh / hub 可写但不可删。
2895
+ deletable: root.deletable === true,
2566
2896
  toggleable: root.toggleable,
2567
2897
  native: root.native,
2568
2898
  rank: root.rank,
@@ -2576,6 +2906,9 @@ export async function state(options = {}) {
2576
2906
  : {}),
2577
2907
  exists,
2578
2908
  truncated: truncated === true,
2909
+ // 界面用:能否从管理器「移除」(不再读取)。dsh / hub / 项目级不可移除。
2910
+ removable: root.key !== "dsh" && root.key !== "hub" && root.scope !== "project",
2911
+ removed: false,
2579
2912
  enabled: policyResult.writable !== false &&
2580
2913
  (root.scope === "project" ||
2581
2914
  root.key === "dsh" ||
@@ -2584,7 +2917,8 @@ export async function state(options = {}) {
2584
2917
  });
2585
2918
  }
2586
2919
  const userItems = all.filter((item) => item.root.scope !== "project");
2587
- markWinners(userItems);
2920
+ const preferredSkills = policyResult.state.preferredSkills || null;
2921
+ markWinners(userItems, { preferred: preferredSkills });
2588
2922
  const projectGroups = new Map();
2589
2923
  for (const item of all.filter((candidate) => candidate.root.scope === "project")) {
2590
2924
  const group = projectGroups.get(item.root.projectRoot) || [];
@@ -2597,7 +2931,7 @@ export async function state(options = {}) {
2597
2931
  ...item,
2598
2932
  view: { ...item.view },
2599
2933
  }));
2600
- markWinners([...projectItems, ...userCopies]);
2934
+ markWinners([...projectItems, ...userCopies], { preferred: preferredSkills });
2601
2935
  }
2602
2936
  const seenProjects = new Set();
2603
2937
  for (const root of result.roots.filter((item) => item.scope === "project")) {
@@ -9,7 +9,7 @@ import { Worker } from 'node:worker_threads';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, join } from 'node:path';
12
- import { state, setSkillEnabled, setSourceEnabled, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
12
+ import { state, setSkillEnabled, setPreferredSkill, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
13
13
  const MAX_LOG_BYTES = 1 << 20;
14
14
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
15
15
  const message = (e) => String((e && e.message) || e);
@@ -282,8 +282,15 @@ function notifyChatCatalog(ctx, invalidateSkills) {
282
282
  }
283
283
  catch { /* 斜杠菜单刷新失败不影响已完成的操作 */ }
284
284
  }
285
+ /** 全插件共用的审计日志:单一串行写队列,避免多处写同一文件交叉。 */
286
+ let sharedLog = null;
287
+ export function pluginLog() {
288
+ if (!sharedLog)
289
+ sharedLog = makeLog();
290
+ return sharedLog;
291
+ }
285
292
  export function createSkillsService(ctx) {
286
- const log = makeLog();
293
+ const log = pluginLog();
287
294
  const roots = userRoots();
288
295
  const rootByKey = Object.fromEntries(roots.map((root) => [root.key, root]));
289
296
  const projectOptions = () => ({ projectCwds: activeSessionCwds(ctx) });
@@ -387,18 +394,37 @@ export function createSkillsService(ctx) {
387
394
  return { ok: true, data: result };
388
395
  };
389
396
  const write = (task) => enqueueMutation(task);
397
+ // 写操作清单:与下方 ops 表同文件同源维护;HTTP 端门禁由 index.ts 从本集合派生,勿在宿主端另抄一份。
398
+ const writeOps = new Set([
399
+ 'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
400
+ 'skill-prefer', 'skill-unprefer',
401
+ 'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
402
+ 'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
403
+ ]);
390
404
  const ops = {
391
405
  // 读操作(state 走短 TTL 缓存,见上方 readState)
392
406
  'skill-state': wrap(() => readState()),
393
- 'skill-detail': wrap(async (args) => skillDetail(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), projectOptions())),
407
+ 'skill-detail': wrap(async (args) => {
408
+ const key = String(args.root || 'dsh');
409
+ // requestRoot 未命中时回传原始 key,让 core 的 error.root.unknown 带上来源名(而非 undefined)。
410
+ return skillDetail((await requestRoot(key)) || key, String(args.name || ''), projectOptions());
411
+ }),
394
412
  'skill-browse': wrap((args) => browseDirectories(args && args.path)),
395
413
  // 启停(写 manager 状态,不改源文件)
396
414
  'skill-enable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
397
415
  'skill-disable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
398
416
  'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), true, log)), afterWrite),
399
417
  'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), false, log)), afterWrite),
418
+ // 移除 / 恢复来源(「不再读取这个文件夹」):只改本地状态,源目录一个字节都不动。
419
+ 'skill-source-remove': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), true, log)), afterWrite),
420
+ 'skill-source-restore': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), false, log)), afterWrite),
421
+ // 同名技能首选来源(手工指定哪个同名版本生效;只写本地策略)。
422
+ 'skill-prefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
423
+ 'skill-unprefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
400
424
  // 创建 / 导入 / 删除 / 回收站
401
- 'skill-create': wrap((args) => write(async () => createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: await requestRoot(String(args.root || 'dsh')) })), afterWrite),
425
+ // 创建默认落 hub(`tool-management/skills/`):UI 不传 root 时走这里;
426
+ // core.js 的 createSkill 默认值是第二道保险(同口径)。官方 `~/.dsh/skills/` 仍作为来源列出。
427
+ 'skill-create': wrap((args) => write(async () => createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: await requestRoot(String(args.root || 'hub')) })), afterWrite),
402
428
  'skill-import': wrap((args) => write(() => importSkill(String(args.source || ''), log, {
403
429
  conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
404
430
  dryRun: args.dryRun === true,
@@ -413,5 +439,5 @@ export function createSkillsService(ctx) {
413
439
  'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
414
440
  'skill-custom-remove': wrap((args) => write(() => removeCustomRoot(args && args.key, log)), afterWrite),
415
441
  };
416
- return { ops, registerProviders };
442
+ return { ops, writeOps, registerProviders };
417
443
  }