dsh-plugin-tool-management 0.1.3 → 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;
@@ -2223,6 +2493,7 @@ export async function createSkill(input, log, options = {}) {
2223
2493
  const name = toKebab(requestedName);
2224
2494
  const description = String((input && input.description) || "").trim();
2225
2495
  const body = String((input && input.body) || "").trim();
2496
+ const form = String((input && input.form) || "bundle") === "flat" ? "flat" : "bundle";
2226
2497
  if (!name || !KEBAB_RE.test(name) || entryPath(root, name) === null)
2227
2498
  return {
2228
2499
  ok: false,
@@ -2252,11 +2523,19 @@ export async function createSkill(input, log, options = {}) {
2252
2523
  params: { name },
2253
2524
  };
2254
2525
  await fs.mkdir(root, { recursive: true });
2526
+ const content = `---\nname: ${name}\ndescription: ${yamlString(description)}\n---\n\n${body}\n`;
2527
+ if (form === "flat") {
2528
+ // flat 形态:直接写 <root>/<name>.md(规则/单文件技能用;bundle 见下方目录分支)
2529
+ const flatPath = resolve(root, `${name}.md`);
2530
+ await writeFileAtomically(flatPath, content);
2531
+ if (log)
2532
+ log("create", `创建 ${flatPath}`);
2533
+ return { name, path: flatPath, root: definition.key };
2534
+ }
2255
2535
  const target = entryPath(root, name);
2256
2536
  const stage = temporaryPath(target, "create");
2257
2537
  try {
2258
2538
  await fs.mkdir(stage);
2259
- const content = `---\nname: ${name}\ndescription: ${yamlString(description)}\n---\n\n${body}\n`;
2260
2539
  await fs.writeFile(join(stage, "SKILL.md"), content, "utf8");
2261
2540
  await fs.rename(stage, target);
2262
2541
  }
@@ -2274,13 +2553,15 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
2274
2553
  const root = (keyOrRoot && typeof keyOrRoot === "object" && typeof keyOrRoot.key === "string" ? keyOrRoot : null) ||
2275
2554
  rootByKey(keyOrRoot) ||
2276
2555
  scopedRoots.find((item) => item.key === keyOrRoot);
2277
- if (!root)
2556
+ if (!root) {
2557
+ const unknownRoot = (keyOrRoot && typeof keyOrRoot === "object" && keyOrRoot.key) || keyOrRoot;
2278
2558
  return {
2279
2559
  ok: false,
2280
- error: `未知技能来源: ${key}`,
2560
+ error: `未知技能来源: ${unknownRoot}`,
2281
2561
  code: "error.root.unknown",
2282
- params: { root: key },
2562
+ params: { root: unknownRoot },
2283
2563
  };
2564
+ }
2284
2565
  const entry = await visibleEntryForRoot(root, name);
2285
2566
  if (!entry)
2286
2567
  return {
@@ -2323,7 +2604,7 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
2323
2604
  export async function listProviderCandidates(options = {}) {
2324
2605
  const policyResult = await readManagerState();
2325
2606
  const candidates = [];
2326
- 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)));
2327
2608
  const userScans = await scanDeduplicatedRoots(user);
2328
2609
  const items = user.flatMap((root) => userScans.get(root.key).entries.map((entry) => ({ root, entry })));
2329
2610
  const cwd = options && typeof options.cwd === "string" ? options.cwd : undefined;
@@ -2441,10 +2722,28 @@ export async function getProviderSkill(candidate, options = {}) {
2441
2722
  function canonicalSkillName(item) {
2442
2723
  return item.entry.declaredName || item.entry.name;
2443
2724
  }
2444
- /** 真实路径去重后按声明名分组;管理页与当前工作区 provider 共用来源优先级。 */
2445
- 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
+ });
2446
2745
  const groups = new Map();
