dsh-toolbox-ui 1.6.1 → 1.6.3
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.en.md +81 -131
- package/README.md +73 -121
- package/docs/installation.md +10 -9
- package/docs/skills.md +23 -2
- package/lib/client.js +89 -47
- package/lib/index.js +68 -17
- package/lib/skills-file-ops.js +48 -7
- package/lib/skills-frontmatter.js +149 -9
- package/lib/skills-registry.js +49 -10
- package/lib/typert.js +1 -0
- package/lib/whats-new.js +26 -0
- package/package.json +4 -4
package/docs/installation.md
CHANGED
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
推荐用 npm 安装:包里的 `^1.x` 范围让后续 `dsh plugin update` 能自动升级小版本。
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
18
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.3
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
也可以固定 GitHub release tag(适合需要锁定某个具体版本时):
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
# 固定 release tag
|
|
25
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-
|
|
25
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.3
|
|
26
26
|
|
|
27
27
|
# 或固定某个 commit
|
|
28
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-
|
|
28
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#<commit>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
两种方式的**代码与运行行为完全相同**(包里带预构建的 `lib/`,不需要运行 `prepare`,也不需要配置 `allowBuilds`);区别只在升级:`^1.x` 范围能被 `dsh plugin update` 自动推进,而 tag/commit 是不可变规格、`update` 只会重新解析同一个规格。
|
|
@@ -73,7 +73,7 @@ dsh plugin --profile web update dsh-toolbox-ui
|
|
|
73
73
|
|
|
74
74
|
| 安装规格 | `update` 的效果 | 怎么跨版本 |
|
|
75
75
|
|---|---|---|
|
|
76
|
-
| `dsh-toolbox-ui@^1.6.
|
|
76
|
+
| `dsh-toolbox-ui@^1.6.3`(npm,推荐) | 在 `^1.x` 内自动升到最新小版本,不会跨到 2.0.0 | 已是自动;跨大版本显式安装 `@^2` |
|
|
77
77
|
| `github:...#v1.4.0`(tag) | 重新解析同一个 tag,**不会动** | 换成新 tag 重新 `add`:`... #<新 tag>` |
|
|
78
78
|
| `github:...#<commit>` | 同上,固定在 commit | 用新 commit/tag 重新 `add` |
|
|
79
79
|
| `github:...#main`(分支) | 拉取最新 main | 已是自动;但不推荐生产(无 release 门槛) |
|
|
@@ -81,11 +81,11 @@ dsh plugin --profile web update dsh-toolbox-ui
|
|
|
81
81
|
|
|
82
82
|
为什么 tag 不会自动跨版本:`dsh plugin` 是 `pnpm` 的薄转发器,`update` 就是「按 package.json 里的 spec 再解析一次」,而 git tag 是不可变规格、没有范围语义(`pnpm update --help`:"Updates packages to their latest version **based on the specified range**")。
|
|
83
83
|
|
|
84
|
-
面板自带更新提示与一键升级(查
|
|
84
|
+
面板自带更新提示与一键升级(查 npm registry 上**本包的 latest**,每天最多一次、可关闭、不会自己动;点「升级」才装)。npm 安装的用户也可以用命令行 `update` 升级,两者等价。每个 release 都会同时发布到 npm 与 GitHub Releases,两者版本一致。
|
|
85
85
|
|
|
86
86
|
## 发布流程(维护者)
|
|
87
87
|
|
|
88
|
-
npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm
|
|
88
|
+
npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm 升级,而面板的更新提示问的正是 npm 上本包的 latest。两者版本不一致就会出现「提示说有新版、`update` 却拿不到」。这两件事现在由 `.github/workflows/release.yml` 在推 tag 时自动完成,并且会先校验「tag 与 package.json 版本一致」再跑测试。
|
|
89
89
|
|
|
90
90
|
### 一次性准备(只需做一次)
|
|
91
91
|
|
|
@@ -96,7 +96,8 @@ npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm
|
|
|
96
96
|
npm publish
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
2. 在 npmjs.com 该包的 Settings → **Trusted Publisher** → GitHub Actions,填:organization/user `Imzl-zl`、repository `dsh-
|
|
99
|
+
2. 在 npmjs.com 该包的 Settings → **Trusted Publisher** → GitHub Actions,填:organization/user `Imzl-zl`、repository `dsh-toolbox-ui`、workflow filename `release.yml`(可另填 environment)。
|
|
100
|
+
⚠️ 这里的 repository 是 **GitHub 仓库名**,它必须与仓库当前的 slug 一致——仓库此前叫 `dsh-mcp-manager-ui`,改名之后不同步改这一项,`npm publish` 会被拒(OIDC 凭据里带的就是仓库名)。失败是安全的:CI 那一步会红,不会建 Release。
|
|
100
101
|
3. 之后可选但推荐:把包的 Publishing access 设为 **Require two-factor authentication and disallow tokens** —— trusted publishing 不受该限制影响(它用 OIDC,不是 token),而长期写凭据因此彻底用不上。
|
|
101
102
|
|
|
102
103
|
### 每次发布
|
|
@@ -112,7 +113,7 @@ dsh web # 打开面板点一遍
|
|
|
112
113
|
# 3) 提交并推 tag(CI 会校验 tag 与 version 一致,不一致就拒绝发布)
|
|
113
114
|
git add -A && git commit -m "..."
|
|
114
115
|
git push origin main
|
|
115
|
-
git tag -a v1.6.
|
|
116
|
+
git tag -a v1.6.3 -m "dsh-toolbox-ui 1.6.3" && git push origin v1.6.3
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
推 tag 后 CI 依次做:校验版本 → `pnpm install --frozen-lockfile` → `npm test` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
|
|
@@ -131,7 +132,7 @@ git tag -a v1.6.1 -m "v1.4.2 ..." && git push origin v1.6.1
|
|
|
131
132
|
|
|
132
133
|
```sh
|
|
133
134
|
npm publish # 会弹 2FA 挑战;若用 bypass-2FA 的 granular token 则非交互
|
|
134
|
-
gh release create v1.6.
|
|
135
|
+
gh release create v1.6.3 --title "dsh-toolbox-ui v1.6.3" --notes-file <notes>
|
|
135
136
|
```
|
|
136
137
|
|
|
137
138
|
`npm login` 的会话 token 只有 2 小时,超时需要重新登录;classic token 已被 npm 永久吊销,能非交互发布的只剩勾了 **Bypass 2FA** 的 granular access token——但它正在被 npm 收窄(已不能做账号/包管理类操作,且 2027-01 起将失去直接发布能力),所以长期方案是上面的 trusted publishing。
|
package/docs/skills.md
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
- 技能到底装在哪个作用域(同名技能跨作用域是合法配置,但模型只会收到生效的那一份);
|
|
8
8
|
- 哪些技能被**停用**了(改名后宿主目录里直接消失,模型和面板都看不见它)——面板会自行扫 `.disabled` 把它们列出来,并给一键恢复;
|
|
9
|
+
- 哪些技能**允许被模型自动调用**,以及就地开关(写的是 `SKILL.md` 的 frontmatter,见[模型自动调用](#允许禁止模型自动调用));
|
|
9
10
|
- 装市场里的技能前,你被如实告知了什么、必须确认什么(披露 + 一次显式确认,不做许可判定)。
|
|
10
11
|
|
|
11
|
-
⚠️ 一个**还没做**的事:宿主对 frontmatter 非法的技能只 `warn` 后跳过,那种技能目前**不会**出现在面板里(要报它得自己扫各根的技能文件并跑一遍校验,见 TODO row 21
|
|
12
|
+
⚠️ 一个**还没做**的事:宿主对 frontmatter 非法的技能只 `warn` 后跳过,那种技能目前**不会**出现在面板里(要报它得自己扫各根的技能文件并跑一遍校验,见 TODO row 21)。面板不会假装能看到它——所以遇到这种技能时,面板给出的调用权限是「未知」而不是一个猜的值。
|
|
12
13
|
|
|
13
14
|
## 打开技能面板
|
|
14
15
|
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
|
|
19
20
|
| 模式 | 用途 |
|
|
20
21
|
|---|---|
|
|
21
|
-
| **已安装** | 左列表 +
|
|
22
|
+
| **已安装** | 左列表 + 右详情。看本机已有的技能,启停、改调用权限、删除、查看正文与诊断 |
|
|
22
23
|
| **发现市场** | 卡片网格。**只有一个来源:skillhub.cn**(外部源、直连上游、只做披露 + 显式确认,也是唯一可安装的来源) |
|
|
23
24
|
|
|
24
25
|
**作用域药丸只在「已安装」模式出现**,而且与列表分组同源(点哪个药丸,就筛出哪一组,
|
|
@@ -48,6 +49,26 @@ DSH 的技能没有「disabled」字段(MCP 有,技能没有),所以启
|
|
|
48
49
|
所以已停用的技能由本面板**自己扫描** `.disabled` 得出,否则你根本看不到它、也没法再启用;
|
|
49
50
|
- 内置(bundled)技能只读,不可启停或删除。
|
|
50
51
|
|
|
52
|
+
## 允许/禁止模型自动调用
|
|
53
|
+
|
|
54
|
+
技能可以被**模型自动调用**(模型自己判断该用它),也可以只在你手动点名时才可用。DSH 用 frontmatter 的
|
|
55
|
+
`disable-model-invocation: true` 表达后者;面板把它做成详情页「技能属性」里的一个开关(**模型自动调用**),
|
|
56
|
+
不必再去手工翻 `SKILL.md`。列表行上那个「仅用户可调用」徽章说的就是同一件事。
|
|
57
|
+
|
|
58
|
+
- **状态一律取自技能文件本身**,不取宿主的发现缓存:宿主对同一个文件的改写会继续报旧值(停用改名就是这样,实测过),
|
|
59
|
+
而这里正是面板要**写**的东西——写盘后显示回退,用户看到的是「点了没反应」。所以行与详情里的调用权限都是
|
|
60
|
+
从那个文件里解析出来的,与正文同一份字节。
|
|
61
|
+
- **读不出来就说「不知道」**:frontmatter 非法的技能宿主会整条跳过,此时面板显示「调用权限未知」且开关不可点。
|
|
62
|
+
编一个「不可调用」出来就是撒谎——**已停用**的技能此前正是这样被显示的(它的权限只有那份 `.disabled` 文件能回答),
|
|
63
|
+
这个缺陷随本开关一并修掉。
|
|
64
|
+
- **只改那一行,不重排你的文件**:关闭时把那**一行**删掉(缺省就是「允许」,所以关掉不留痕迹,开→关能逐字节还原原文);
|
|
65
|
+
打开时若已有该字段就地把值改成 `true`,没有才在 frontmatter **末尾**追加一行。字段顺序、缩进、正文、CRLF 风格都不动。
|
|
66
|
+
- **形状读不准就拒绝**:值是块标量或跨了多行、要在带行尾注释的那一行上**打开**它(关掉是整行删除,
|
|
67
|
+
边界确定,注释随行而去)、或文件里有宿主会拒绝的驼峰键(`disableModelInvocation`)时,
|
|
68
|
+
面板会拒绝并在界面上说明原因,让你手工改——猜一个改法等于改坏你的技能文件。
|
|
69
|
+
- **生效时机**:写的是文件,宿主**重新发现**这条技能之后才影响模型(重启 DSH 最稳妥)。面板显示的是磁盘上的值。
|
|
70
|
+
- 内置(bundled)技能只读,开关呈**禁用**(与启停/删除同一约定:渲染出来但点不动)。
|
|
71
|
+
|
|
51
72
|
## 安装技能
|
|
52
73
|
|
|
53
74
|
市场里点「安装」会先给你一份披露(安装确认弹窗):
|
package/lib/client.js
CHANGED
|
@@ -2823,7 +2823,8 @@ const McpTab = (props) => {
|
|
|
2823
2823
|
return startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, timer, POLL_INTERVAL_MS, () => load(false));
|
|
2824
2824
|
}, [load, timer]);
|
|
2825
2825
|
|
|
2826
|
-
// 更新提示:Host 每天最多查一次
|
|
2826
|
+
// 更新提示:Host 每天最多查一次 npm registry(本包的 latest 端点,见 lib/index.js 的
|
|
2827
|
+
// updateLatestUrl);纯增值信息,任何失败都静默,不打扰面板。
|
|
2827
2828
|
useEffect(() => {
|
|
2828
2829
|
let cancelled = false;
|
|
2829
2830
|
call('updateHint').then((res) => {
|
|
@@ -3301,7 +3302,7 @@ const McpTab = (props) => {
|
|
|
3301
3302
|
: h('a', {
|
|
3302
3303
|
key: 'link',
|
|
3303
3304
|
className: 'dsh-mcp-btn sm',
|
|
3304
|
-
href: (updateHint && updateHint.url) || 'https://github.com/Imzl-zl/dsh-
|
|
3305
|
+
href: (updateHint && updateHint.url) || 'https://github.com/Imzl-zl/dsh-toolbox-ui/releases',
|
|
3305
3306
|
target: '_blank',
|
|
3306
3307
|
rel: 'noreferrer',
|
|
3307
3308
|
}, upgradeState.phase === 'done' ? '看这次改了什么' : '查看更新方式'),
|
|
@@ -3520,6 +3521,8 @@ const McpTab = (props) => {
|
|
|
3520
3521
|
sortKey: skillSort,
|
|
3521
3522
|
onSort: setSkillSort,
|
|
3522
3523
|
onSummary: onSkillsSummary,
|
|
3524
|
+
// 技能面的行动作失败时要说出宿主的原话(见 SkillsPanel 里 act 的说明)——与 MCP 面共用这一条 flash。
|
|
3525
|
+
flash,
|
|
3523
3526
|
});
|
|
3524
3527
|
|
|
3525
3528
|
const inner = h(
|
|
@@ -4121,12 +4124,16 @@ const SkillGroupHeader = ({ group, collapsed, onToggleCollapse }) => h(
|
|
|
4121
4124
|
);
|
|
4122
4125
|
|
|
4123
4126
|
/** 管理模式的右侧详情:元信息 + 正文 + 启停/删除(与 MCP 面同一张详情卡片)。 */
|
|
4124
|
-
const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, tab = 'overview', onTab }) => {
|
|
4127
|
+
const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, tab = 'overview', onTab }) => {
|
|
4125
4128
|
if (!row) return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' },
|
|
4126
4129
|
h('b', null, '选择左侧的一个技能'),
|
|
4127
4130
|
h('span', null, '这里会显示它的位置与调用权限、SKILL.md 正文,以及遮蔽或校验失败时的诊断。')));
|
|
4128
4131
|
const disabled = row.enabled === false;
|
|
4129
4132
|
const readonly = row.writable === false;
|
|
4133
|
+
// 调用权限的**三态**:true / false / 未知。未知是真实的一态——宿主对 frontmatter 非法的技能
|
|
4134
|
+
// 整条跳过,那时「能不能被模型调用」没有答案。此前它被渲染成「不可调用」,那是凭空的断言
|
|
4135
|
+
// (已停用的行尤其明显:它们的权限只有我们读得到的那份文件能回答)。
|
|
4136
|
+
const invocationKnown = row.modelInvocable === true || row.modelInvocable === false;
|
|
4130
4137
|
const paneOff = (id) => detailPaneOff(tab, id);
|
|
4131
4138
|
const tabBtn = (id, label, count) => detailTab(tab, onTab, id, label, count);
|
|
4132
4139
|
// 「(加载中…)」只能出现在**真的在加载**的时候。之前失败也落到这句兜底文案上
|
|
@@ -4156,8 +4163,10 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
|
|
|
4156
4163
|
),
|
|
4157
4164
|
h('div', { className: 'dsh-mcp-d-meta' },
|
|
4158
4165
|
h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' }, disabled ? '已停用' : shadowedBy ? '被遮蔽' : '已启用'),
|
|
4159
|
-
|
|
4160
|
-
|
|
4166
|
+
invocationKnown
|
|
4167
|
+
? h(Badge, { cls: row.modelInvocable ? null : 's-off' }, row.modelInvocable ? '模型可调用' : '模型不可调用')
|
|
4168
|
+
: h(Badge, { cls: 's-off', title: 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断它的调用权限' }, '调用权限未知'),
|
|
4169
|
+
invocationKnown && !row.userInvocable ? h(Badge, { cls: 's-off' }, '用户不可调用') : null,
|
|
4161
4170
|
),
|
|
4162
4171
|
h('p', { className: 'dsh-mcp-d-desc' }, row.description || '(无说明)'),
|
|
4163
4172
|
),
|
|
@@ -4191,8 +4200,26 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
|
|
|
4191
4200
|
// 作用域与根目录合成一行:分组之后「哪一级」已由组头说过,这里只补完整路径。
|
|
4192
4201
|
h('b', null, '位置'), h('span', null, (row.root || '(未报告)') + '(' + skillScopeLabel(row) + ')'),
|
|
4193
4202
|
h('b', null, '状态'), h('span', null, disabled ? '已停用(SKILL.md.disabled)' : '已启用'),
|
|
4194
|
-
|
|
4195
|
-
|
|
4203
|
+
// 「模型自动调用」是**可操作**的那一项(issue #5:快速改 disable-model-invocation,
|
|
4204
|
+
// 不必手工去翻 SKILL.md)。写在 SKILL.md 的 frontmatter 里,所以只在可写根上给开关;
|
|
4205
|
+
// 权限读不出来时不装懂——开关禁用并写明未知。
|
|
4206
|
+
h('b', null, '模型自动调用'),
|
|
4207
|
+
h('span', null,
|
|
4208
|
+
h(Toggle, {
|
|
4209
|
+
on: row.modelInvocable === true,
|
|
4210
|
+
disabled: busy || readonly || !invocationKnown,
|
|
4211
|
+
label: row.name + ':允许模型自动调用',
|
|
4212
|
+
title: readonly
|
|
4213
|
+
? '内置技能只读'
|
|
4214
|
+
: invocationKnown
|
|
4215
|
+
? '写入 SKILL.md 的 disable-model-invocation;宿主重新发现这条技能后才影响模型(重启 DSH 最稳妥)'
|
|
4216
|
+
: 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断当前状态',
|
|
4217
|
+
onChange: () => onModelInvocable(row, row.modelInvocable !== true),
|
|
4218
|
+
}),
|
|
4219
|
+
' ',
|
|
4220
|
+
invocationKnown ? (row.modelInvocable ? '允许' : '禁止') : '未知',
|
|
4221
|
+
),
|
|
4222
|
+
h('b', null, '用户可调用'), h('span', null, invocationKnown ? (row.userInvocable ? '是' : '否') : '未知'),
|
|
4196
4223
|
),
|
|
4197
4224
|
),
|
|
4198
4225
|
h('div', { className: 'dsh-mcp-card' + paneOff('text') },
|
|
@@ -4403,7 +4430,7 @@ const HubCard = ({ entry, installed, onInstall }) => h(
|
|
|
4403
4430
|
*/
|
|
4404
4431
|
// ⚠️ 它**不接** onClose / floating / onMode:悬浮外壳(wrapFloating)与模式分段都归 McpTab,
|
|
4405
4432
|
// 面板只负责「技能面那具身体」。留着没人用的 prop 会让下一个人以为这里还能控制面板开关。
|
|
4406
|
-
const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', onScopeFilter, sortKey = 'default', onSort, onSummary }) => {
|
|
4433
|
+
const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', onScopeFilter, sortKey = 'default', onSort, onSummary, flash }) => {
|
|
4407
4434
|
const [rows, setRows] = useState([]);
|
|
4408
4435
|
// ⚠️ 选中存的是**身份**(行 key),行对象从当前 rows **派生**——不存对象本身。
|
|
4409
4436
|
// 存对象会留住"上一次取数时的那个对象":面板里刚启停一个技能后列表刷新,详情却还在渲染
|
|
@@ -4535,56 +4562,69 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
|
|
|
4535
4562
|
void loadBody(row);
|
|
4536
4563
|
}, [loadBody]);
|
|
4537
4564
|
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4565
|
+
// 行动作的统一出口——与 MCP 面的 act 同一条纪律:**失败必须说出来**。宿主拒绝时给的是具体
|
|
4566
|
+
// 原因(「这个字段的值跨了多行,请手工修改这一行」这类),吞掉它就只剩「点了没反应」;而技能面
|
|
4567
|
+
// 的动作真的动用户的文件,那句原因正是用户唯一能据以修好的信息。成功才重新取数。
|
|
4568
|
+
// 返回 true = 成功,调用方据此决定要不要做后续(清选中、关弹窗)。
|
|
4569
|
+
const act = useCallback(async (method, payload) => {
|
|
4542
4570
|
setBusy(true);
|
|
4543
4571
|
try {
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4572
|
+
const res = await call(method, payload);
|
|
4573
|
+
if (!res || res.ok !== true) {
|
|
4574
|
+
flash?.((res && res.error) || method + ' 失败', 'err');
|
|
4575
|
+
return false;
|
|
4576
|
+
}
|
|
4547
4577
|
await load();
|
|
4578
|
+
return true;
|
|
4548
4579
|
} finally { setBusy(false); }
|
|
4549
|
-
}, [call, load]);
|
|
4580
|
+
}, [call, flash, load]);
|
|
4581
|
+
|
|
4582
|
+
const toggleRow = useCallback(async (row, enable) => {
|
|
4583
|
+
// 双向:MCP 面每行都有启用/停用开关,技能面必须同样能**恢复**已停用的技能
|
|
4584
|
+
// (原先只有停用单向,已停用条目只能看、没有任何恢复入口)。
|
|
4585
|
+
const next = enable === undefined ? row.enabled === false : enable === true;
|
|
4586
|
+
// 只送**引用**(source + root + 项目路径),不送 writable:可写与否由服务端判定,
|
|
4587
|
+
// 客户端传什么不算数(否则「只读根不可写」只是客户端的礼貌)。
|
|
4588
|
+
await act('setEnabled', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, enabled: next });
|
|
4589
|
+
}, [act]);
|
|
4590
|
+
|
|
4591
|
+
const toggleModelInvocable = useCallback(async (row, modelInvocable) => {
|
|
4592
|
+
// 「允许模型自动调用」的开关:写技能文件里的 disable-model-invocation(issue #5)。
|
|
4593
|
+
// 与启停同一条纪律:只送引用,可写与否由服务端判定。
|
|
4594
|
+
// 重新取数在 act 里:行上的权限来自**磁盘上的那个文件**(宿主侧从文件解析),刷出来的就是
|
|
4595
|
+
// 真正写下去的值;被拒绝时文件没变,开关如实停在原位,而原因由 act 说出来。
|
|
4596
|
+
await act('setModelInvocable', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, modelInvocable: modelInvocable === true });
|
|
4597
|
+
}, [act]);
|
|
4550
4598
|
|
|
4551
4599
|
const removeRow = useCallback(async (row) => {
|
|
4552
4600
|
// 「删除技能」是**真删**(宿主 removeSkill:目录捆绑连目录、扁平删文件)。
|
|
4553
4601
|
// 之前这个菜单项接的是 toggleRow —— 点删除只会把技能停用,还报成功。
|
|
4554
4602
|
if (!confirm('确认删除技能「' + row.name + '」?' + (row.kind === 'bundle' ? '整个目录(含 scripts/references)' : '该文件') + '会从磁盘移除,不可撤销。')) return;
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
setSelectedKey('');
|
|
4559
|
-
await load();
|
|
4560
|
-
} finally { setBusy(false); }
|
|
4561
|
-
}, [call, load]);
|
|
4603
|
+
// 删成功才清选中:失败时那一行还在,清掉选中只会让用户面对一个空详情、还不知道为什么。
|
|
4604
|
+
if (await act('removeSkill', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd })) setSelectedKey('');
|
|
4605
|
+
}, [act]);
|
|
4562
4606
|
|
|
4563
4607
|
const confirmInstall = useCallback(async (target) => {
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
setHubDetail(null);
|
|
4585
|
-
await load();
|
|
4586
|
-
} finally { setBusy(false); }
|
|
4587
|
-
}, [call, installTargets, load, scope]);
|
|
4608
|
+
// 落点按**安装目标的唯一身份**(source + root)解析:两个工作区会有同一个 source,
|
|
4609
|
+
// 按 source `find` 会静默装进第一个(用户以为选了项目 B,实际写进项目 A)。
|
|
4610
|
+
const option = installTargets.find((item) => item.id === scope) || installTargets[0] || {};
|
|
4611
|
+
// writable 是显式声明:宿主默认按可写处理,所以这里的 true 必须与
|
|
4612
|
+
// 「只列可写目标」这个前提一致,不能靠默认值蒙。
|
|
4613
|
+
//
|
|
4614
|
+
// 市场只有外部源这一条路:宿主下载上游 zip 再走**同一个** importSkill 落盘——
|
|
4615
|
+
// 落点因此仍然由面板选,而不是上游说了算。
|
|
4616
|
+
const ok = await act('skillHubInstall', {
|
|
4617
|
+
slug: target.slug,
|
|
4618
|
+
version: target.version,
|
|
4619
|
+
// 落点只送引用;`acknowledged` 是**服务端**要求的确认(UI 的勾选框只是它的界面)。
|
|
4620
|
+
source: option.source,
|
|
4621
|
+
root: option.root,
|
|
4622
|
+
projectPath: option.projectPath,
|
|
4623
|
+
acknowledged: true,
|
|
4624
|
+
});
|
|
4625
|
+
// 装失败时弹窗留在原地(用户要能看到失败原因、能重试),只有成功才收起来。
|
|
4626
|
+
if (ok) { setEntry(null); setHubDetail(null); }
|
|
4627
|
+
}, [act, installTargets, scope]);
|
|
4588
4628
|
|
|
4589
4629
|
// 列表过滤:作用域(第二行的药丸)→ 状态 → 搜索词 → 排序。
|
|
4590
4630
|
// 顺序有讲究:先按作用域收窄,才知道「这个作用域里有几个已停用」。
|
|
@@ -4696,6 +4736,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
|
|
|
4696
4736
|
busy,
|
|
4697
4737
|
onRetry: () => void loadBody(selected),
|
|
4698
4738
|
onToggle: toggleRow,
|
|
4739
|
+
onModelInvocable: toggleModelInvocable,
|
|
4699
4740
|
onRemove: removeRow,
|
|
4700
4741
|
}),
|
|
4701
4742
|
)
|
|
@@ -4999,6 +5040,7 @@ const MCP_CONTRIBUTION = {
|
|
|
4999
5040
|
sk('list', [skParam('payload', 'SkillPayload')], 'SkillListResult'),
|
|
5000
5041
|
sk('detail', [skParam('payload', 'SkillPayload')], 'SkillDetailResult'),
|
|
5001
5042
|
sk('setEnabled', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5043
|
+
sk('setModelInvocable', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5002
5044
|
sk('removeSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5003
5045
|
sk('importSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5004
5046
|
sk('skillTargets', [skParam('payload', 'SkillPayload')], 'SkillTargetsResult'),
|
package/lib/index.js
CHANGED
|
@@ -12,8 +12,8 @@ import { admitImportedServers } from "./import-admission.js";
|
|
|
12
12
|
import { collectImportLocations, missingEnvNames, resolveImportLocation } from "./mcp-import-sources.js";
|
|
13
13
|
import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observability.js";
|
|
14
14
|
import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
|
|
15
|
-
import { listSkillViews, resolveTargetReference, skillRowKey, skillTargetsView } from "./skills-registry.js";
|
|
16
|
-
import { importSkill,
|
|
15
|
+
import { invocationOf, listSkillViews, resolveTargetReference, skillRowKey, skillTargetsView } from "./skills-registry.js";
|
|
16
|
+
import { importSkill, readSkillDocument, removeSkill, resolveSkillTarget, scanDisabledSkills, setSkillEnabled, setSkillModelInvocable } from "./skills-file-ops.js";
|
|
17
17
|
import { downloadHubArchive, hubCategories, hubSkillDetail, searchHubSkills } from "./skillhub.js";
|
|
18
18
|
import { setOwn } from "./own-property.js";
|
|
19
19
|
import { WHATS_NEW } from "./whats-new.js";
|
|
@@ -300,10 +300,18 @@ function containsRedactedValue(value) {
|
|
|
300
300
|
return false;
|
|
301
301
|
}
|
|
302
302
|
// ---------- 更新提示(被动、可关闭、绝不自动更新) ----------
|
|
303
|
-
// 面板打开时客户端会调一次 updateHint:Host 每天最多向
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
303
|
+
// 面板打开时客户端会调一次 updateHint:Host 每天最多向 npm registry 发一次查询(进程内缓存),
|
|
304
|
+
// 有新版时返回供面板显示可关闭的提示条。查询失败、超时、限流或被
|
|
305
|
+
// DSH_MCP_MANAGER_DISABLE_UPDATE_CHECK 禁用时静默无提示——这是有意设计:更新提示是纯增值信息,
|
|
306
|
+
// 不允许影响面板可用性;除这一条 GET 外不发送任何数据。
|
|
307
|
+
//
|
|
308
|
+
// ⚠️ 问的是**本包在 npm 上的 latest**,不是本仓库的 `/releases/latest`:同一个仓库承载两条产品线
|
|
309
|
+
// (本线 `dsh-toolbox-ui` 与老线那个包——两个名字见 release.yml),而仓库级 "latest" 是**共用**的单个
|
|
310
|
+
// 槽位,且必须留给老线——老线(≤1.5.3)面板的「升级」按钮读的正是那里,下发的却是**它自己那个包名**的
|
|
311
|
+
// 版本,所以本线的 Release 一直带 `--prerelease`(见 release.yml 的说明),让 latest 停在老线的
|
|
312
|
+
// 版本上。本线若也读那个 feed,读到的永远是旧线的版本号 → 提示恒为「已是最新」:新版本发出去,
|
|
313
|
+
// 装了本线的用户什么也看不到(v1.6.1 → v1.6.2 就这样哑掉过一次)。
|
|
314
|
+
// 包名由 package.json 派生(pluginName):写死任何一个包名都会在另一条线上静默错。
|
|
307
315
|
/**
|
|
308
316
|
* 让位检测(`conflictingManager`)的自身判据:**loader 条目的 `name` 等于本包名**。
|
|
309
317
|
*
|
|
@@ -333,12 +341,20 @@ function isMcpManagerPluginName(name) {
|
|
|
333
341
|
return lower.includes("mcp") && lower.includes("manager");
|
|
334
342
|
}
|
|
335
343
|
|
|
336
|
-
const UPDATE_CHECK_URL = "https://api.github.com/repos/Imzl-zl/dsh-mcp-manager-ui/releases/latest";
|
|
337
344
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
338
345
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
339
346
|
const UPDATE_CHECK_DISABLED_ENV = "DSH_MCP_MANAGER_DISABLE_UPDATE_CHECK";
|
|
340
347
|
let updateCheckState = { checkedAt: 0, result: null };
|
|
341
348
|
|
|
349
|
+
/** 本包在 npm 上的 latest 端点;包名读不到就没有端点(当作无提示,不猜一个包名)。 */
|
|
350
|
+
function updateLatestUrl() {
|
|
351
|
+
const name = pluginName();
|
|
352
|
+
return name === "" ? null : `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 说明页:两条线的 tag 都是 vX.Y.Z,故由版本号拼本仓库的 release 页(与老线的 updateHint 一致)。 */
|
|
356
|
+
const releasePageOf = (version) => `https://github.com/Imzl-zl/dsh-toolbox-ui/releases/tag/v${version}`;
|
|
357
|
+
|
|
342
358
|
function pluginVersion() {
|
|
343
359
|
try {
|
|
344
360
|
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "";
|
|
@@ -376,15 +392,22 @@ async function checkForUpdate(now = Date.now(), force = false) {
|
|
|
376
392
|
if (!force && cached && now - updateCheckState.checkedAt < UPDATE_CHECK_INTERVAL_MS && cached.current === current) return cached;
|
|
377
393
|
let latest = null;
|
|
378
394
|
let url = null;
|
|
395
|
+
const endpoint = updateLatestUrl();
|
|
379
396
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
397
|
+
if (endpoint !== null) {
|
|
398
|
+
const response = await fetch(endpoint, {
|
|
399
|
+
headers: { accept: "application/json" },
|
|
400
|
+
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
|
401
|
+
});
|
|
402
|
+
if (response.ok) {
|
|
403
|
+
const body = await response.json();
|
|
404
|
+
// npm 的 `/<包名>/latest` 只给版本号,没有页面链接——提示条要指得出「更新了什么」,
|
|
405
|
+
// 所以由版本号拼 release 页(两线共用 vX.Y.Z 的 tag 命名)。
|
|
406
|
+
if (typeof body?.version === "string" && body.version !== "") {
|
|
407
|
+
latest = body.version;
|
|
408
|
+
url = releasePageOf(latest);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
388
411
|
}
|
|
389
412
|
} catch {
|
|
390
413
|
// 离线/超时/限流:视作无新版,提示缺失比面板报错更可接受。
|
|
@@ -1425,7 +1448,7 @@ let McpManagerGateway = (() => {
|
|
|
1425
1448
|
let SkillsManagerGateway = (() => {
|
|
1426
1449
|
let _classSuper = TypertRemoteService;
|
|
1427
1450
|
let _instanceExtraInitializers = [];
|
|
1428
|
-
let _list_decorators, _detail_decorators, _setEnabled_decorators, _removeSkill_decorators, _importSkill_decorators, _skillTargets_decorators, _skillHubSearch_decorators, _skillHubDetail_decorators, _skillHubCategories_decorators, _skillHubInstall_decorators, _conflictingManager_decorators;
|
|
1451
|
+
let _list_decorators, _detail_decorators, _setEnabled_decorators, _setModelInvocable_decorators, _removeSkill_decorators, _importSkill_decorators, _skillTargets_decorators, _skillHubSearch_decorators, _skillHubDetail_decorators, _skillHubCategories_decorators, _skillHubInstall_decorators, _conflictingManager_decorators;
|
|
1429
1452
|
return class SkillsManagerGateway extends _classSuper {
|
|
1430
1453
|
static {
|
|
1431
1454
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
@@ -1435,6 +1458,8 @@ let SkillsManagerGateway = (() => {
|
|
|
1435
1458
|
__esDecorate(this, null, _detail_decorators, { kind: "method", name: "detail", static: false, private: false, access: { has: (obj) => "detail" in obj, get: (obj) => obj.detail }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1436
1459
|
_setEnabled_decorators = [Remote("setEnabled")];
|
|
1437
1460
|
__esDecorate(this, null, _setEnabled_decorators, { kind: "method", name: "setEnabled", static: false, private: false, access: { has: (obj) => "setEnabled" in obj, get: (obj) => obj.setEnabled }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1461
|
+
_setModelInvocable_decorators = [Remote("setModelInvocable")];
|
|
1462
|
+
__esDecorate(this, null, _setModelInvocable_decorators, { kind: "method", name: "setModelInvocable", static: false, private: false, access: { has: (obj) => "setModelInvocable" in obj, get: (obj) => obj.setModelInvocable }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1438
1463
|
_removeSkill_decorators = [Remote("removeSkill")];
|
|
1439
1464
|
__esDecorate(this, null, _removeSkill_decorators, { kind: "method", name: "removeSkill", static: false, private: false, access: { has: (obj) => "removeSkill" in obj, get: (obj) => obj.removeSkill }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1440
1465
|
_importSkill_decorators = [Remote("importSkill")];
|
|
@@ -1480,6 +1505,10 @@ let SkillsManagerGateway = (() => {
|
|
|
1480
1505
|
source: target.source,
|
|
1481
1506
|
cwd: target.projectPath,
|
|
1482
1507
|
writable: target.writable !== false,
|
|
1508
|
+
// 调用权限与可见行走**同一处**推导(skills-registry 的 invocationOf):宿主目录里
|
|
1509
|
+
// 看不到停用件,这个文件是唯一能回答「能不能被模型调用」的地方。缺了它,客户端
|
|
1510
|
+
// 会把「未知」渲染成「不可调用」——一个凭空的断言。
|
|
1511
|
+
...(await invocationOf({ name: row.name, root: target.root, scope: target.scope })),
|
|
1483
1512
|
});
|
|
1484
1513
|
}
|
|
1485
1514
|
}
|
|
@@ -1519,10 +1548,15 @@ let SkillsManagerGateway = (() => {
|
|
|
1519
1548
|
scope: target.scope,
|
|
1520
1549
|
writable: target.writable !== false,
|
|
1521
1550
|
});
|
|
1551
|
+
// 正文与调用权限来自**同一次读**(readSkillDocument 的契约):分两次读会配错版本,
|
|
1552
|
+
// 用户看到的就是「这份正文 + 别人的权限」。
|
|
1553
|
+
const document = await readSkillDocument(resolved);
|
|
1522
1554
|
return {
|
|
1523
1555
|
skill: {
|
|
1524
1556
|
name,
|
|
1525
|
-
content:
|
|
1557
|
+
content: document.text,
|
|
1558
|
+
modelInvocable: document.invocation?.modelInvocable ?? null,
|
|
1559
|
+
userInvocable: document.invocation?.userInvocable ?? null,
|
|
1526
1560
|
kind: resolved.kind,
|
|
1527
1561
|
path: resolved.path,
|
|
1528
1562
|
enabled: resolved.disabled !== true,
|
|
@@ -1548,6 +1582,23 @@ let SkillsManagerGateway = (() => {
|
|
|
1548
1582
|
});
|
|
1549
1583
|
return await setSkillEnabled(resolved, payload?.enabled !== false);
|
|
1550
1584
|
}
|
|
1585
|
+
async setModelInvocable(payload) {
|
|
1586
|
+
// 「允许模型自动调用」的开关:写技能文件的 disable-model-invocation。纪律与 setEnabled
|
|
1587
|
+
// 完全一致——wire 只送引用、服务端核对自己算出来的目标、只读根拒绝;改写的形状规则在
|
|
1588
|
+
// lib/skills-frontmatter.js,读不准时那个错误原样上抛给用户。
|
|
1589
|
+
const target = await resolveTargetReference(this.ctx, {
|
|
1590
|
+
source: payload?.source,
|
|
1591
|
+
root: payload?.root,
|
|
1592
|
+
projectPath: payload?.projectPath,
|
|
1593
|
+
});
|
|
1594
|
+
const resolved = await resolveSkillTarget({
|
|
1595
|
+
name: String(payload?.name ?? ""),
|
|
1596
|
+
root: target.root,
|
|
1597
|
+
scope: target.scope,
|
|
1598
|
+
writable: target.writable !== false,
|
|
1599
|
+
});
|
|
1600
|
+
return await setSkillModelInvocable(resolved, payload?.modelInvocable !== false);
|
|
1601
|
+
}
|
|
1551
1602
|
async removeSkill(payload) {
|
|
1552
1603
|
const target = await resolveTargetReference(this.ctx, {
|
|
1553
1604
|
source: payload?.source,
|
package/lib/skills-file-ops.js
CHANGED
|
@@ -14,9 +14,9 @@ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFi
|
|
|
14
14
|
import { randomBytes } from 'node:crypto'
|
|
15
15
|
import { tmpdir } from 'node:os'
|
|
16
16
|
import { join, relative } from 'node:path'
|
|
17
|
-
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
|
17
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
18
18
|
import { MAX_ARCHIVE_BYTES, extractArchive } from './skills-archive.js'
|
|
19
|
-
import { diagnoseSkill } from './skills-frontmatter.js'
|
|
19
|
+
import { diagnoseSkill, setFrontmatterBoolean } from './skills-frontmatter.js'
|
|
20
20
|
|
|
21
21
|
// 与宿主同一套名字语法(dsh-skill 的 SKILL_NAME)。它天然排除 '.'、'/'、'\\',
|
|
22
22
|
// 所以合法名字不可能跳出根目录——但越界校验仍然保留,因为这是系统边界。
|
|
@@ -166,12 +166,53 @@ export async function scanDisabledSkills(input) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
169
|
+
* 读一个技能文件:正文**和它在磁盘上的调用权限**一次给出。
|
|
170
|
+
*
|
|
171
|
+
* 为什么合成一个入口:分两次读会读到两个版本(改动正好落在两次读之间),面板于是显示一份正文
|
|
172
|
+
* 配一个不属于它的权限。调用权限一律从**这份读到的字节**里解析出来,所以它天然与正文同源。
|
|
173
|
+
*
|
|
174
|
+
* 解析不出来时 `invocation` 是 `null`——**不知道就说不知道**。非法 frontmatter 的技能会被宿主
|
|
175
|
+
* 整条跳过,此时「模型能不能调用它」没有答案,编一个布尔值就是让面板撒谎。
|
|
176
|
+
*
|
|
177
|
+
* @param {object} target - resolveSkillTarget 的结果。
|
|
178
|
+
* @returns {Promise<{ text: string, invocation: null | { modelInvocable: boolean, userInvocable: boolean } }>}
|
|
179
|
+
*/
|
|
180
|
+
export async function readSkillDocument(target) {
|
|
181
|
+
const text = await readFile(target.path, 'utf8')
|
|
182
|
+
return { text, invocation: diagnoseSkill(text).invocation ?? null }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 改一个技能的「模型自动调用」权限——写的是它 frontmatter 里的 disable-model-invocation。
|
|
187
|
+
*
|
|
188
|
+
* 与启停同一套纪律:只读根拒绝、同一把锁(root + name,见 setSkillEnabled 的说明)、
|
|
189
|
+
* 读-改-写全在锁内、原子提交(writeFileAtomic 写临时文件再 rename)。改写规则本身在
|
|
190
|
+
* lib/skills-frontmatter.js:形状读不准时它抛错,本层**原样放行那个错误**——拒绝的原因
|
|
191
|
+
* 只有那一处知道,包一层只会把「哪一行不对」这类信息丢掉。
|
|
192
|
+
*
|
|
193
|
+
* @param {object} target - resolveSkillTarget 的结果(已停用的技能也收,权限与启用态无关)。
|
|
194
|
+
* @param {boolean} modelInvocable - true 允许模型自动调用,false 禁止。
|
|
195
|
+
* @returns {Promise<{ changed: boolean, modelInvocable: boolean }>}
|
|
172
196
|
*/
|
|
173
|
-
export async function
|
|
174
|
-
|
|
197
|
+
export async function setSkillModelInvocable(target, modelInvocable) {
|
|
198
|
+
if (target.writable === false) {
|
|
199
|
+
throw new Error('该技能来自只读根(bundled),不可修改调用权限')
|
|
200
|
+
}
|
|
201
|
+
const next = modelInvocable !== false
|
|
202
|
+
// 锁键与启停/删除/导入一致:同名技能上的这些操作必须彼此串行,否则「读到旧内容 → 写回」
|
|
203
|
+
// 会把另一次操作的结果覆盖掉(例如与删除交错时把技能文件重新写出来)。
|
|
204
|
+
return await withFileLock(join(target.root, target.name), async () => {
|
|
205
|
+
const text = await readFile(target.path, 'utf8')
|
|
206
|
+
// 字段名说的是「禁止」而不是「允许」:modelInvocable === false ⇔ disable-model-invocation: true。
|
|
207
|
+
const written = setFrontmatterBoolean(text, 'disable-model-invocation', !next)
|
|
208
|
+
if (written.changed) {
|
|
209
|
+
// 原子替换是「写临时文件再 rename」,新文件的权限位会退回默认值。技能文件是用户自己的
|
|
210
|
+
// 资产,动它一个字不该顺手把它的权限也改掉——照抄 profile 写盘的纪律,保留原 mode。
|
|
211
|
+
const { mode } = await stat(target.path)
|
|
212
|
+
await writeFileAtomic(target.path, written.text, { mode })
|
|
213
|
+
}
|
|
214
|
+
return { changed: written.changed, modelInvocable: next }
|
|
215
|
+
})
|
|
175
216
|
}
|
|
176
217
|
|
|
177
218
|
/**
|