dsh-plugin-tool-management 0.1.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/LICENSE +21 -0
- package/README.md +136 -0
- package/README_EN.md +136 -0
- package/cordis.patch.yml +31 -0
- package/lib/client.js +850 -0
- package/lib/index.js +1930 -0
- package/lib/skills/core.js +2612 -0
- package/lib/skills/readonly-discovery.js +153 -0
- package/lib/skills/service.js +417 -0
- package/package.json +71 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// 只读技能目录发现:允许目录链接,按发现路径过滤隐藏目录,用真实祖先路径终止循环。
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { resolve, join, relative, sep } from "node:path";
|
|
4
|
+
const MAX_DEPTH = 6;
|
|
5
|
+
const MAX_DIRECTORIES = 2000;
|
|
6
|
+
const MAX_ENTRIES = 20000;
|
|
7
|
+
const identity = (path) => process.platform === "win32" ? path.toLowerCase() : path;
|
|
8
|
+
/** 只读条目使用根下相对路径;点号表示根本身的 SKILL.md,不接受路径穿越。 */
|
|
9
|
+
export function validDiscoveryName(name) {
|
|
10
|
+
return (typeof name === "string" &&
|
|
11
|
+
(name === "." ||
|
|
12
|
+
(name.length <= 1024 &&
|
|
13
|
+
name
|
|
14
|
+
.split("/")
|
|
15
|
+
.every((part) => part.length > 0 &&
|
|
16
|
+
part.length <= 255 &&
|
|
17
|
+
!part.startsWith(".") &&
|
|
18
|
+
!/[\\:*?"<>|\0]/.test(part) &&
|
|
19
|
+
!/[. ]$/.test(part) &&
|
|
20
|
+
!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(part)))));
|
|
21
|
+
}
|
|
22
|
+
/** 只返回文件定位信息,不读取正文,供列表、详情及策略复用同一发现与去重规则。 */
|
|
23
|
+
export async function discoverReadonlyEntries(root) {
|
|
24
|
+
const rootPath = resolve(root);
|
|
25
|
+
const queue = [{ path: rootPath, depth: 0, ancestors: new Set() }];
|
|
26
|
+
const byName = new Map();
|
|
27
|
+
let directories = 0;
|
|
28
|
+
let entries = 0;
|
|
29
|
+
let exists = false;
|
|
30
|
+
let truncated = false;
|
|
31
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
32
|
+
if (directories >= MAX_DIRECTORIES || entries >= MAX_ENTRIES) {
|
|
33
|
+
truncated = true;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
const current = queue[cursor];
|
|
37
|
+
let realDirectory;
|
|
38
|
+
const items = [];
|
|
39
|
+
try {
|
|
40
|
+
realDirectory = await fs.realpath(current.path);
|
|
41
|
+
const key = identity(realDirectory);
|
|
42
|
+
if (current.ancestors.has(key))
|
|
43
|
+
continue;
|
|
44
|
+
// 普通 bundle 是叶子,在枚举前识别,避免资源文件耗尽整根预算。
|
|
45
|
+
// 根自身的 SKILL.md 可与其他技能并存,因此根目录继续发现。
|
|
46
|
+
if (current.depth > 0) {
|
|
47
|
+
const docPath = join(current.path, "SKILL.md");
|
|
48
|
+
let docStat;
|
|
49
|
+
try {
|
|
50
|
+
docStat = await fs.lstat(docPath);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== "ENOENT")
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (docStat?.isFile() && !docStat.isSymbolicLink()) {
|
|
57
|
+
const name = relative(rootPath, current.path).split(sep).join("/");
|
|
58
|
+
const realDocPath = await fs.realpath(docPath);
|
|
59
|
+
directories++;
|
|
60
|
+
byName.set(name, {
|
|
61
|
+
name,
|
|
62
|
+
kind: "bundle",
|
|
63
|
+
docPath,
|
|
64
|
+
entryPath: current.path,
|
|
65
|
+
realDocPath,
|
|
66
|
+
realEntryPath: realDirectory,
|
|
67
|
+
linked: identity(resolve(current.path)) !== identity(realDirectory),
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const directory = await fs.opendir(current.path);
|
|
73
|
+
if (current.depth === 0)
|
|
74
|
+
exists = true;
|
|
75
|
+
directories++;
|
|
76
|
+
for await (const item of directory) {
|
|
77
|
+
if (entries >= MAX_ENTRIES) {
|
|
78
|
+
truncated = true;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
entries++;
|
|
82
|
+
items.push(item);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
} // 失效链接或不可读目录不阻断其他来源。
|
|
88
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
89
|
+
const ancestors = new Set(current.ancestors).add(identity(realDirectory));
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
const path = join(current.path, item.name);
|
|
92
|
+
const name = relative(rootPath, path).split(sep).join("/");
|
|
93
|
+
if (!validDiscoveryName(name))
|
|
94
|
+
continue;
|
|
95
|
+
try {
|
|
96
|
+
const stat = await fs.lstat(path);
|
|
97
|
+
if (stat.isDirectory() || stat.isSymbolicLink()) {
|
|
98
|
+
if (identity(item.name) === "node_modules")
|
|
99
|
+
continue;
|
|
100
|
+
if (stat.isSymbolicLink() && !(await fs.stat(path)).isDirectory())
|
|
101
|
+
continue;
|
|
102
|
+
if (current.depth >= MAX_DEPTH) {
|
|
103
|
+
truncated = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// 待遍历队列也受目录预算约束,避免宽目录占用无界内存。
|
|
107
|
+
if (queue.length >= MAX_DIRECTORIES) {
|
|
108
|
+
truncated = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
queue.push({ path, depth: current.depth + 1, ancestors });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!stat.isFile())
|
|
115
|
+
continue;
|
|
116
|
+
const bundle = item.name === "SKILL.md";
|
|
117
|
+
// 保留既有顶层单文件技能兼容,不把嵌套 README 或参考文档识别成技能。
|
|
118
|
+
const flat = current.depth === 0 &&
|
|
119
|
+
item.name.toLowerCase().endsWith(".md") &&
|
|
120
|
+
item.name.toLowerCase() !== "skill.md";
|
|
121
|
+
if (!bundle && !flat)
|
|
122
|
+
continue;
|
|
123
|
+
const entryName = bundle
|
|
124
|
+
? relative(rootPath, current.path).split(sep).join("/") || "."
|
|
125
|
+
: item.name.slice(0, -3);
|
|
126
|
+
if (!validDiscoveryName(entryName))
|
|
127
|
+
continue;
|
|
128
|
+
const realDocPath = await fs.realpath(path);
|
|
129
|
+
const entryPath = bundle ? current.path : path;
|
|
130
|
+
const realEntryPath = bundle ? realDirectory : realDocPath;
|
|
131
|
+
if (!bundle && byName.has(entryName))
|
|
132
|
+
continue;
|
|
133
|
+
byName.set(entryName, {
|
|
134
|
+
name: entryName,
|
|
135
|
+
kind: bundle ? "bundle" : "flat",
|
|
136
|
+
docPath: path,
|
|
137
|
+
entryPath,
|
|
138
|
+
realDocPath,
|
|
139
|
+
realEntryPath,
|
|
140
|
+
linked: identity(resolve(entryPath)) !== identity(realEntryPath),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* 来源在扫描期间失效时跳过;加载前仍复验真实路径。 */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
exists,
|
|
150
|
+
entries: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
151
|
+
truncated,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 技能管理服务层
|
|
2
|
+
// 把 src/skills/core.js(移植自 dsh-skills-manager,Apache-2.0)的纯文件操作
|
|
3
|
+
// 适配成统一 op 协议:handler(args) → { ok:true, data }(core 业务失败原样透传
|
|
4
|
+
// { ok:false, error, code?, params? })。provider / agent-scope 注册与变更通知
|
|
5
|
+
// 沿用上游机制:全局层接管 + 每个活动 agent scope 内二次注册。
|
|
6
|
+
import { appendFile, rename, rm, stat } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { Worker } from 'node:worker_threads';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { state, setSkillEnabled, setSourceEnabled, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
|
|
13
|
+
const MAX_LOG_BYTES = 1 << 20;
|
|
14
|
+
const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
|
|
15
|
+
const message = (e) => String((e && e.message) || e);
|
|
16
|
+
/** 串行写日志,超过上限滚动为 .1;日志失败不阻塞主流程(移植自上游)。 */
|
|
17
|
+
function makeLog() {
|
|
18
|
+
const file = logPath();
|
|
19
|
+
let queue = Promise.resolve();
|
|
20
|
+
return async (event, detail) => {
|
|
21
|
+
queue = queue.then(async () => {
|
|
22
|
+
try {
|
|
23
|
+
const current = await stat(file).catch(() => null);
|
|
24
|
+
if (current && current.size >= MAX_LOG_BYTES) {
|
|
25
|
+
await rm(`${file}.1`, { force: true });
|
|
26
|
+
await rename(file, `${file}.1`);
|
|
27
|
+
}
|
|
28
|
+
await appendFile(file, `${JSON.stringify({ ts: new Date().toISOString(), event, detail })}\n`, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* 日志失败不阻塞主流程 */
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
await queue;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// ── 系统回收站(永久删除的最后一道保险) ──────────────────────────────────
|
|
38
|
+
function runQuietly(command, args) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawn(command, args, { stdio: 'ignore', windowsHide: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
child.on('error', () => resolve(false));
|
|
49
|
+
child.on('close', (code) => resolve(code === 0));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function psQuote(value) {
|
|
53
|
+
return "'" + String(value).replace(/'/g, "''") + "'";
|
|
54
|
+
}
|
|
55
|
+
/** Win → 回收站;macOS → ~/.Trash;Linux → gio trash。失败返回 false。 */
|
|
56
|
+
async function moveToSystemTrash(abs) {
|
|
57
|
+
try {
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
const script = [
|
|
60
|
+
'Add-Type -AssemblyName Microsoft.VisualBasic',
|
|
61
|
+
"[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(" + psQuote(abs) + ", 'OnlyErrorDialogs', 'SendToRecycleBin')",
|
|
62
|
+
].join('; ');
|
|
63
|
+
return await runQuietly('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
|
64
|
+
}
|
|
65
|
+
if (process.platform === 'darwin') {
|
|
66
|
+
await rename(abs, join(homedir(), '.Trash', basename(abs) + '-' + Date.now()));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return await runQuietly('gio', ['trash', abs]);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 永久删除回收站条目:先尝试系统回收站(用户仍可在系统里找回),
|
|
77
|
+
* 失败才落到 core 的硬删除 —— 让「永久删除」也不会静默丢数据。
|
|
78
|
+
*/
|
|
79
|
+
async function permanentlyDeleteTrashSafely(id, log) {
|
|
80
|
+
const clean = String(id || '').trim();
|
|
81
|
+
const root = trashRootPath();
|
|
82
|
+
const sep = root.indexOf('\\') >= 0 ? '\\' : '/';
|
|
83
|
+
const target = /^[A-Za-z0-9._-]{1,128}$/.test(clean) ? root + sep + clean : null;
|
|
84
|
+
if (target && target.indexOf(root + sep) === 0 && existsSync(target)) {
|
|
85
|
+
if (await moveToSystemTrash(target)) {
|
|
86
|
+
await log('trash-delete-system', { id: clean, path: target });
|
|
87
|
+
return { id: clean, method: 'system-trash' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return permanentlyDeleteTrash(clean, log);
|
|
91
|
+
}
|
|
92
|
+
// ── 文件监听:外部改动自动失效 ────────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* 监听技能来源目录,200ms 防抖后失效 provider 缓存:在编辑器或其他工具里
|
|
95
|
+
* 新增/修改/删除技能后,无需手动刷新即可出现在列表里。
|
|
96
|
+
*
|
|
97
|
+
* 为什么用 worker_threads:Windows 上 fs.watch(recursive) 的句柄在「被监听
|
|
98
|
+
* 目录被删除」时会静默卡死事件循环(不触发 error、unref 也无效)——宿主
|
|
99
|
+
* 进程将永远无法退出。把 watcher 放进独立的 worker 线程,主线程对 worker
|
|
100
|
+
* unref(),无论目录发生什么,宿主与测试进程都能正常收尾;worker 内部失败
|
|
101
|
+
* 也不影响主线程。目录不存在或平台不支持递归监听时静默跳过。
|
|
102
|
+
*/
|
|
103
|
+
function watchSkillRoots(paths, invalidate) {
|
|
104
|
+
const valid = paths.filter((dir) => dir && existsSync(dir));
|
|
105
|
+
if (!valid.length)
|
|
106
|
+
return () => { };
|
|
107
|
+
let timer = null;
|
|
108
|
+
const fire = () => {
|
|
109
|
+
if (timer)
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
timer = setTimeout(() => {
|
|
112
|
+
timer = null;
|
|
113
|
+
try {
|
|
114
|
+
invalidate();
|
|
115
|
+
}
|
|
116
|
+
catch { /* 失效失败不影响监听 */ }
|
|
117
|
+
}, 200);
|
|
118
|
+
};
|
|
119
|
+
let worker = null;
|
|
120
|
+
try {
|
|
121
|
+
// eval 模式内联 worker 代码:无需额外文件与构建步骤。worker 事件循环独立,
|
|
122
|
+
// 主线程 unref 后完全不参与宿主进程的退出判定。
|
|
123
|
+
const src = `
|
|
124
|
+
const { watch } = require('node:fs');
|
|
125
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
126
|
+
for (const dir of workerData.paths) {
|
|
127
|
+
try {
|
|
128
|
+
const w = watch(dir, { recursive: true, persistent: false }, () => {
|
|
129
|
+
try { parentPort.postMessage('change') } catch { /* worker 正在关闭 */ }
|
|
130
|
+
});
|
|
131
|
+
w.on('error', () => { /* 目录被移除等:忽略,不向主线程传播 */ });
|
|
132
|
+
} catch { /* 不可监听(权限/平台)时跳过该目录 */ }
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
worker = new Worker(src, { eval: true, workerData: { paths: valid } });
|
|
136
|
+
worker.unref();
|
|
137
|
+
worker.on('message', () => fire());
|
|
138
|
+
worker.on('error', () => { });
|
|
139
|
+
}
|
|
140
|
+
catch { /* worker 不可用时静默跳过,功能退化为依赖 op 全量扫描 */ }
|
|
141
|
+
return () => {
|
|
142
|
+
if (timer) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
timer = null;
|
|
145
|
+
}
|
|
146
|
+
if (worker) {
|
|
147
|
+
const w = worker;
|
|
148
|
+
worker = null;
|
|
149
|
+
try {
|
|
150
|
+
w.removeAllListeners();
|
|
151
|
+
w.terminate();
|
|
152
|
+
}
|
|
153
|
+
catch { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** 活动 Session 的宿主 cwd;项目 Skill 只从这些已知 workspace 推导。 */
|
|
158
|
+
export function activeSessionCwds(ctx) {
|
|
159
|
+
try {
|
|
160
|
+
const sessions = (typeof ctx.get === 'function' ? ctx.get('sessions') : undefined) || ctx.sessions;
|
|
161
|
+
const list = sessions && typeof sessions.list === 'function' ? sessions.list() : [];
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const cwds = [];
|
|
164
|
+
for (const session of Array.isArray(list) ? list : []) {
|
|
165
|
+
const cwd = session && session.header && session.header.cwd;
|
|
166
|
+
if (typeof cwd !== 'string' || cwd.trim() === '')
|
|
167
|
+
continue;
|
|
168
|
+
const trimmed = cwd.trim();
|
|
169
|
+
if (seen.has(trimmed))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(trimmed);
|
|
172
|
+
cwds.push(trimmed);
|
|
173
|
+
}
|
|
174
|
+
return cwds;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** manager provider:以 core 的候选/正文接口接管四个来源(含启停策略)。 */
|
|
181
|
+
function externalSkillProvider(control, invalidators) {
|
|
182
|
+
if (control && typeof control.invalidate === 'function')
|
|
183
|
+
invalidators.add(control.invalidate);
|
|
184
|
+
if (control && control.signal && typeof control.signal.addEventListener === 'function') {
|
|
185
|
+
control.signal.addEventListener('abort', () => { invalidators.delete(control.invalidate); }, { once: true });
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
name: PROVIDER_NAME,
|
|
189
|
+
list: async (options) => listProviderCandidates(options),
|
|
190
|
+
get: async (candidate, options) => getProviderSkill(candidate, options),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 在每个活动 agent 的 scope 内注册同一 provider。
|
|
195
|
+
* 用户级技能由 agent-preset 的 scoped 层解析;只有在 agent 自己的层里注册,
|
|
196
|
+
* 候选 rank 覆盖才能对它们生效(详见上游 registerAgentSkillProviders)。
|
|
197
|
+
*/
|
|
198
|
+
function registerAgentSkillProviders(ctx, invalidators) {
|
|
199
|
+
const on = ctx && ctx.on;
|
|
200
|
+
if (typeof on !== 'function')
|
|
201
|
+
return () => { };
|
|
202
|
+
const registrations = new Map();
|
|
203
|
+
const skillsOf = (agent) => {
|
|
204
|
+
const agentCtx = agent && agent.ctx;
|
|
205
|
+
if (!agentCtx)
|
|
206
|
+
return null;
|
|
207
|
+
const skills = typeof agentCtx.get === 'function' ? agentCtx.get('skills') : agentCtx.skills;
|
|
208
|
+
return skills && typeof skills.registerProvider === 'function' ? skills : null;
|
|
209
|
+
};
|
|
210
|
+
const install = (agent) => {
|
|
211
|
+
if (!agent || agent.id == null || registrations.has(agent.id))
|
|
212
|
+
return;
|
|
213
|
+
const skills = skillsOf(agent);
|
|
214
|
+
if (!skills)
|
|
215
|
+
return;
|
|
216
|
+
try {
|
|
217
|
+
const dispose = skills.registerProvider((control) => externalSkillProvider(control, invalidators));
|
|
218
|
+
registrations.set(agent.id, typeof dispose === 'function' ? dispose : () => { });
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
console.error('[dsh-plugin-tool-management] agent-scoped skills provider registration failed:', message(e));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const uninstall = (agent) => {
|
|
225
|
+
const id = agent && agent.id;
|
|
226
|
+
if (id == null)
|
|
227
|
+
return;
|
|
228
|
+
const dispose = registrations.get(id);
|
|
229
|
+
registrations.delete(id);
|
|
230
|
+
if (typeof dispose === 'function') {
|
|
231
|
+
try {
|
|
232
|
+
dispose();
|
|
233
|
+
}
|
|
234
|
+
catch { /* ignore */ }
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const stopCreated = on('agent/created', (payload) => install(payload && payload.agent));
|
|
238
|
+
const stopDisposed = on('agent/disposed', (payload) => uninstall(payload && payload.agent));
|
|
239
|
+
try {
|
|
240
|
+
const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined;
|
|
241
|
+
if (agents && typeof agents.list === 'function') {
|
|
242
|
+
for (const agent of agents.list())
|
|
243
|
+
install(agent);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch { /* agents 服务不可用 → 仅靠事件 */ }
|
|
247
|
+
return () => {
|
|
248
|
+
if (typeof stopCreated === 'function')
|
|
249
|
+
stopCreated();
|
|
250
|
+
if (typeof stopDisposed === 'function')
|
|
251
|
+
stopDisposed();
|
|
252
|
+
for (const dispose of registrations.values()) {
|
|
253
|
+
try {
|
|
254
|
+
dispose();
|
|
255
|
+
}
|
|
256
|
+
catch { /* ignore */ }
|
|
257
|
+
}
|
|
258
|
+
registrations.clear();
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** 文件写入后刷新技能目录,并通知 Web 端重拉 `/` 菜单缓存(移植自上游)。 */
|
|
262
|
+
function notifyChatCatalog(ctx, invalidateSkills) {
|
|
263
|
+
try {
|
|
264
|
+
invalidateSkills();
|
|
265
|
+
}
|
|
266
|
+
catch { /* ignore */ }
|
|
267
|
+
try {
|
|
268
|
+
if (typeof ctx.emit === 'function')
|
|
269
|
+
ctx.emit('commands/change');
|
|
270
|
+
}
|
|
271
|
+
catch { /* ignore */ }
|
|
272
|
+
try {
|
|
273
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined;
|
|
274
|
+
const list = sessions && typeof sessions.list === 'function' ? sessions.list() : [];
|
|
275
|
+
for (const session of list) {
|
|
276
|
+
const id = session && (session.id ?? (session.header && session.header.id));
|
|
277
|
+
const preset = session && session.header && typeof session.header.agentPreset === 'string' ? session.header.agentPreset : undefined;
|
|
278
|
+
if (id == null || !preset || typeof ctx.emit !== 'function')
|
|
279
|
+
continue;
|
|
280
|
+
ctx.emit('agent-preset/selected', id, preset);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch { /* 斜杠菜单刷新失败不影响已完成的操作 */ }
|
|
284
|
+
}
|
|
285
|
+
export function createSkillsService(ctx) {
|
|
286
|
+
const log = makeLog();
|
|
287
|
+
const roots = userRoots();
|
|
288
|
+
const rootByKey = Object.fromEntries(roots.map((root) => [root.key, root]));
|
|
289
|
+
const projectOptions = () => ({ projectCwds: activeSessionCwds(ctx) });
|
|
290
|
+
const requestRoot = async (key) => {
|
|
291
|
+
if (rootByKey[key])
|
|
292
|
+
return rootByKey[key];
|
|
293
|
+
// 自定义来源不在静态 root 表里:从状态文件的 customRoots 解析 definition。
|
|
294
|
+
try {
|
|
295
|
+
const policy = await readManagerState();
|
|
296
|
+
const custom = customRootsFromState(policy.state).find((root) => root.key === key);
|
|
297
|
+
if (custom)
|
|
298
|
+
return custom;
|
|
299
|
+
}
|
|
300
|
+
catch { /* 状态不可用时按未知来源处理 */ }
|
|
301
|
+
const list = await projectRoots(projectOptions().projectCwds);
|
|
302
|
+
return list.find((root) => root.key === key);
|
|
303
|
+
};
|
|
304
|
+
const providerInvalidators = new Set();
|
|
305
|
+
const invalidateSkills = () => {
|
|
306
|
+
for (const invalidate of providerInvalidators) {
|
|
307
|
+
try {
|
|
308
|
+
invalidate();
|
|
309
|
+
}
|
|
310
|
+
catch { /* 单个失效不影响其余 */ }
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
// state() 全量扫描 4 个用户根(递归,上限 2000 目录)+ 活动项目根,并读取
|
|
314
|
+
// 全部 SKILL.md 正文;UI 默认每 5s 轮询一次。加 1s TTL 缓存吸收密集轮询,
|
|
315
|
+
// 任何写操作成功后立即失效(afterWrite),保证写后读到的总是新状态。
|
|
316
|
+
const STATE_TTL_MS = 1000;
|
|
317
|
+
let stateCache = null;
|
|
318
|
+
const readState = async () => {
|
|
319
|
+
if (stateCache && Date.now() - stateCache.at < STATE_TTL_MS)
|
|
320
|
+
return stateCache.value;
|
|
321
|
+
const value = await state(projectOptions());
|
|
322
|
+
stateCache = { at: Date.now(), value };
|
|
323
|
+
return value;
|
|
324
|
+
};
|
|
325
|
+
const afterWrite = () => {
|
|
326
|
+
stateCache = null;
|
|
327
|
+
notifyChatCatalog(ctx, invalidateSkills);
|
|
328
|
+
};
|
|
329
|
+
let mutationQueue = Promise.resolve();
|
|
330
|
+
const enqueueMutation = (task) => {
|
|
331
|
+
const queued = mutationQueue.then(task, task);
|
|
332
|
+
mutationQueue = queued.catch(() => undefined);
|
|
333
|
+
return queued;
|
|
334
|
+
};
|
|
335
|
+
const registerProviders = () => {
|
|
336
|
+
const disposers = [];
|
|
337
|
+
try {
|
|
338
|
+
const dispose = ctx.skills.registerProvider((control) => externalSkillProvider(control, providerInvalidators));
|
|
339
|
+
if (typeof dispose === 'function')
|
|
340
|
+
disposers.push(dispose);
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
console.error('[dsh-plugin-tool-management] global skills provider registration failed:', message(e));
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
disposers.push(registerAgentSkillProviders(ctx, providerInvalidators));
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
console.error('[dsh-plugin-tool-management] agent-scoped provider setup failed:', message(e));
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const watchPaths = roots.map((root) => String((root && root.path) || '')).filter(Boolean);
|
|
353
|
+
disposers.push(watchSkillRoots(watchPaths, invalidateSkills));
|
|
354
|
+
// 自定义目录在状态文件里,注册时读取一次并一并监听(后添加的目录由
|
|
355
|
+
// provider 首次 list 天然覆盖,等插件重载后才有 watcher)。
|
|
356
|
+
readManagerState()
|
|
357
|
+
.then((current) => {
|
|
358
|
+
const customPaths = customRootsFromState(current.state)
|
|
359
|
+
.map((root) => String((root && root.path) || ''))
|
|
360
|
+
.filter(Boolean);
|
|
361
|
+
if (customPaths.length)
|
|
362
|
+
disposers.push(watchSkillRoots(customPaths, invalidateSkills));
|
|
363
|
+
})
|
|
364
|
+
.catch(() => { });
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
console.error('[dsh-plugin-tool-management] skill watcher setup failed:', message(e));
|
|
368
|
+
}
|
|
369
|
+
return () => { for (const dispose of disposers) {
|
|
370
|
+
try {
|
|
371
|
+
dispose();
|
|
372
|
+
}
|
|
373
|
+
catch { /* ignore */ }
|
|
374
|
+
} };
|
|
375
|
+
};
|
|
376
|
+
/** 成功 → {ok:true, data};core 业务失败 → 原样透传 {ok:false,...};成功后可选触发 afterWrite。 */
|
|
377
|
+
const wrap = (fn, after) => async (args) => {
|
|
378
|
+
const result = await fn(args);
|
|
379
|
+
if (result && result.ok === false)
|
|
380
|
+
return result;
|
|
381
|
+
if (after) {
|
|
382
|
+
try {
|
|
383
|
+
after();
|
|
384
|
+
}
|
|
385
|
+
catch { /* ignore */ }
|
|
386
|
+
}
|
|
387
|
+
return { ok: true, data: result };
|
|
388
|
+
};
|
|
389
|
+
const write = (task) => enqueueMutation(task);
|
|
390
|
+
const ops = {
|
|
391
|
+
// 读操作(state 走短 TTL 缓存,见上方 readState)
|
|
392
|
+
'skill-state': wrap(() => readState()),
|
|
393
|
+
'skill-detail': wrap(async (args) => skillDetail(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), projectOptions())),
|
|
394
|
+
'skill-browse': wrap((args) => browseDirectories(args && args.path)),
|
|
395
|
+
// 启停(写 manager 状态,不改源文件)
|
|
396
|
+
'skill-enable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
|
|
397
|
+
'skill-disable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
|
|
398
|
+
'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), true, log)), afterWrite),
|
|
399
|
+
'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), false, log)), afterWrite),
|
|
400
|
+
// 创建 / 导入 / 删除 / 回收站
|
|
401
|
+
'skill-create': wrap((args) => write(async () => createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: await requestRoot(String(args.root || 'dsh')) })), afterWrite),
|
|
402
|
+
'skill-import': wrap((args) => write(() => importSkill(String(args.source || ''), log, {
|
|
403
|
+
conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
|
|
404
|
+
dryRun: args.dryRun === true,
|
|
405
|
+
})), afterWrite),
|
|
406
|
+
'skill-upload': wrap((args) => write(() => importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
|
|
407
|
+
conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
|
|
408
|
+
})), afterWrite),
|
|
409
|
+
'skill-delete': wrap((args) => write(async () => deleteSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), log)), afterWrite),
|
|
410
|
+
'skill-trash-restore': wrap((args) => write(() => restoreTrash(String(args.id || ''), log, projectOptions())), afterWrite),
|
|
411
|
+
'skill-trash-delete': wrap((args) => write(() => permanentlyDeleteTrashSafely(String(args.id || ''), log))),
|
|
412
|
+
// 自定义技能目录:添加(绝对路径,只读接入)/ 移除(连同策略键)。
|
|
413
|
+
'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
|
|
414
|
+
'skill-custom-remove': wrap((args) => write(() => removeCustomRoot(args && args.key, log)), afterWrite),
|
|
415
|
+
};
|
|
416
|
+
return { ops, registerProviders };
|
|
417
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-tool-management",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH plugin: unified Skills and MCP manager — Settings UI + HTTP API + model-facing skill_mcp_manager_* / skill_manager_* tools. Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"README_EN.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org",
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json && node scripts/sync-client.mjs",
|
|
28
|
+
"build:client": "node scripts/sync-client.mjs",
|
|
29
|
+
"test": "npm run build && node --test",
|
|
30
|
+
"test:fast": "node --test",
|
|
31
|
+
"test:client": "node scripts/sync-client.mjs && node --test tests/client.test.mjs",
|
|
32
|
+
"prepublishOnly": "npm run build",
|
|
33
|
+
"lint": "node --check lib/client.js && node --check lib/index.js"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"dsh",
|
|
37
|
+
"deepseek-harness",
|
|
38
|
+
"skill",
|
|
39
|
+
"skills",
|
|
40
|
+
"mcp",
|
|
41
|
+
"mcp-server",
|
|
42
|
+
"plugin",
|
|
43
|
+
"cordis"
|
|
44
|
+
],
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web",
|
|
51
|
+
"inject": [
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
53
|
+
"@deepseek-ai/dsh-client-locale"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
|
|
59
|
+
"react": "^18.2.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
63
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
|
|
64
|
+
"@types/node": "^24.0.0",
|
|
65
|
+
"typescript": "^6.0.0"
|
|
66
|
+
},
|
|
67
|
+
"license": "MIT",
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"fflate": "^0.8.3"
|
|
70
|
+
}
|
|
71
|
+
}
|