@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.
- package/README.md +55 -65
- package/client.js +457 -457
- package/cordis.patch.yml +2 -1
- package/lib/agent-table.json +133 -64
- package/lib/core/agents.js +68 -9
- package/lib/core/exporter.js +137 -0
- package/lib/core/inventory.js +73 -39
- package/lib/core/migrations.js +178 -0
- package/lib/core/paths.js +13 -0
- package/lib/core/policy.js +373 -0
- package/lib/core/remote.js +3 -1
- package/lib/core/roots.js +182 -0
- package/lib/core/service.js +391 -137
- package/lib/core/shadow.js +195 -0
- package/lib/core/skill-md.js +23 -4
- package/lib/core/state.js +293 -42
- package/lib/core/visibility.js +145 -0
- package/lib/index.js +27 -0
- package/lib/routes.js +13 -3
- package/package.json +3 -2
package/lib/core/service.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
504
|
+
/**
|
|
505
|
+
* 记账:只在链**确实存在且指向 canonical** 时写。
|
|
506
|
+
* `created: false` 但没记账 = 链是上一次运行/别人建的、我们刚接管,也要补记。
|
|
507
|
+
*/
|
|
508
|
+
function recordExport(name, { target, agentDir, linkPath, mode }) {
|
|
488
509
|
if (store.readOnly) return;
|
|
489
|
-
|
|
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.
|
|
512
|
+
if (draft.exports.some((item) => item.skill === name && item.target === target)) return;
|
|
513
|
+
draft.exports.push({
|
|
494
514
|
skill: name,
|
|
495
|
-
target
|
|
496
|
-
agentDir
|
|
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
|
-
|
|
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
|
|
631
|
-
const
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
693
|
-
draft.settings.
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
|
847
|
+
async setExports({ skill, targets = [], action = 'add' }) {
|
|
848
|
+
assertSafeSkillName(skill);
|
|
711
849
|
assertWritable();
|
|
712
|
-
const
|
|
713
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
if (
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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.
|
|
1064
|
+
if (row) row.enabled = true;
|
|
1065
|
+
draft.legacyShadows = (draft.legacyShadows || []).filter((item) => item.skill !== name);
|
|
841
1066
|
});
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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('、')}
|
|
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
|
|
1030
|
-
const outcome = removeLink({ linkPath:
|
|
1031
|
-
if (outcome.removed) cleaned.push(
|
|
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.
|
|
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
|
-
|
|
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,
|