2447
- for (const item of [...items].sort((a, b) => a.root.rank - b.root.rank)) {
2746
+ for (const item of ordered) {
2448
2747
  if (!item.entry.loadable)
2449
2748
  continue;
2450
2749
  const name = canonicalSkillName(item);
@@ -2456,8 +2755,12 @@ function groupLoadableSkillsByName(items) {
2456
2755
  }
2457
2756
  function markWinners(items, options = {}) {
2458
2757
  const winners = new Map();
2459
- for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items)) {
2758
+ const preferred = options.preferred;
2759
+ for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items, preferred)) {
2460
2760
  winners.set(canonicalName, winner);
2761
+ // 只有「首选命中且确实赢了」才算首选;来源被停用/移除时不谎报。
2762
+ if (preferred && preferred[canonicalName] === winner.root.key)
2763
+ winner.view.preferred = true;
2461
2764
  if (options.markShadowed !== false) {
2462
2765
  for (const item of shadowed)
2463
2766
  item.view.shadowedBy = {
@@ -2475,8 +2778,16 @@ function markWinners(items, options = {}) {
2475
2778
  /** DSH、常见 Agent、自定义目录与活动 Session 项目根的技能快照。 */
2476
2779
  export async function state(options = {}) {
2477
2780
  const policyResult = await readManagerState();
2478
- const user = userRoots().concat(customRootsFromState(policyResult.state));
2479
- 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);
2480
2791
  const projectWarnings = [];
2481
2792
  const scoped = await projectRoots(options.projectCwds, projectWarnings);
2482
2793
  const trash = await listTrash();
@@ -2504,9 +2815,35 @@ export async function state(options = {}) {
2504
2815
  });
2505
2816
  const all = [];
2506
2817
  for (const root of [...scoped, ...user]) {
2507
- const { exists, entries, truncated } = root.scope === "project"
2508
- ? (await scanDeduplicatedRoots([root])).get(root.key)
2509
- : 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
+ }
2510
2847
  // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
2511
2848
  if (root.scope === "project" && !exists && root.kind !== "project-dsh")
2512
2849
  continue;
@@ -2554,6 +2891,8 @@ export async function state(options = {}) {
2554
2891
  path: root.path,
2555
2892
  label: root.label,
2556
2893
  mutable: root.mutable,
2894
+ // 是否提供「删除」:只有项目级 DSH 根为 true。用户级 dsh / hub 可写但不可删。
2895
+ deletable: root.deletable === true,
2557
2896
  toggleable: root.toggleable,
2558
2897
  native: root.native,
2559
2898
  rank: root.rank,
@@ -2567,6 +2906,9 @@ export async function state(options = {}) {
2567
2906
  : {}),
2568
2907
  exists,
2569
2908
  truncated: truncated === true,
2909
+ // 界面用:能否从管理器「移除」(不再读取)。dsh / hub / 项目级不可移除。
2910
+ removable: root.key !== "dsh" && root.key !== "hub" && root.scope !== "project",
2911
+ removed: false,
2570
2912
  enabled: policyResult.writable !== false &&
2571
2913
  (root.scope === "project" ||
2572
2914
  root.key === "dsh" ||
@@ -2575,7 +2917,8 @@ export async function state(options = {}) {
2575
2917
  });
2576
2918
  }
2577
2919
  const userItems = all.filter((item) => item.root.scope !== "project");
2578
- markWinners(userItems);
2920
+ const preferredSkills = policyResult.state.preferredSkills || null;
2921
+ markWinners(userItems, { preferred: preferredSkills });
2579
2922
  const projectGroups = new Map();
2580
2923
  for (const item of all.filter((candidate) => candidate.root.scope === "project")) {
2581
2924
  const group = projectGroups.get(item.root.projectRoot) || [];
@@ -2588,7 +2931,7 @@ export async function state(options = {}) {
2588
2931
  ...item,
2589
2932
  view: { ...item.view },
2590
2933
  }));
2591
- markWinners([...projectItems, ...userCopies]);
2934
+ markWinners([...projectItems, ...userCopies], { preferred: preferredSkills });
2592
2935
  }
2593
2936
  const seenProjects = new Set();
2594
2937
  for (const root of result.roots.filter((item) => item.scope === "project")) {