@morehao/dsh-skills-console 0.4.0 → 0.5.1

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/lib/core/state.js CHANGED
@@ -7,11 +7,14 @@ import path from 'node:path';
7
7
  * - v2:`installs[].enabled` + `exports[]`(只给其他 agent 的显式导出)+ `shadows[]`(DSH 扫描根里的停用标记)。
8
8
  * - v3:`installs[].enabled` 是**唯一**启停开关;`shadows[]` 退休,`legacyShadows[]` 只放
9
9
  * 迁移时删不掉的旧桩与 orphan(v0.4.0 起启停由每个 live agent scope 的 policy provider 负责)。
10
+ * - v4:`installs[].mode`(auto / manual / hidden)成为调用策略;`enabled` 只保留给 legacy
11
+ * `dsh-off`(旧版 DSH-only 停用),manual / hidden 不再借助 provider。`manual` 记录
12
+ * frontmatter 的原始值与来源,`hidden` 记录 A′ park 的 marker / 内容哈希。
10
13
  *
11
- * 迁移分两步:`upgradeStateV1` / `upgradeStateV2` 只做**纯结构**改写(可测、无副作用),
12
- * 删链/删旧桩/剪悬空导出这类要做 IO 的事在 `migrations.js` 里按 manifest 执行。
14
+ * 迁移分三步:`upgradeStateV1` / `upgradeStateV2` / `upgradeStateV3` 只做**纯结构**改写
15
+ * (可测、无副作用),删链/删旧桩/剪悬空导出这类要做 IO 的事在 `migrations.js` 里按 manifest 执行。
13
16
  */
14
- export const STATE_VERSION = 3;
17
+ export const STATE_VERSION = 4;
15
18
 
