@morehao/dsh-skills-console 0.3.7 → 0.4.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.
@@ -2,7 +2,13 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { StateReadOnlyError } from './state.js';
4
4
  import { contentHash } from './hash.js';
5
- import { ensureLink, inspectTarget, removeLink } from './links.js';
5
+ import { inspectTarget, removeLink } from './links.js';
6
+ import { exportSkills, unexportSkill } from './exporter.js';
7
+ import { ensureMigrated } from './migrations.js';
8
+ import { findProjectRoot } from './paths.js';
9
+ import { listRoots } from './roots.js';
10
+ import { readShadowMarker, removeShadow } from './shadow.js';
11
+ import { resolveVisibility } from './visibility.js';
6
12
  import { buildLedger, readLock, scanCanonical } from './inventory.js';
7
13
  import { readLockFile, updateLock } from './lock.js';
8
14
  import { readBackup, restoreBackup, trashSkill } from './trash.js';
@@ -25,10 +31,12 @@ import { fetchAtomCommit, snapshotFromTarball } from './remote.js';
25
31
  const QUEUE_LIMIT = 8;
26
32
 
27
33
  export class ServiceError extends Error {
28
- constructor(code, message, detail) {
34
+ constructor(code, message, detail, status = 400) {
29
35
  super(message);
30
36
  this.code = code;
31
37
  this.detail = detail;
38
+ // 路由默认按 400 返回;少数「能力缺失/状态冲突」用 409,调用方显式传入。
39
+ this.status = status;
32
40
  }
33
41
  }
34
42
 
@@ -53,6 +61,17 @@ function skillFileFailure(error) {
53
61
  return error;
54
62
  }
55
63
 
64
+ /** 是我方写的 legacy 停用桩(真实目录 + marker)?删除体检时只放过这一类。 */
65
+ function isLegacyStubDir(dirPath) {
66
+ try {
67
+ const stat = fs.lstatSync(dirPath);
68
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
69
+ return readShadowMarker(dirPath) !== null;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
56
75
  /** 路由参数里的技能名:在拼任何路径之前先校验,拒绝穿越/分隔符/控制字符。 */
57
76
  function assertSafeSkillName(name) {
58
77
  if (
@@ -202,6 +221,10 @@ export function createService({
202
221
  fetchImpl = fetch,
203
222
  visibleProvider = async () => ({ skills: [], complete: true }),
204
223
  workspacesProvider = async () => [],
224
+ /** 启停写完后让每个 live agent 的 policy provider 失效(宿主下次 list 重新收集)。 */
225
+ invalidatePolicies = async () => {},
226
+ /** `{ available, liveAgents, registered, errors[] }`;没有 provider 时启停必须 409。 */
227
+ policyStatus = () => ({ available: false, liveAgents: 0, registered: 0, errors: [] }),
205
228
  }) {
206
229
  const enqueue = createQueue(logger);
207
230
  const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
@@ -250,49 +273,43 @@ export function createService({
250
273
  return registry.detected();
251
274
  }
252
275
 
253
- function defaultTargets(current) {
254
- const targets = ['dsh-global'];
255
- // null = 老语义「所有探测到的 Agent」;数组 = 用户在「自动接入」里勾选的那些
256
- const selected = current.settings.autoLinkTargets ?? null;
257
- for (const target of detectedTargets()) {
258
- if (target.kind !== 'agent') continue;
259
- if (selected === null || selected.includes(target.target)) targets.push(target.target);
260
- }
261
- return targets;
276
+ /**
277
+ * 新安装/更新时默认导出的目标。v2 起默认空:导出是逐 agent 的显式动作,
278
+ * 「装完就到处建链」正是 v1 那套「链接 = 可见性」的残留。
279
+ */
280
+ /**
281
+ * 这个 agent 需要导出吗?判据由注册表算好(`exportable`):读共享根的不需要、
282
+ * DSH 自己的扫描根也不需要。`unknown` 仍然算「需要」——没验证过就按需要处理。
283
+ */
284
+ function needsExport(entry) {
285
+ return entry.exportable === true;
262
286
  }
263
287
 
264
- /** 建链:逐条幂等,冲突跳过不覆盖,失败收集错误继续。 */
265
- function linkSkill(name, targets, { scopeProjects = [], record = true } = {}) {
266
- const created = [];
267
- const skipped = [];
268
- const failed = [];
269
- const canonicalPath = path.join(paths.canonical, name);
270
- if (!fs.existsSync(canonicalPath)) {
271
- throw new ServiceError('error.skills.not-installed', `${name} 不在 canonical 里`);
272
- }
288
+ function exportTargetList(current) {
289
+ const selected = Array.isArray(current.settings.exportTargets) ? current.settings.exportTargets : [];
290
+ if (selected.length === 0) return [];
291
+ const detected = new Set(
292
+ detectedTargets()
293
+ .filter((item) => needsExport(item))
294
+ .map((item) => item.target),
295
+ );
296
+ return selected.filter((target) => detected.has(target));
297
+ }
273
298
 
274
- const resolved = [];
275
- for (const target of targets) {
276
- try {
277
- resolved.push(registry.resolve(target));
278
- } catch (error) {
279
- failed.push({ target, reason: error.message });
280
- }
281
- }
282
- // 作用范围非空 = 只写项目链,不写全局链(否则项目链没有额外效果,见 §4.6)
283
- for (const root of scopeProjects) {
284
- resolved.push(registry.resolve(`project:${root}`));
285
- }
286
- const effective = scopeProjects.length > 0 ? resolved.filter((item) => item.target !== 'dsh-global') : resolved;
287
-
288
- for (const target of effective) {
289
- const linkPath = path.join(target.dir, name);
290
- const result = ensureLink({ linkPath, canonicalPath });
291
- if (result.created) created.push({ target: target.target, linkPath, mode: result.mode });
292
- else if (result.skipped) skipped.push({ target: target.target, linkPath, reason: result.state });
293
- if (record) recordLink(name, target, linkPath, result.mode || 'relative', result.created);
294
- }
295
- return { created, skipped, failed };
299
+ /** 某条技能已记账的导出目标(更新后复验用;不看 settings,导出是逐技能的) */
300
+ function exportTargetsFor(skill) {
301
+ return [...new Set(state().exports.filter((item) => item.skill === skill).map((item) => item.target))];
302
+ }
303
+
304
+ /** 导出(建链):逐条幂等,冲突跳过不覆盖,失败收集原因继续。 */
305
+ function exportSkill(name, targets, { record = true } = {}) {
306
+ return exportSkills({
307
+ paths,
308
+ registry,
309
+ name,
310
+ targets,
311
+ record: record ? (row) => recordExport(name, row) : () => {},
312
+ });
296
313
  }
297
314
 
298
315
  /**
@@ -484,23 +501,66 @@ export function createService({
484
501
  }
485
502
  }
486
503
 
487
- function recordLink(name, target, linkPath, mode, created) {
504
+ /**
505
+ * 记账:只在链**确实存在且指向 canonical** 时写。
506
+ * `created: false` 但没记账 = 链是上一次运行/别人建的、我们刚接管,也要补记。
507
+ */
508
+ function recordExport(name, { target, agentDir, linkPath, mode }) {
488
509
  if (store.readOnly) return;
489
- const existing = state().links.find((item) => item.skill === name && item.target === target.target);
490
- if (existing) return;
491
- if (!created) return;
510
+ if (state().exports.some((item) => item.skill === name && item.target === target)) return;
492
511
  store.update((draft) => {
493
- draft.links.push({
512
+ if (draft.exports.some((item) => item.skill === name && item.target === target)) return;
513
+ draft.exports.push({
494
514
  skill: name,
495
- target: target.target,
496
- agentDir: target.dir,
515
+ target,
516
+ agentDir,
497
517
  linkPath,
498
- mode,
518
+ mode: mode === 'junction' ? 'junction' : 'relative',
499
519
  createdAt: nowIso(),
500
520
  });
501
521
  });
502
522
  }
503
523
 
524
+ /** cwd → 项目根(最近含 .git 的祖先)。拿不到就返回 null:不能凭空造 project 扫描根。 */
525
+ function projectRootOf(cwd) {
526
+ if (!cwd || typeof cwd !== 'string') return null;
527
+ const start = path.resolve(cwd);
528
+ if (!fs.existsSync(start)) return null;
529
+ return findProjectRoot(start, fs.existsSync);
530
+ }
531
+
532
+ /**
533
+ * 本地策略隐藏的 canonical 目录:`enabled === false` 且目录真实存在。
534
+ * 与 provider 的条件发候选对齐——只有 canonical 是 native winner 时才会命中。
535
+ */
536
+ function policyHiddenPathsOf() {
537
+ const hidden = new Set();
538
+ if (!paths.canonicalScanned) return hidden;
539
+ for (const install of state().installs || []) {
540
+ if (!install || install.enabled !== false) continue;
541
+ const dir = path.join(paths.canonical, install.name);
542
+ try {
543
+ if (fs.statSync(dir).isDirectory()) hidden.add(dir);
544
+ } catch {
545
+ /* 真身不在:不隐藏(provider 也不会发候选) */
546
+ }
547
+ }
548
+ return hidden;
549
+ }
550
+
551
+ /** 组装一次可见性视图:扫根 + 合并宿主 snapshot 的 global 层 + 本地策略。 */
552
+ async function visibilityFor(cwd, { withHost = true } = {}) {
553
+ const projectRoot = projectRootOf(cwd);
554
+ const roots = listRoots({ paths, projectRoot });
555
+ const host = withHost ? await visibleSkills(cwd) : { skills: [], complete: true };
556
+ const resolved = resolveVisibility({
557
+ roots,
558
+ hostSkills: host.skills,
559
+ policyHiddenPaths: policyHiddenPathsOf(),
560
+ });
561
+ return { ...resolved, hostComplete: host.complete !== false, projectRoot };
562
+ }
563
+
504
564
  async function visibleSkills(cwd) {
505
565
  try {
506
566
  const snapshot = await visibleProvider(cwd);
@@ -531,8 +591,6 @@ export function createService({
531
591
  const ref = lockEntry?.ref || null;
532
592
  const subscriptionId = source ? snapshotId({ source, ref, subpath: null }) : null;
533
593
  const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
534
- const dshLink = path.join(paths.dshGlobalSkills, name);
535
- const live = inspectTarget({ linkPath: dshLink, canonicalPath });
536
594
  return {
537
595
  name,
538
596
  subscriptionId,
@@ -540,8 +598,6 @@ export function createService({
540
598
  ref,
541
599
  skillPath: lockEntry?.skillPath || `${name}/SKILL.md`,
542
600
  installedHash,
543
- // 之前由 CLI/别人建的 DSH 链,接管后要记成自己的(否则账本里一直是 managed-external)
544
- link: live.state === 'managed-external' ? { target: 'dsh-global', agentDir: paths.dshGlobalSkills, linkPath: dshLink } : null,
545
601
  };
546
602
  }
547
603
 
@@ -570,22 +626,11 @@ export function createService({
570
626
  installedHash: record.installedHash,
571
627
  installedAt: nowIso(),
572
628
  updatedAt: nowIso(),
573
- mode: 'on',
574
- scopeProjects: [],
629
+ enabled: true,
575
630
  backup: null,
576
631
  adopted: true,
577
632
  });
578
633
  }
579
- if (record.link && !draft.links.some((item) => item.skill === record.name && item.target === 'dsh-global')) {
580
- draft.links.push({
581
- skill: record.name,
582
- target: record.link.target,
583
- agentDir: record.link.agentDir,
584
- linkPath: record.link.linkPath,
585
- mode: 'relative',
586
- createdAt: nowIso(),
587
- });
588
- }
589
634
  }
590
635
  }
591
636
 
@@ -621,27 +666,62 @@ export function createService({
621
666
  // 有写操作在跑时不要重读:否则会把队列里刚写完但尚未落盘的状态冲掉
622
667
  if (enqueue.pending() > 0) return store.state;
623
668
  store.load();
669
+ // v1 → v2 的文件侧迁移(删历史 DSH 链 / 剪悬空导出 / 补停用标记)。
670
+ // 幂等且带 manifest:中断后下次 load 接着做。
671
+ if (!store.readOnly && store.state.migration && store.state.migration.status !== 'done') {
672
+ try {
673
+ ensureMigrated({ paths, store, logger });
674
+ } catch (error) {
675
+ logger('state.migrate.failed', { message: error.message });
676
+ }
677
+ }
624
678
  return store.state;
625
679
  },
626
680
 
681
+ /** 供插件 apply 时主动跑一次迁移(不必等到第一个请求)。 */
682
+ ensureMigrated() {
683
+ return this.reloadState();
684
+ },
685
+
627
686
  async getState({ cwd, workspace } = {}) {
628
687
  const lock = readLock(paths);
629
688
  reconcileTracking(lock);
630
- const visible = await visibleSkills(cwd);
631
- const visibleNames = new Map(visible.skills.map((item) => [item.name, item.source || item.provider || 'unknown']));
632
- const ledger = buildLedger({ paths, state: state(), registry, lock, visibleNames });
689
+ const visible = await visibilityFor(cwd);
690
+ const ledger = buildLedger({ paths, state: state(), registry, lock, visibility: visible });
633
691
  const capabilities = nativeCapabilities();
692
+ const rowByName = new Map(ledger.rows.map((row) => [row.name, row]));
693
+ const visibleList = [];
694
+ for (const [name, item] of visible.byName) {
695
+ if (!item.visible) continue;
696
+ const row = rowByName.get(name);
697
+ visibleList.push({
698
+ name,
699
+ description: row?.description ?? null,
700
+ source: item.winnerSource,
701
+ provider: item.hostOnly ? 'host' : 'filesystem',
702
+ path: item.path,
703
+ nameConflict: Boolean(item.nameConflict),
704
+ shadowedBy: item.shadowedBy || [],
705
+ });
706
+ }
634
707
  return {
635
708
  ok: true,
636
709
  readOnly: store.readOnly,
637
710
  readOnlyReason: store.problem,
711
+ // direct:canonical 就是 rank 500 的扫描根,装完即可见;
712
+ // unscanned:canonical 被挪到扫描根之外,本插件不提供可见性兜底(/diagnostics 会报出来)
713
+ mode: paths.canonicalScanned ? 'direct' : 'unscanned',
638
714
  settings: state().settings,
639
715
  agents: { all: registry.all(), detected: detectedTargets() },
640
716
  subscriptions: state().subscriptions,
641
717
  workspaces: await workspaceList(),
642
718
  installs: ledger.rows,
643
- visible: visible.skills,
644
- visibleComplete: visible.complete !== false,
719
+ exports: state().exports,
720
+ legacyShadows: state().legacyShadows,
721
+ roots: visible.roots,
722
+ visible: visibleList,
723
+ visibleComplete: visible.hostComplete && visible.complete,
724
+ visibleErrors: visible.errors,
645
725
  visibleOnly: ledger.visibleOnly,
646
726
  checks: state().checks,
647
727
  cli: capabilities,
@@ -649,9 +729,11 @@ export function createService({
649
729
  canonical: paths.canonical,
650
730
  lock: paths.lockFile,
651
731
  dshGlobal: paths.dshGlobalSkills,
732
+ userAgents: paths.userAgentsSkills,
652
733
  state: paths.stateFile,
653
734
  },
654
735
  cwd: cwd || null,
736
+ projectRoot: visible.projectRoot,
655
737
  workspace: workspace || null,
656
738
  scannedAt: ledger.scannedAt,
657
739
  };
@@ -661,7 +743,9 @@ export function createService({
661
743
  const capabilities = nativeCapabilities();
662
744
  const lock = readLock(paths);
663
745
  reconcileTracking(lock);
664
- const rows = buildLedger({ paths, state: state(), registry, lock, visibleNames: new Map() }).rows;
746
+ // 诊断不传 cwd:只看 user 两个根,避免把某个项目目录的副本算成全局事实
747
+ const visible = await visibilityFor(null);
748
+ const rows = buildLedger({ paths, state: state(), registry, lock, visibility: visible }).rows;
665
749
  const mismatched = rows
666
750
  .filter((row) => row.managed && row.lockHash)
667
751
  .map((row) => ({
@@ -671,15 +755,60 @@ export function createService({
671
755
  paired: row.sourceType === 'github' ? /^[0-9a-f]{40}$/.test(row.lockHash) : true,
672
756
  }))
673
757
  .filter((item) => !item.paired);
758
+
759
+ const legacyShadows = state().legacyShadows || [];
760
+ const exportsRows = state().exports || [];
761
+ const policy = policyStatus() || {};
762
+ const drift = {
763
+ /** 账上有链、磁盘上没有(迁移会收缩,运行期只报告) */
764
+ exportsMissing: exportsRows.filter((row) => !row.linkPath || !fs.existsSync(row.linkPath)),
765
+ /** 迁移删不掉的旧停用桩;用户可手动处理,或在控制台点启用时再试 */
766
+ legacyShadows,
767
+ /** 说不该出现的组合:界面停用、模型仍可见(更靠前的同名副本赢了) */
768
+ disabledButVisible: state().installs.filter((item) => item.enabled === false && visible.byName.get(item.name)?.visible),
769
+ /** 界面启用、模型不可见(真身缺失,或同名停用技能排在前面);自己声明 manual-only 的不算异常 */
770
+ enabledButHidden: state().installs.filter((item) => {
771
+ if (item.enabled === false) return false;
772
+ const view = visible.byName.get(item.name);
773
+ if (view?.visible) return false;
774
+ const canonical = path.join(paths.canonical, item.name);
775
+ const selfHidden = Boolean(view?.foreignHidden && view.winnerPath
776
+ && path.resolve(view.winnerPath) === path.resolve(canonical));
777
+ return !selfHidden;
778
+ }),
779
+ };
780
+
674
781
  return {
675
782
  ok: true,
676
783
  cli: capabilities,
784
+ mode: paths.canonicalScanned ? 'direct' : 'unscanned',
677
785
  lock: { present: lock.present, version: lock.version, entries: Object.keys(lock.skills || {}).length, problem: lock.problem },
678
- canonical: { path: paths.canonical, entries: rows.length },
786
+ canonical: { path: paths.canonical, entries: rows.length, scanned: paths.canonicalScanned },
679
787
  paths,
788
+ roots: visible.roots,
789
+ // 环境层面的硬限制:canonical 不在任何 DSH 扫描根里时,装好的技能对 DSH 不可见。
790
+ // 这不是「还能补救」的状态,所以在诊断里明说,而不是静默假装支持。
791
+ warnings: paths.canonicalScanned
792
+ ? []
793
+ : [{
794
+ code: 'canonical-not-scanned',
795
+ path: paths.canonical,
796
+ message: `canonical ${paths.canonical} 不在 DSH 的扫描根里(DSH_AGENTS_HOME 被改过?),本插件不保证技能对 DSH 可见`,
797
+ }],
680
798
  hashMismatch: mismatched,
799
+ // 宿主 provider 能力与每个 live agent scope 的注册结果。
800
+ // 盲区(customSkillDirs / plugin runtime 技能)如实标出,不假装支持。
801
+ policy: {
802
+ available: Boolean(policy.available),
803
+ agents: policy.registered || 0,
804
+ liveAgents: policy.liveAgents || 0,
805
+ errors: policy.errors || [],
806
+ blindSpots: ['custom-dirs', 'plugin-skills'],
807
+ },
681
808
  installs: state().installs.length,
682
- links: state().links.length,
809
+ exports: exportsRows.length,
810
+ legacyShadows: legacyShadows.length,
811
+ drift,
683
812
  subscriptions: state().subscriptions.length,
684
813
  backups: state().installs.filter((item) => item.backup).length,
685
814
  };
@@ -687,18 +816,26 @@ export function createService({
687
816
 
688
817
  async setSettings(patch) {
689
818
  assertWritable();
690
- const detected = new Set(detectedTargets().filter((target) => target.kind === 'agent').map((target) => target.target));
819
+ // 读共享根的 agent 不进设置:存下来也永远不会建链(exporter 会跳过),
820
+ // 留着只会让「新安装时自动导出 · N」数出一个人人都看不见的 1。
821
+ const detected = new Set(detectedTargets().filter((target) => needsExport(target)).map((target) => target.target));
822
+ const onlyDetected = (list) => [
823
+ ...new Set((Array.isArray(list) ? list : []).filter((target) => typeof target === 'string' && detected.has(target))),
824
+ ];
691
825
  store.update((draft) => {
692
- if (patch.autoLinkTargets === null) {
693
- draft.settings.autoLinkTargets = null;
826
+ if (patch.exportTargets === null) {
827
+ draft.settings.exportTargets = [];
828
+ } else if (Array.isArray(patch.exportTargets)) {
829
+ draft.settings.exportTargets = onlyDetected(patch.exportTargets);
830
+ } else if (patch.autoLinkTargets === null) {
831
+ // v1 客户端兼容:`null` 原本表示「所有探测到的 Agent」,语义照搬成显式全导出
832
+ draft.settings.exportTargets = onlyDetected(detectedTargets().map((item) => item.target));
694
833
  } else if (Array.isArray(patch.autoLinkTargets)) {
695
- // 只接受当前探测到的 Agent,避免把任意 target 字符串写进设置
696
- draft.settings.autoLinkTargets = [
697
- ...new Set(patch.autoLinkTargets.filter((target) => typeof target === 'string' && detected.has(target))),
698
- ];
834
+ draft.settings.exportTargets = onlyDetected(patch.autoLinkTargets);
699
835
  } else if (typeof patch.autoLinkAgents === 'boolean') {
700
- // 旧客户端兼容:true = 所有探测到的 Agent
701
- draft.settings.autoLinkTargets = patch.autoLinkAgents ? null : [];
836
+ draft.settings.exportTargets = patch.autoLinkAgents
837
+ ? onlyDetected(detectedTargets().map((item) => item.target))
838
+ : [];
702
839
  }
703
840
  if (patch.placement === 'settings' || patch.placement === 'sidebar') {
704
841
  draft.settings.placement = patch.placement;
@@ -707,25 +844,38 @@ export function createService({
707
844
  return { ok: true, settings: state().settings };
708
845
  },
709
846
 
710
- async setLinks({ skill, targets = [], action = 'add', scopeProjects = [] }) {
847
+ async setExports({ skill, targets = [], action = 'add' }) {
848
+ assertSafeSkillName(skill);
711
849
  assertWritable();
712
- const install = store.findInstall(skill);
713
- if (!install && action !== 'add') throw new ServiceError('error.skills.not-managed', `${skill} 没有安装记录`);
850
+ const canonicalPath = path.join(paths.canonical, skill);
851
+ const exists = fs.existsSync(canonicalPath);
714
852
  if (action === 'remove') {
853
+ const rows = state().exports.filter(
854
+ (item) => item.skill === skill && (targets.length === 0 || targets.includes(item.target)),
855
+ );
715
856
  const removed = [];
716
- for (const target of targets) {
717
- const resolved = registry.resolve(target);
718
- const result = removeLink({ linkPath: path.join(resolved.dir, skill), canonicalPath: path.join(paths.canonical, skill) });
719
- if (result.removed) removed.push({ target, linkPath: path.join(resolved.dir, skill) });
857
+ const failed = [];
858
+ for (const row of rows) {
859
+ const outcome = unexportSkill({ linkPath: row.linkPath, canonicalPath, name: skill, allowDangling: !exists });
860
+ if (outcome.removed || outcome.reason === 'absent') removed.push(row.target);
861
+ else failed.push({ target: row.target, reason: outcome.reason || null, linkPath: row.linkPath });
720
862
  }
721
- if (!store.readOnly) {
722
- store.update((draft) => {
723
- draft.links = draft.links.filter((link) => !(link.skill === skill && targets.includes(link.target)));
724
- });
725
- }
726
- return { ok: true, removed };
863
+ // 只清「真的删掉 / 本来就不存在」的记录:删链失败仍记账会把漂移重新长出来
864
+ store.update((draft) => {
865
+ draft.exports = draft.exports.filter((item) => !(item.skill === skill && removed.includes(item.target)));
866
+ });
867
+ logger('exports.remove', { skill, removed, failed: failed.length });
868
+ return { ok: true, action: 'remove', removed, failed };
727
869
  }
728
- return { ok: true, ...linkSkill(skill, targets, { scopeProjects }) };
870
+ if (!exists) throw new ServiceError('error.skills.not-installed', `${skill} 不在 canonical 里`);
871
+ const result = exportSkill(skill, targets);
872
+ logger('exports.add', { skill, created: result.created.length, skipped: result.skipped.length, failed: result.failed.length });
873
+ return { ok: true, action: 'add', ...result };
874
+ },
875
+
876
+ /** @deprecated v2 起叫 `setExports`;`/links` 路由别名仍指到这里 */
877
+ async setLinks(args) {
878
+ return this.setExports(args);
729
879
  },
730
880
 
731
881
  /**
@@ -802,49 +952,125 @@ export function createService({
802
952
  };
803
953
  },
804
954
 
805
- async setMode({ name, mode }) {
955
+ /**
956
+ * 停用 = 只写 state(由 policy provider 覆盖 canonical 候选);启用 = 写 state 并清掉
957
+ * 可能残留的 legacy 停用桩。
958
+ *
959
+ * 四条不变量:
960
+ * - 不写/删 `~/.dsh/skills`(唯一例外:清理 旧版迁移残桩),不碰 canonical/lock;
961
+ * - 宿主没有 provider 能力(或所有 live agent 都注册失败)时 409,绝不写
962
+ * 「界面说停用、模型照样看见」的状态;
963
+ * - 项目级启停已移除(400):项目副本优先,停用 canonical 不会隐藏项目副本;
964
+ * - 已经 on/off 时返回 `already`,不重复写 state。
965
+ */
966
+ async setMode({ name, mode, scope = 'user', projectRoot = null }) {
806
967
  assertSafeSkillName(name);
807
968
  assertWritable();
808
969
  if (mode !== 'on' && mode !== 'off') throw new ServiceError('error.skills.bad-mode', 'mode 只能是 on / off');
970
+ if (projectRoot || scope !== 'user') {
971
+ throw new ServiceError(
972
+ 'error.skills.scope-unsupported',
973
+ '项目级启停已移除;项目副本优先,停用 canonical 不会隐藏项目副本',
974
+ { scope, projectRoot },
975
+ 400,
976
+ );
977
+ }
809
978
  const install = store.findInstall(name);
810
979
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
811
980
  return enqueue(`mode:${name}`, async () => {
812
- const scopeProjects = install.scopeProjects || [];
981
+ const canonicalPath = path.join(paths.canonical, name);
982
+ // 在队列里重新读一次:并发两次 off/on 时不能拿入队前的快照判断「已经如此」。
983
+ const current = store.findInstall(name) || install;
984
+ const already = mode === 'off' ? current.enabled === false : current.enabled !== false;
985
+
813
986
  if (mode === 'off') {
814
- // 只摘 DSH 侧链接(全局链 + 项目链),其他 Agent 与文件一律不动
815
- const dshTargets = ['dsh-global', ...scopeProjects.map((root) => `project:${root}`)];
816
- const removed = [];
817
- for (const target of dshTargets) {
818
- const resolved = registry.resolve(target);
819
- const result = removeLink({
820
- linkPath: path.join(resolved.dir, name),
821
- canonicalPath: path.join(paths.canonical, name),
822
- force: true,
987
+ if (already) return { ok: true, mode: 'off', enabled: false, policy: 'provider', already: true };
988
+ if (!fs.existsSync(canonicalPath)) {
989
+ if (fs.existsSync(`${canonicalPath}.md`)) {
990
+ throw new ServiceError(
991
+ 'error.skills.unsupported',
992
+ `${name} 是平铺 .md 技能,本期不支持启停`,
993
+ { name, kind: 'file' },
994
+ 400,
995
+ );
996
+ }
997
+ throw new ServiceError('error.skills.not-installed', `${name} 不在 canonical 里,没有可停用的真身`);
998
+ }
999
+ let isDir = false;
1000
+ try {
1001
+ isDir = fs.statSync(canonicalPath).isDirectory();
1002
+ } catch {
1003
+ isDir = false;
1004
+ }
1005
+ if (!isDir) {
1006
+ throw new ServiceError(
1007
+ 'error.skills.unsupported',
1008
+ `${name} 不是目录型技能,本期不支持启停`,
1009
+ { name, kind: 'file' },
1010
+ 400,
1011
+ );
1012
+ }
1013
+ const status = policyStatus() || {};
1014
+ if (!status.available || (status.liveAgents > 0 && status.registered === 0)) {
1015
+ logger('mode.off.rejected', {
1016
+ name,
1017
+ available: Boolean(status.available),
1018
+ liveAgents: status.liveAgents || 0,
1019
+ registered: status.registered || 0,
823
1020
  });
824
- if (result.removed) removed.push(target);
1021
+ throw new ServiceError(
1022
+ 'error.skills.policy-unavailable',
1023
+ '宿主没有可用的 skill provider 注册接口,停用无法保证生效',
1024
+ {
1025
+ available: Boolean(status.available),
1026
+ liveAgents: status.liveAgents || 0,
1027
+ registered: status.registered || 0,
1028
+ errors: status.errors || [],
1029
+ },
1030
+ 409,
1031
+ );
825
1032
  }
826
1033
  store.update((draft) => {
827
1034
  const row = draft.installs.find((item) => item.name === name);
828
- if (row) row.mode = 'off';
829
- draft.links = draft.links.filter(
830
- (link) => !(link.skill === name && dshTargets.includes(link.target)),
831
- );
1035
+ if (row) row.enabled = false;
832
1036
  });
833
- logger('mode.off', { name, removed });
834
- return { ok: true, mode: 'off', removed };
1037
+ await invalidatePolicies();
1038
+ logger('mode.off', { name, policy: 'provider' });
1039
+ return { ok: true, mode: 'off', enabled: false, policy: 'provider' };
835
1040
  }
836
1041
 
837
- const result = linkSkill(name, ['dsh-global'], { scopeProjects });
1042
+ // 启用:先清掉 旧版留下的旧桩(只认本插件 marker);删不掉就不改 enabled
1043
+ const legacy = (state().legacyShadows || []).filter((row) => row.skill === name);
1044
+ const removed = [];
1045
+ const failed = [];
1046
+ for (const row of legacy) {
1047
+ const outcome = removeShadow({ stubPath: row.stubPath, name: row.skill });
1048
+ if (outcome.removed || outcome.reason === 'absent') removed.push(row.stubPath);
1049
+ else failed.push({ stubPath: row.stubPath, reason: outcome.reason || 'locked' });
1050
+ }
1051
+ if (failed.length > 0) {
1052
+ throw new ServiceError(
1053
+ 'error.skills.shadow-locked',
1054
+ `旧停用桩删不掉(${failed[0].reason}),技能仍是停用状态`,
1055
+ { failed },
1056
+ 409,
1057
+ );
1058
+ }
1059
+ if (already && removed.length === 0) {
1060
+ return { ok: true, mode: 'on', enabled: true, policy: 'provider', already: true };
1061
+ }
838
1062
  store.update((draft) => {
839
1063
  const row = draft.installs.find((item) => item.name === name);
840
- if (row) row.mode = 'on';
1064
+ if (row) row.enabled = true;
1065
+ draft.legacyShadows = (draft.legacyShadows || []).filter((item) => item.skill !== name);
841
1066
  });
842
- logger('mode.on', { name, created: result.created.length });
843
- return { ok: true, mode: 'on', ...result };
1067
+ await invalidatePolicies();
1068
+ logger('mode.on', { name, policy: 'provider', removedLegacy: removed.length });
1069
+ return { ok: true, mode: 'on', enabled: true, policy: 'provider', ...(already ? { already: true } : {}) };
844
1070
  });
845
1071
  },
846
1072
 
847
- async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
1073
+ async install({ source, subscriptionId, skills = [], commit, targets }) {
848
1074
  assertWritable();
849
1075
  await requireInstaller();
850
1076
  const requested = (skills || []).filter(Boolean);
@@ -918,8 +1144,6 @@ export function createService({
918
1144
  installedHash,
919
1145
  installedAt: recordedAt,
920
1146
  updatedAt: recordedAt,
921
- mode: 'on',
922
- scopeProjects: scopeProjects || [],
923
1147
  backup: item.backup
924
1148
  ? { key: item.backup, hash: item.backupHash || null, treeSha: item.backupTreeSha || null }
925
1149
  : null,
@@ -933,7 +1157,8 @@ export function createService({
933
1157
  installedAt: draft.installs[index].installedAt || record.installedAt,
934
1158
  };
935
1159
  } else {
936
- draft.installs.push(record);
1160
+ // 重新安装不改变启停意图:装之前停用的,装完仍然停用
1161
+ draft.installs.push({ ...record, enabled: true });
937
1162
  }
938
1163
  });
939
1164
  installed.push({
@@ -946,10 +1171,11 @@ export function createService({
946
1171
  });
947
1172
  }
948
1173
 
949
- const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
1174
+ // 导出是显式动作:默认一个都不建;请求里点名了才建
1175
+ const exportTargets = Array.isArray(targets) && targets.length > 0 ? targets : exportTargetList(state());
950
1176
  const linkResult = { created: [], skipped: [], failed: [] };
951
1177
  for (const item of installed) {
952
- const one = linkSkill(item.name, linkTargets, { scopeProjects });
1178
+ const one = exportSkill(item.name, exportTargets);
953
1179
  linkResult.created.push(...one.created);
954
1180
  linkResult.skipped.push(...one.skipped);
955
1181
  linkResult.failed.push(...one.failed);
@@ -976,11 +1202,26 @@ export function createService({
976
1202
  throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
977
1203
  }
978
1204
 
979
- // ① 体检:只动自己建的链接;真实目录/外来链接先拦下(前端弹窗依赖 detail.conflicts)
1205
+ // ① 体检:只动自己建的链接;真实目录/外来链接先拦下(前端弹窗依赖 detail.conflicts)。
1206
+ // 顺手记下「此刻确认指向 canonical 的自家链」——真身删掉之后就没法再验了。
980
1207
  const conflicts = [];
1208
+ const ownLinks = [];
1209
+ const legacyStubs = new Set((state().legacyShadows || []).map((item) => item.stubPath));
1210
+ // 只体检「记账里有导出」的目标:别的 Agent 自己放的同名目录/链与我无关,
1211
+ // 删除 canonical 也不会连带删掉它(例如 OpenCode 指向另一个仓库的 handoff)。
1212
+ const recordedTargets = new Set(
1213
+ (state().exports || []).filter((item) => item.skill === name).map((item) => item.target),
1214
+ );
981
1215
  for (const target of detectedTargets()) {
982
1216
  const linkPath = path.join(target.dir, name);
1217
+ // legacy 停用桩也是「真实目录」,但它就是我们要清掉的东西。
1218
+ // 只跳过我方 marker 的真实目录:同路径若是软链,仍要交给体检判定,不能误删。
1219
+ if (legacyStubs.has(linkPath) && isLegacyStubDir(linkPath)) continue;
983
1220
  const live = inspectTarget({ linkPath, canonicalPath });
1221
+ if (live.state === 'managed' || live.state === 'managed-external') {
1222
+ ownLinks.push({ target: target.target, linkPath, state: live.state });
1223
+ }
1224
+ if (!recordedTargets.has(target.target)) continue;
984
1225
  if (live.state === 'real') conflicts.push({ target: target.target, label: target.label, linkPath, reason: 'real' });
985
1226
  if (live.state === 'foreign-link' && !live.broken) {
986
1227
  conflicts.push({ target: target.target, label: target.label, linkPath, reason: 'foreign-link' });
@@ -989,7 +1230,7 @@ export function createService({
989
1230
  if (conflicts.length > 0) {
990
1231
  throw new ServiceError(
991
1232
  'error.skills.remove-conflict',
992
- `${conflicts.map((item) => item.label).join('、')} 里有不是本插件创建的 ${name},继续会连带删掉它`,
1233
+ `${conflicts.map((item) => item.label).join('、')} 里的 ${name} 不是本插件创建的,删除已停下;先处理它再重试`,
993
1234
  { conflicts },
994
1235
  );
995
1236
  }
@@ -1024,22 +1265,34 @@ export function createService({
1024
1265
  };
1025
1266
  }
1026
1267
 
1027
- // ④ 清自家链接
1268
+ // ④ 清自家导出链:只删 ① 里验证过「指向 canonical」的那些,不按 force 乱扫
1028
1269
  const cleaned = [];
1029
- for (const link of state().links.filter((item) => item.skill === name)) {
1030
- const outcome = removeLink({ linkPath: link.linkPath, canonicalPath, force: true });
1031
- if (outcome.removed) cleaned.push(link.linkPath);
1270
+ for (const item of ownLinks) {
1271
+ const outcome = removeLink({ linkPath: item.linkPath, canonicalPath, force: true });
1272
+ if (outcome.removed) cleaned.push(item.linkPath);
1273
+ }
1274
+
1275
+ // ⑤ 清 legacy 停用桩:不带它走,删除后会留一个「看不见也不存在」的幽灵目录。
1276
+ // 失败只记报告,不阻塞删除(真身已经删了,强留桩没有意义)。
1277
+ const legacyResults = [];
1278
+ for (const row of (state().legacyShadows || []).filter((item) => item.skill === name)) {
1279
+ const outcome = removeShadow({ stubPath: row.stubPath, name });
1280
+ legacyResults.push({ stubPath: row.stubPath, ...outcome });
1281
+ if (!outcome.removed && outcome.reason !== 'absent') {
1282
+ logger('remove.legacy-shadow.kept', { name, stubPath: row.stubPath, reason: outcome.reason || null });
1283
+ }
1032
1284
  }
1033
1285
 
1034
- // ⑤ 清记账
1286
+ // ⑥ 清记账
1035
1287
  store.update((draft) => {
1036
1288
  draft.installs = draft.installs.filter((item) => item.name !== name);
1037
- draft.links = draft.links.filter((item) => item.skill !== name);
1289
+ draft.exports = draft.exports.filter((item) => item.skill !== name);
1290
+ draft.legacyShadows = (draft.legacyShadows || []).filter((item) => item.skill !== name);
1038
1291
  delete draft.checks[name];
1039
1292
  });
1040
1293
 
1041
- logger('remove', { name, trash: trash.key });
1042
- return { ok: true, name, trash: trash.key, removedLinks: cleaned };
1294
+ logger('remove', { name, trash: trash.key, legacyShadows: legacyResults.length });
1295
+ return { ok: true, name, trash: trash.key, removedLinks: cleaned, removedLegacyShadows: legacyResults };
1043
1296
  });
1044
1297
  },
1045
1298
 
@@ -1471,8 +1724,9 @@ export function createService({
1471
1724
  };
1472
1725
  });
1473
1726
 
1474
- // 复验软链:更新重建的是目录,链接理论上不受影响,断一条就补一条
1475
- const relinked = linkSkill(name, ['dsh-global'], { scopeProjects: install.scopeProjects || [] });
1727
+ // 更新重建的是 canonical 目录:导出链按原目标复验补建。`enabled` 保持不变——
1728
+ // 停用期间更新,模型仍然看不到新正文,直到启用(不再需要刷新任何桩的 hash)。
1729
+ const relinked = exportSkill(name, exportTargetsFor(name));
1476
1730
  logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: item.backup || null });
1477
1731
  return {
1478
1732
  ok: true,