16
19
  export function emptyState() {
17
20
  return {
@@ -150,6 +153,37 @@ export function upgradeStateV2(value, { now = new Date().toISOString() } = {}) {
150
153
  return next;
151
154
  }
152
155
 
156
+ const INVOCATION_MODES = new Set(['auto', 'manual', 'hidden']);
157
+
158
+ /**
159
+ * v3 → v4 的结构升级。**不读写任何文件**:
160
+ * - 老记录没有 `mode` → `auto`(行为与 v3 完全一致);
161
+ * - `enabled === false` → 额外 `legacy: 'dsh-off'`,仍由 provider 隐藏,绝不静默改成 manual;
162
+ * - `manual` / `hidden` 只做形状占位(null),真实值由后续写操作填充。
163
+ *
164
+ * 不写 `migration`:v1→v2→v3 的 migration 是文件侧待办清单,v4 只是字段迁移,没有 IO。
165
+ */
166
+ export function upgradeStateV3(value) {
167
+ const installs = (Array.isArray(value.installs) ? value.installs : []).map((item) => {
168
+ const next = isPlainObject(item) ? { ...item } : {};
169
+ if (!INVOCATION_MODES.has(next.mode)) next.mode = 'auto';
170
+ if (next.mode === 'auto') {
171
+ if (next.enabled === false) next.legacy = 'dsh-off';
172
+ else delete next.legacy;
173
+ } else {
174
+ // manual / hidden 都不再依赖 provider:enabled 必须是 true,否则会双重隐藏。
175
+ next.enabled = true;
176
+ delete next.legacy;
177
+ }
178
+ if (next.mode === 'auto') {
179
+ next.manual = null;
180
+ next.hidden = null;
181
+ }
182
+ return next;
183
+ });
184
+ return { ...value, version: 4, installs };
185
+ }
186
+
153
187
  function validateV3(state) {
154
188
  for (const key of ['subscriptions', 'installs', 'exports', 'legacyShadows']) {
155
189
  if (!Array.isArray(state[key])) return { ok: false, reason: `state.${key} is not an array` };
@@ -202,6 +236,69 @@ function validateV3(state) {
202
236
  return { ok: true, state };
203
237
  }
204
238
 
239
+ /**
240
+ * v4 的形状校验。只修「缺省即可推断」的字段(mode 缺省 auto、hidden 的固定文件名),
241
+ * 不猜冲突语义:mode 写错、manual 缺 origin / original 一律只读,避免拿坏状态去写文件。
242
+ */
243
+ function validateV4(state) {
244
+ for (const row of state.installs) {
245
+ if (row.mode === undefined) row.mode = 'auto';
246
+ if (!INVOCATION_MODES.has(row.mode)) {
247
+ return { ok: false, reason: `state.installs[] ${row.name} has invalid mode ${String(row.mode)}` };
248
+ }
249
+ if (row.legacy !== undefined && row.legacy !== 'dsh-off') {
250
+ return { ok: false, reason: `state.installs[] ${row.name} has invalid legacy ${String(row.legacy)}` };
251
+ }
252
+ if (row.mode === 'auto') {
253
+ row.manual = null;
254
+ row.hidden = null;
255
+ if (row.legacy === 'dsh-off') row.enabled = false;
256
+ else delete row.legacy;
257
+ continue;
258
+ }
259
+ row.enabled = true;
260
+ delete row.legacy;
261
+ if (row.mode === 'manual') {
262
+ row.hidden = null;
263
+ if (!isPlainObject(row.manual)) {
264
+ return { ok: false, reason: `state.installs[] ${row.name} manual metadata is missing` };
265
+ }
266
+ if (row.manual.origin !== 'plugin' && row.manual.origin !== 'author') {
267
+ return { ok: false, reason: `state.installs[] ${row.name} manual.origin is invalid` };
268
+ }
269
+ if (!['absent', 'true', 'false'].includes(row.manual.original)) {
270
+ return { ok: false, reason: `state.installs[] ${row.name} manual.original is invalid` };
271
+ }
272
+ if (typeof row.manual.appliedAt !== 'string' || !row.manual.appliedAt) {
273
+ row.manual.appliedAt = new Date().toISOString();
274
+ }
275
+ continue;
276
+ }
277
+ // hidden
278
+ if (!isPlainObject(row.hidden)) {
279
+ return { ok: false, reason: `state.installs[] ${row.name} hidden metadata is missing` };
280
+ }
281
+ if (row.hidden.previousMode !== 'auto' && row.hidden.previousMode !== 'manual') {
282
+ return { ok: false, reason: `state.installs[] ${row.name} hidden.previousMode is invalid` };
283
+ }
284
+ if (typeof row.hidden.disabledFile !== 'string' || !row.hidden.disabledFile) {
285
+ row.hidden.disabledFile = 'SKILL.md.disabled';
286
+ }
287
+ if (typeof row.hidden.marker !== 'string' || !row.hidden.marker) {
288
+ row.hidden.marker = '.dsh-skills-console.park.json';
289
+ }
290
+ if (row.hidden.canonicalHash !== undefined && row.hidden.canonicalHash !== null
291
+ && typeof row.hidden.canonicalHash !== 'string') {
292
+ return { ok: false, reason: `state.installs[] ${row.name} hidden.canonicalHash is invalid` };
293
+ }
294
+ if (typeof row.hidden.at !== 'string' || !row.hidden.at) row.hidden.at = new Date().toISOString();
295
+ if (row.manual !== undefined && row.manual !== null && !isPlainObject(row.manual)) {
296
+ return { ok: false, reason: `state.installs[] ${row.name} manual metadata is invalid` };
297
+ }
298
+ }
299
+ return { ok: true, state };
300
+ }
301
+
205
302
  export function validateState(value) {
206
303
  if (!isPlainObject(value)) return { ok: false, reason: 'state is not an object' };
207
304
  if (typeof value.version !== 'number' || !Number.isInteger(value.version)) {
@@ -229,8 +326,9 @@ export function validateState(value) {
229
326
  }
230
327
 
231
328
  let upgraded;
232
- if (value.version === 1) upgraded = upgradeStateV2(upgradeStateV1(value));
233
- else if (value.version === 2) upgraded = upgradeStateV2(value);
329
+ if (value.version === 1) upgraded = upgradeStateV3(upgradeStateV2(upgradeStateV1(value)));
330
+ else if (value.version === 2) upgraded = upgradeStateV3(upgradeStateV2(value));
331
+ else if (value.version === 3) upgraded = upgradeStateV3(value);
234
332
  else upgraded = value;
235
333
 
236
334
  const state = { ...emptyState(), ...upgraded };
@@ -238,7 +336,9 @@ export function validateState(value) {
238
336
  // v1 的顶层 links[] 在 v2 升级时已拆成 exports[] + migration.legacyLinks;
239
337
  // 旧版本只搬家没删原字段,v3 里读到就剪掉,不再往外写。
240
338
  delete state.links;
241
- const verdict = validateV3(state);
339
+ const v3 = validateV3(state);
340
+ if (!v3.ok) return v3;
341
+ const verdict = validateV4(v3.state);
242
342
  if (!verdict.ok) return verdict;
243
343
  state.version = STATE_VERSION;
244
344
  return { ok: true, state: verdict.state };
package/lib/index.js CHANGED
@@ -4,6 +4,8 @@ import { createPaths } from './core/paths.js';
4
4
  import { createRegistry } from './core/agents.js';
5
5
  import { createPolicyProvider, installPolicyProviders } from './core/policy.js';
6
6
  import { createService } from './core/service.js';
7
+ import { normalizeIntroConfig } from './core/intro.js';
8
+ import { createIntroStore } from './core/intro-store.js';
7
9
  import { StateStore } from './core/state.js';
8
10
  import { createRoutes, ROUTE_PREFIX } from './routes.js';
9
11
 
@@ -86,6 +88,20 @@ export function apply(ctx, config = {}) {
86
88
  workspacesProvider,
87
89
  invalidatePolicies: () => policies.invalidate(),
88
90
  policyStatus: () => policies.status(),
91
+ // 技能介绍:走宿主已挂载的模型服务(可选服务,缺失时接口 501,其他功能不受影响)
92
+ introStore: createIntroStore({ paths, logger }),
93
+ introOptions: normalizeIntroConfig(config.intro || {}),
94
+ getLlm: () => ctx.get('llm'),
95
+ getDefaultModel: () => {
96
+ const defaults = ctx.get('agentDefaultModel');
97
+ if (!defaults?.currentSelection) return null;
98
+ try {
99
+ return defaults.currentSelection();
100
+ } catch (error) {
101
+ logger('intro.default-model.failed', { message: error?.message });
102
+ return null;
103
+ }
104
+ },
89
105
  });
90
106
 
91
107
  // 迁移必须尽早跑:GUI 一打开就该是「停用 = 真不可见」,而不是等用户点到某一行才发现。
@@ -0,0 +1,46 @@
1
+ {
2
+ "generatedAt": "2026-10-26",
3
+ "note": "只承诺已确认的客户端;`disable-model-invocation` 是客户端扩展,不是 Agent Skills 规范字段。",
4
+ "clients": [
5
+ {
6
+ "id": "dsh",
7
+ "label": "DSH",
8
+ "manual": "yes",
9
+ "hidden": "yes",
10
+ "verifiedAt": "2026-10-26",
11
+ "source": "宿主 SKILL.md frontmatter + 扫描根"
12
+ },
13
+ {
14
+ "id": "claude",
15
+ "label": "Claude Code",
16
+ "manual": "yes",
17
+ "hidden": "yes",
18
+ "verifiedAt": "2026-10-26",
19
+ "source": "Claude Code skills frontmatter"
20
+ },
21
+ {
22
+ "id": "cursor",
23
+ "label": "Cursor",
24
+ "manual": "yes",
25
+ "hidden": "yes",
26
+ "verifiedAt": "2026-10-26",
27
+ "source": "Cursor skills frontmatter"
28
+ },
29
+ {
30
+ "id": "opencode",
31
+ "label": "OpenCode",
32
+ "manual": "version",
33
+ "hidden": "yes",
34
+ "verifiedAt": null,
35
+ "source": "版本相关,未逐版验证"
36
+ },
37
+ {
38
+ "id": "other",
39
+ "label": "其他 / 未验证",
40
+ "manual": "unverified",
41
+ "hidden": "spec",
42
+ "verifiedAt": null,
43
+ "source": "目录里没有 SKILL.md 即不符合规范发现"
44
+ }
45
+ ]
46
+ }
package/lib/routes.js CHANGED
@@ -73,12 +73,23 @@ function matchRoute(method, pathname) {
73
73
  if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'files' && method === 'GET') {
74
74
  return { name: 'skill.files', params: { name: decodeURIComponent(segments[1]) } };
75
75
  }
76
+ // 技能介绍(模型生成):GET 只读缓存,POST 才真正调模型
77
+ if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'intro') {
78
+ if (method === 'GET') return { name: 'skill.intro.get', params: { name: decodeURIComponent(segments[1]) } };
79
+ if (method === 'POST') return { name: 'skill.intro.generate', params: { name: decodeURIComponent(segments[1]) } };
80
+ }
76
81
  if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'adopt' && method === 'POST') {
77
82
  return { name: 'skill.adopt', params: { name: decodeURIComponent(segments[1]) } };
78
83
  }
79
84
  if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'mode' && method === 'POST') {
80
85
  return { name: 'skill.mode', params: { name: decodeURIComponent(segments[1]) } };
81
86
  }
87
+ if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'invocation' && method === 'POST') {
88
+ return { name: 'skill.invocation', params: { name: decodeURIComponent(segments[1]) } };
89
+ }
90
+ if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'unpark' && method === 'POST') {
91
+ return { name: 'skill.unpark', params: { name: decodeURIComponent(segments[1]) } };
92
+ }
82
93
  if (segments[0] === 'skills' && segments.length === 2 && method === 'DELETE') {
83
94
  return { name: 'skill.remove', params: { name: decodeURIComponent(segments[1]) } };
84
95
  }
@@ -91,6 +102,13 @@ function matchRoute(method, pathname) {
91
102
  if (segments.length === 3 && segments[2] === 'refresh' && method === 'POST') {
92
103
  return { name: 'subscriptions.refresh', params: { id: decodeURIComponent(segments[1]) } };
93
104
  }
105
+ // 订阅候选的介绍:路径由服务端按快照反查,客户端只给订阅 id + 条目名
106
+ if (segments.length === 5 && segments[2] === 'entries' && segments[4] === 'intro') {
107
+ const id = decodeURIComponent(segments[1]);
108
+ const entryName = decodeURIComponent(segments[3]);
109
+ if (method === 'GET') return { name: 'subscription.intro.get', params: { id, name: entryName } };
110
+ if (method === 'POST') return { name: 'subscription.intro.generate', params: { id, name: entryName } };
111
+ }
94
112
  }
95
113
 
96
114
  if (segments[0] === 'updates') {
@@ -123,13 +141,24 @@ export function createRoutes({ service, logger }) {
123
141
  return;
124
142
  }
125
143
 
144
+ // 客户端断开(关抽屉 / 关页面)就取消模型调用:摘要是一次性辅助请求,没必要跑完再丢。
145
+ // 测试里的 fake res 没有 on/off,所以要按能力探测。
146
+ const abort = new AbortController();
147
+ const onClose = () => {
148
+ if (!res.writableEnded) abort.abort();
149
+ };
150
+ if (typeof res.on === 'function') res.on('close', onClose);
151
+
126
152
  try {
127
153
  service.reloadState();
128
154
  const body = method === 'GET' ? {} : await readBody(req);
129
155
  const query = Object.fromEntries(url.searchParams.entries());
130
- const result = await dispatch(route, { body, query, service });
156
+ const result = await dispatch(route, { body, query, service, signal: abort.signal });
157
+ // 生成期间客户端可能已经走了:往已关闭的响应写会抛 ERR_STREAM_WRITE_AFTER_END
158
+ if (res.writableEnded || res.destroyed) return;
131
159
  sendJson(res, 200, result);
132
160
  } catch (error) {
161
+ if (res.writableEnded || res.destroyed) return;
133
162
  if (error instanceof StateReadOnlyError) {
134
163
  sendJson(res, 409, {
135
164
  ok: false,
@@ -146,11 +175,13 @@ export function createRoutes({ service, logger }) {
146
175
  }
147
176
  logger('route.error', { message: error?.message, stack: String(error?.stack || '').slice(0, 800) });
148
177
  sendJson(res, 500, { ok: false, code: 'error.skills.internal', message: error?.message || 'internal error' });
178
+ } finally {
179
+ if (typeof res.off === 'function') res.off('close', onClose);
149
180
  }
150
181
  };
151
182
  }
152
183
 
153
- async function dispatch(route, { body, query, service }) {
184
+ async function dispatch(route, { body, query, service, signal = null }) {
154
185
  switch (route.name) {
155
186
  case 'state': {
156
187
  const state = await service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
@@ -174,6 +205,29 @@ async function dispatch(route, { body, query, service }) {
174
205
  return service.listSkillBody({ name: route.params.name, path: query.path });
175
206
  case 'skill.files':
176
207
  return service.listSkillFiles({ name: route.params.name });
208
+ case 'skill.intro.get':
209
+ return service.getSkillIntro({ name: route.params.name, lang: query.lang });
210
+ case 'subscription.intro.get':
211
+ return service.getRemoteIntro({
212
+ subscriptionId: route.params.id,
213
+ name: route.params.name,
214
+ lang: query.lang,
215
+ });
216
+ case 'subscription.intro.generate':
217
+ return service.generateRemoteIntro({
218
+ subscriptionId: route.params.id,
219
+ name: route.params.name,
220
+ lang: body.lang ?? query.lang,
221
+ force: body.force === true,
222
+ signal,
223
+ });
224
+ case 'skill.intro.generate':
225
+ return service.generateSkillIntro({
226
+ name: route.params.name,
227
+ lang: body.lang ?? query.lang,
228
+ force: body.force === true,
229
+ signal,
230
+ });
177
231
  case 'skill.adopt':
178
232
  return service.adopt({ name: route.params.name });
179
233
  case 'skill.mode':
@@ -183,6 +237,10 @@ async function dispatch(route, { body, query, service }) {
183
237
  scope: body.scope,
184
238
  projectRoot: body.projectRoot,
185
239
  });
240
+ case 'skill.invocation':
241
+ return service.setInvocation({ name: route.params.name, mode: body.mode });
242
+ case 'skill.unpark':
243
+ return service.unpark({ name: route.params.name, keep: body.keep || null });
186
244
  case 'skill.remove':
187
245
  return service.remove({ name: route.params.name });
188
246
  case 'subscriptions.add':
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Skills Console",
4
- "description": "Install, update, remove, link and enable local agent skills. Skill bodies stay owned by the skills CLI."
4
+ "description": "Install, update, remove and manage invocation policies for local agent skills. Skill bodies stay untouched; only Manual-only adds one marker line."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "技能控制台",
4
- "description": "安装、更新、删除、接入与启停本机 Agent 技能;技能正文始终由 skills CLI 写入。"
4
+ "description": "安装、更新、删除与调用策略管理本机 Agent 技能;技能正文保持原样,仅「仅手动」会加一行标记。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",