@restregular/dsh-skill-center 0.2.2 → 0.3.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/index.js CHANGED
@@ -22,12 +22,18 @@ import {
22
22
  } from 'node:fs';
23
23
  import { homedir } from 'node:os';
24
24
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
25
26
 
26
27
  /** Loader identity of the Host half. */
27
28
  export const name = 'skill-center';
28
29
 
29
- /** Services this plugin needs; absent `webServer` keeps it inert instead of throwing. */
30
- export const inject = ['skills', 'webServer'];
30
+ /**
31
+ * Services this plugin needs. `webServer` is *not* listed here: the loopback
32
+ * route is mounted through `ctx.inject(['webServer'], …)` so a deployment
33
+ * without a web server still gets the bundled skill and the `skill_create`
34
+ * tool instead of an inert plugin.
35
+ */
36
+ export const inject = ['skills'];
31
37
 
32
38
  /** Loopback route prefix owned by this plugin. */
33
39
  const ROUTE = '/dsh-skill-center';
@@ -52,6 +58,35 @@ const ROOT_LABELS = {
52
58
  'user-agents': { label: '用户 Agents 技能', rank: 500 },
53
59
  };
54
60
 
61
+ /**
62
+ * 随包(内嵌)技能:目录名 == frontmatter name,正文与资源都在本包 `skills/` 下。
63
+ * 这些技能以 provider 形式注册,资源通过 `resourceBase` 目录暴露给模型 ——
64
+ * 与第一方的 `@deepseek-ai/dsh-skill-office` 同一形态。
65
+ */
66
+ const BUNDLED_SKILL_ROOT = fileURLToPath(new URL('../skills/', import.meta.url));
67
+
68
+ /** 随包技能的名字列表。 */
69
+ const BUNDLED_SKILL_NAMES = ['skill-authoring'];
70
+
71
+ /** provider 名:面板的 provider 列显示这个名字。 */
72
+ const BUNDLED_SKILL_PROVIDER = 'dsh-skill-center';
73
+
74
+ /**
75
+ * 浏览器模块图里本插件那一行的 id 候选:图按**包名**编号,插件 id 只是宿主侧名字。
76
+ * 两个都收,免得 loader 行名或注册 id 变一处就误报。
77
+ */
78
+ const BROWSER_ENTRY_IDS = new Set([
79
+ '@restregular/dsh-skill-center',
80
+ 'dsh-skill-center',
81
+ ]);
82
+
83
+ /**
84
+ * 随包技能的 rank,与 `@deepseek-ai/dsh-skill` 的 `BUNDLED_SKILL_RANK` 一致:
85
+ * 项目根(100/200)与用户根(400/500)的同名技能都能盖过它。
86
+ * 这里写字面量而不是 import,是为了让本插件保持零运行时依赖。
87
+ */
88
+ const BUNDLED_SKILL_RANK = 600;
89
+
55
90
  // ---------------------------------------------------------------------------
56
91
  // HTTP 小工具
57
92
  // ---------------------------------------------------------------------------
@@ -174,6 +209,109 @@ function frontmatterField(text, key) {
174
209
  return raw.replace(/^["']/u, '').replace(/["']$/u, '');
175
210
  }
176
211
 
212
+ /**
213
+ * 读 frontmatter 里的布尔字段,取值规则与 dsh-skill-filesystem 的
214
+ * `frontmatterBoolean` 一致(true/false/1/0/yes/no/on/off,大小写不敏感)。
215
+ * @param text - 文件全文。
216
+ * @param key - 字段名。
217
+ * @returns 布尔值,或 undefined(缺字段或值非法)。
218
+ */
219
+ function frontmatterBoolean(text, key) {
220
+ const raw = frontmatterField(text, key);
221
+ if (raw === undefined) return undefined;
222
+ const value = raw.toLowerCase();
223
+ if (value === 'true' || value === '1' || value === 'yes' || value === 'on') return true;
224
+ if (value === 'false' || value === '0' || value === 'no' || value === 'off') return false;
225
+ return undefined;
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // 随包技能:注册成 provider,资源用 resourceBase 目录暴露
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * 把本包 `skills/<name>/` 读成注册表候选。
234
+ *
235
+ * `resourceBase` 是模型能不能用上这个技能资源的关键:注册表会把
236
+ * "Base directory for this skill: <绝对路径>" 渲染进 `<skill_resources>`,
237
+ * 并告诉模型把正文里的相对路径解析到这个目录。缺了它,资源引用就只是空话。
238
+ *
239
+ * @param name - 技能名(等于目录名)。
240
+ * @returns 注册表候选。
241
+ * @throws 当 SKILL.md 缺失或没有 description 时(属于打包错误,要立刻看见)。
242
+ */
243
+ function bundledSkillCandidate(name) {
244
+ const directory = join(BUNDLED_SKILL_ROOT, name);
245
+ const path = join(directory, 'SKILL.md');
246
+ const text = readFileSync(path, 'utf8');
247
+ const description = frontmatterField(text, 'description');
248
+ if (description === undefined) {
249
+ throw new Error(`随包技能 "${name}" 的 SKILL.md 缺少 description:${path}`);
250
+ }
251
+ return {
252
+ name,
253
+ description,
254
+ invocation: { modelInvocable: true, userInvocable: true },
255
+ provider: BUNDLED_SKILL_PROVIDER,
256
+ source: 'bundled',
257
+ rank: BUNDLED_SKILL_RANK,
258
+ resourceBase: { kind: 'directory', path: directory },
259
+ locator: path,
260
+ path,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * 读随包技能的正文(frontmatter 之后的部分)。
266
+ * @param name - 技能名。
267
+ * @returns 已 trim 的 Markdown 正文。
268
+ */
269
+ function bundledSkillBody(name) {
270
+ const text = readFileSync(join(BUNDLED_SKILL_ROOT, name, 'SKILL.md'), 'utf8');
271
+ const match = matchFrontmatter(text);
272
+ return (match === undefined ? text : text.slice(match[0].length)).trim();
273
+ }
274
+
275
+ /**
276
+ * 构造随包技能的 provider。
277
+ *
278
+ * `list()` 只回目录摘要;正文每次 `get()` 现读,所以仓库里的 SKILL.md 改完
279
+ * 下一次加载就生效(注册表只缓存目录,不缓存正文)。
280
+ *
281
+ * @returns skill provider 对象。
282
+ */
283
+ function createBundledSkillProvider() {
284
+ const candidates = BUNDLED_SKILL_NAMES.map((name) => bundledSkillCandidate(name));
285
+ const byName = new Map(candidates.map((candidate) => [candidate.name, candidate]));
286
+ return {
287
+ name: BUNDLED_SKILL_PROVIDER,
288
+ list: () => Promise.resolve(candidates),
289
+ get(winner) {
290
+ const known = byName.get(winner?.name);
291
+ if (known === undefined) throw new Error(`随包技能 "${String(winner?.name)}" 不存在`);
292
+ const { rank: _rank, locator: _locator, ...summary } = known;
293
+ return Promise.resolve({ ...summary, content: bundledSkillBody(known.name) });
294
+ },
295
+ };
296
+ }
297
+
298
+ /**
299
+ * 注册随包技能。失败只记一行日志:技能资产出问题不该把整个插件(含面板)带下水。
300
+ * @param ctx - 宿主上下文。
301
+ */
302
+ function mountBundledSkills(ctx) {
303
+ const skills = ctx.skills ?? ctx.get('skills');
304
+ if (skills === undefined) return;
305
+ try {
306
+ ctx.effect(
307
+ () => skills.registerProvider(createBundledSkillProvider),
308
+ 'skill-center: bundled skills',
309
+ );
310
+ } catch (error) {
311
+ ctx.logger?.warn?.(`skill-center: 随包技能未能注册:${error?.message ?? error}`);
312
+ }
313
+ }
314
+
177
315
  // ---------------------------------------------------------------------------
178
316
  // 技能根目录
179
317
  // ---------------------------------------------------------------------------
@@ -374,13 +512,29 @@ function describeSkill(skill, roots) {
374
512
  const root = rootOfPath(path, roots);
375
513
  const onDisk = path !== undefined && existsSync(path) && statSync(path).isFile();
376
514
  let bytes;
515
+ let invocation;
377
516
  if (onDisk) {
378
517
  try {
379
518
  bytes = statSync(path).size;
380
519
  } catch {
381
520
  bytes = undefined;
382
521
  }
522
+ // 受管根里的技能以**磁盘为准**:技能目录是缓存的,写完盘之后 watcher 的失效
523
+ // 事件到达之前,快照里的调用策略还是旧值 —— 面板若照抄快照,刚拨完开关的界面
524
+ // 会被自己重读回来的旧值弹回去(手动刷新才会变)。这里现读一次 frontmatter 求解。
525
+ if (root !== undefined) {
526
+ try {
527
+ const text = readFileSync(path, 'utf8');
528
+ invocation = {
529
+ modelInvocable: frontmatterBoolean(text, 'disable-model-invocation') !== true,
530
+ userInvocable: frontmatterBoolean(text, 'user-invocable') !== false,
531
+ };
532
+ } catch {
533
+ invocation = undefined;
534
+ }
535
+ }
383
536
  }
537
+ const effective = invocation ?? skill.invocation;
384
538
  return {
385
539
  name: skill.name,
386
540
  description: skill.description,
@@ -389,8 +543,8 @@ function describeSkill(skill, roots) {
389
543
  source: skill.source,
390
544
  path,
391
545
  directory: path === undefined ? undefined : dirname(path),
392
- modelInvocable: skill.invocation?.modelInvocable !== false,
393
- userInvocable: skill.invocation?.userInvocable !== false,
546
+ modelInvocable: effective?.modelInvocable !== false,
547
+ userInvocable: effective?.userInvocable !== false,
394
548
  rootId: root?.id,
395
549
  rootLabel: root?.label,
396
550
  writable: root !== undefined && onDisk,
@@ -438,7 +592,10 @@ async function opDiagnose(ctx, request) {
438
592
  const clientModules = ctx.get('clientModules');
439
593
  const graph = clientModules?.graph?.();
440
594
  const entries = Array.isArray(graph?.entries) ? graph.entries : [];
441
- const mine = entries.find((entry) => entry.id === 'dsh-skill-center');
595
+ // 浏览器模块图按**包名**给行编号(客户端 bundle 的注册 id 也是包名),
596
+ // 插件 id 只是宿主侧的 loader 名字:只按 'dsh-skill-center' 找会永远找不到,
597
+ // diagnose 就会把"客户端半边好好地挂在图里"误报成 inGraph: false。
598
+ const mine = entries.find((entry) => BROWSER_ENTRY_IDS.has(entry.id));
442
599
  let catalog;
443
600
  try {
444
601
  const catalogRead = await readCatalog(ctx, request);
@@ -451,6 +608,7 @@ async function opDiagnose(ctx, request) {
451
608
  host: { mounted: true, route: ROUTE },
452
609
  client: {
453
610
  inGraph: mine !== undefined,
611
+ entryId: mine?.id,
454
612
  graphRevision: graph?.rev,
455
613
  graphEntryCount: entries.length,
456
614
  url: mine?.url,
@@ -548,45 +706,163 @@ async function opSetInvocation(ctx, request) {
548
706
  }
549
707
 
550
708
  /**
551
- * 新建一个技能目录包。
709
+ * 归一化资源相对路径:必须是技能包目录内的相对路径,拒绝绝对路径、`.`/`..` 段与
710
+ * 二次写入 SKILL.md。面板与模型工具都走这道闸。
711
+ *
712
+ * @param raw - 原始路径(允许 `\` 分隔)。
713
+ * @param directory - 技能包目录。
714
+ * @returns 归一化后的 posix 相对路径,或 undefined(非法)。
715
+ */
716
+ function normalizeResourcePath(raw, directory) {
717
+ if (typeof raw !== 'string') return undefined;
718
+ const value = raw.replace(/\\/gu, '/').trim();
719
+ if (value === '' || value.startsWith('/') || /^[A-Za-z]:/u.test(value)) return undefined;
720
+ const segments = value.split('/').filter((segment) => segment !== '');
721
+ if (segments.length === 0) return undefined;
722
+ if (segments.some((segment) => segment === '.' || segment === '..')) return undefined;
723
+ const relative = segments.join('/');
724
+ if (relative.toLowerCase() === 'skill.md') return undefined;
725
+ const base = resolve(directory);
726
+ const target = resolve(base, ...segments);
727
+ if (target !== base && !target.startsWith(base.endsWith(sep) ? base : base + sep)) return undefined;
728
+ return relative;
729
+ }
730
+
731
+ /**
732
+ * 新建(或按 `overwrite` 覆盖)一个技能包:写 frontmatter + 正文,再把资源文件落到
733
+ * 同一个技能目录里。面板的 `create` 与模型工具 `skill_create` 共用这一份实现。
734
+ *
552
735
  * @param ctx - 宿主上下文。
553
- * @param request - `{ sessionId, cwd, name, description, body, rootId, modelInvocable, userInvocable }`。
554
- * @returns 响应体。
736
+ * @param request - `{ sessionId, cwd, name, description, whenToUse, body, rootId, resources, overwrite, modelInvocable, userInvocable }`。
737
+ * @returns 响应体;`ok` 为 false 时带 `error`。
555
738
  */
556
- async function opCreate(ctx, request) {
739
+ async function createSkillBundle(ctx, request) {
557
740
  const skillName = String(request.name ?? '').trim();
558
741
  if (!SKILL_NAME.test(skillName)) {
559
742
  return { ok: false, error: `技能名 "${skillName}" 不合法:只允许小写字母、数字与单个连字符(kebab-case)` };
560
743
  }
561
- const description = String(request.description ?? '').trim();
744
+ const description = String(request.description ?? '').replace(/\s+/gu, ' ').trim();
562
745
  if (description === '') return { ok: false, error: 'description 必填' };
746
+ const whenToUse = String(request.whenToUse ?? '').replace(/\s+/gu, ' ').trim();
747
+ const overwrite = request.overwrite === true;
748
+ const rawResources = Array.isArray(request.resources) ? request.resources : [];
563
749
 
564
750
  const catalog = await readCatalog(ctx, request);
565
- if (catalog.skills.some((skill) => skill.name === skillName)) {
566
- return { ok: false, error: `技能 "${skillName}" 已存在` };
751
+ const existing = locateOnDisk(skillName, catalog.roots);
752
+ if (existing === undefined && catalog.skills.some((skill) => skill.name === skillName)) {
753
+ return {
754
+ ok: false,
755
+ error: `技能 "${skillName}" 已存在,但由插件内嵌或其它来源提供,不在可管理的技能根目录里`,
756
+ };
757
+ }
758
+ if (existing !== undefined && !overwrite) {
759
+ return { ok: false, error: `技能 "${skillName}" 已存在:${existing.path}(需要覆盖请传 overwrite)` };
567
760
  }
568
- const roots = catalog.roots.filter((root) => root.exists || root.id.startsWith('user-'));
569
- const root = roots.find((candidate) => candidate.id === request.rootId)
570
- ?? roots.find((candidate) => candidate.id === 'user-agents')
571
- ?? roots[0];
572
- if (root === undefined) return { ok: false, error: '没有可用的技能根目录' };
573
-
574
- const dir = join(root.path, skillName);
575
- if (existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` };
761
+
762
+ let root = existing?.root;
763
+ let directory = existing?.dir;
764
+ if (existing === undefined) {
765
+ if (request.rootId !== undefined && request.rootId !== '') {
766
+ // 显式指定的根允许尚不存在(mkdir 会补上):项目根就在工作区里,
767
+ // 由调用方明确点名比"静默换到用户根"更可预期。
768
+ root = catalog.roots.find((candidate) => candidate.id === request.rootId);
769
+ if (root === undefined) {
770
+ return {
771
+ ok: false,
772
+ error: `未知的技能根 "${request.rootId}";可选:${catalog.roots.map((candidate) => candidate.id).join('、')}`,
773
+ };
774
+ }
775
+ } else {
776
+ const usable = catalog.roots.filter((candidate) => candidate.exists || candidate.id.startsWith('user-'));
777
+ root = usable.find((candidate) => candidate.id === 'user-agents') ?? usable[0];
778
+ }
779
+ if (root === undefined) return { ok: false, error: '没有可用的技能根目录' };
780
+ directory = join(root.path, skillName);
781
+ if (existsSync(directory) && !overwrite) {
782
+ return { ok: false, error: `目录已存在:${directory}(需要覆盖请传 overwrite)` };
783
+ }
784
+ }
785
+
786
+ const resources = [];
787
+ if (existing?.flat === true) {
788
+ if (rawResources.length > 0) {
789
+ return {
790
+ ok: false,
791
+ error: `技能 "${skillName}" 是平坦文件 ${existing.path},无法承载资源文件;请改用目录包`,
792
+ };
793
+ }
794
+ } else {
795
+ for (const entry of rawResources) {
796
+ const relative = normalizeResourcePath(entry?.path, directory);
797
+ if (relative === undefined) {
798
+ return {
799
+ ok: false,
800
+ error: `资源路径 "${String(entry?.path ?? '')}" 不合法:必须是技能目录内的相对路径,不能是 SKILL.md,也不能含 . 或 .. 段`,
801
+ };
802
+ }
803
+ if (typeof entry?.content !== 'string') {
804
+ return { ok: false, error: `资源 "${relative}" 缺少 content` };
805
+ }
806
+ resources.push({
807
+ relative,
808
+ content: entry.content,
809
+ target: join(directory, ...relative.split('/')),
810
+ });
811
+ }
812
+ }
813
+
576
814
  const invocation = {
577
815
  modelInvocable: request.modelInvocable !== false,
578
816
  userInvocable: request.userInvocable !== false,
579
817
  };
580
818
  const lines = ['---', `name: ${skillName}`, `description: ${JSON.stringify(description)}`];
819
+ if (whenToUse !== '') lines.push(`whenToUse: ${JSON.stringify(whenToUse)}`);
581
820
  if (invocation.modelInvocable === false) lines.push('disable-model-invocation: true');
582
821
  if (invocation.userInvocable === false) lines.push('user-invocable: false');
583
822
  lines.push('---', '');
584
823
  const body = String(request.body ?? '').trim();
585
824
  lines.push(body === '' ? `# ${skillName}` : body, '');
586
- mkdirSync(dir, { recursive: true });
587
- const path = join(dir, 'SKILL.md');
825
+
826
+ const flat = existing?.flat === true;
827
+ const path = flat ? existing.path : join(directory, 'SKILL.md');
828
+ mkdirSync(flat ? dirname(path) : directory, { recursive: true });
588
829
  writeFileSync(path, lines.join('\n'), 'utf8');
589
- return { ok: true, name: skillName, path, rootId: root.id };
830
+ for (const resource of resources) {
831
+ mkdirSync(dirname(resource.target), { recursive: true });
832
+ writeFileSync(resource.target, resource.content, 'utf8');
833
+ }
834
+
835
+ return {
836
+ ok: true,
837
+ name: skillName,
838
+ path,
839
+ directory: flat ? dirname(path) : directory,
840
+ rootId: root?.id,
841
+ rootLabel: root?.label,
842
+ replaced: existing !== undefined,
843
+ resources: resources.map((resource) => resource.relative),
844
+ };
845
+ }
846
+
847
+ /**
848
+ * 新建一个技能目录包(HTTP `op=create`)。
849
+ * @param ctx - 宿主上下文。
850
+ * @param request - 见 createSkillBundle。
851
+ * @returns 响应体。
852
+ */
853
+ async function opCreate(ctx, request) {
854
+ const created = await createSkillBundle(ctx, request);
855
+ if (created.ok !== true) return created;
856
+ return {
857
+ ok: true,
858
+ name: created.name,
859
+ path: created.path,
860
+ directory: created.directory,
861
+ rootId: created.rootId,
862
+ rootLabel: created.rootLabel,
863
+ replaced: created.replaced,
864
+ resources: created.resources,
865
+ };
590
866
  }
591
867
 
592
868
  /**
@@ -614,6 +890,161 @@ async function opDelete(ctx, request) {
614
890
  return { ok: true, name: request.name, movedTo: target, rootId: entry.root.id };
615
891
  }
616
892
 
893
+ // ---------------------------------------------------------------------------
894
+ // 模型工具:skill_create
895
+ // ---------------------------------------------------------------------------
896
+
897
+ /**
898
+ * `skill_create` 的参数 schema。
899
+ *
900
+ * 这里手写**原始 JSON Schema**,而不是 import `@deepseek-ai/dsh-tools` 的
901
+ * `defineTool`:本包因此保持零运行时依赖,也不依赖宿主 node_modules 的解析路径。
902
+ * 只使用注册表支持的子集:type/oneOf/properties/required/additionalProperties/
903
+ * items/enum/const 加注解键(description/title…)。
904
+ */
905
+ const SKILL_CREATE_PARAMETERS = {
906
+ type: 'object',
907
+ properties: {
908
+ name: {
909
+ type: 'string',
910
+ description: 'Kebab-case skill name; it is also the bundle directory name.',
911
+ },
912
+ description: {
913
+ type: 'string',
914
+ description: 'Frontmatter description — the only routing signal the model catalog shows. Write it as "Use when …".',
915
+ },
916
+ whenToUse: {
917
+ type: 'string',
918
+ description: 'Optional "whenToUse" frontmatter field (provider metadata; not rendered into the catalog).',
919
+ },
920
+ body: {
921
+ type: 'string',
922
+ description: 'Full Markdown body that follows the frontmatter. Omit to write a placeholder "# <name>" heading.',
923
+ },
924
+ root: {
925
+ type: 'string',
926
+ enum: ['project-dsh', 'project-agents', 'user-dsh', 'user-agents'],
927
+ description: 'Target skill root. Defaults to user-agents (~/.agents/skills); pass a project root for a repository-scoped skill.',
928
+ },
929
+ resources: {
930
+ type: 'array',
931
+ description: 'Extra files written inside the same bundle directory, e.g. references/roots.md or scripts/check.py.',
932
+ items: {
933
+ type: 'object',
934
+ properties: {
935
+ path: { type: 'string', description: 'Relative path inside the bundle directory.' },
936
+ content: { type: 'string', description: 'Full UTF-8 file content.' },
937
+ },
938
+ required: ['path', 'content'],
939
+ additionalProperties: false,
940
+ },
941
+ },
942
+ modelInvocable: {
943
+ type: 'boolean',
944
+ description: 'Default true; false writes disable-model-invocation: true, keeping the skill out of the model catalog.',
945
+ },
946
+ userInvocable: {
947
+ type: 'boolean',
948
+ description: 'Default true; false writes user-invocable: false, disabling the user-facing /name entry point.',
949
+ },
950
+ overwrite: {
951
+ type: 'boolean',
952
+ description: 'Default false: fail when the skill already exists. true rewrites SKILL.md and the listed resources in place.',
953
+ },
954
+ },
955
+ required: ['name', 'description'],
956
+ additionalProperties: false,
957
+ };
958
+
959
+ /** `skill_create` 的返回值 schema,与 execute 的返回字段一一对应。 */
960
+ const SKILL_CREATE_OUTPUT = {
961
+ type: 'object',
962
+ properties: {
963
+ name: { type: 'string' },
964
+ directory: { type: 'string' },
965
+ skillFile: { type: 'string' },
966
+ rootId: { type: 'string' },
967
+ rootLabel: { type: 'string' },
968
+ resources: { type: 'array', items: { type: 'string' } },
969
+ replaced: { type: 'boolean' },
970
+ },
971
+ required: ['name', 'directory', 'skillFile', 'resources', 'replaced'],
972
+ additionalProperties: false,
973
+ };
974
+
975
+ /**
976
+ * 构造 `skill_create` 工具定义:把面板的"新建"能力交给模型,并顺带解决
977
+ * "技能根目录在工作区之外"这个真实障碍 —— 文件写入工具被沙箱限制在工作区内,
978
+ * 而受管技能根不在工作区。
979
+ *
980
+ * @param ctx - 宿主上下文。
981
+ * @returns 可交给 `ctx.tools.register()` 的定义。
982
+ */
983
+ function skillCreateTool(ctx) {
984
+ return {
985
+ name: 'skill_create',
986
+ description: 'Create a Harness skill bundle: writes <root>/<name>/SKILL.md with the given frontmatter and body, plus any extra files listed in resources, inside that same directory. Use it to author or scaffold a skill, especially when the target skill root is outside the session workspace (~/.agents/skills, ~/.dsh/skills), where the file-write tools are sandboxed and cannot write. Put long-form detail in resources (for example references/<topic>.md, scripts/<tool>.py) and refer to those files by relative path from the body. Fails when a skill of that name already exists unless overwrite is true.',
987
+ parameters: SKILL_CREATE_PARAMETERS,
988
+ output: {
989
+ schema: SKILL_CREATE_OUTPUT,
990
+ render(_args, value) {
991
+ const lines = [
992
+ `${value.replaced === true ? 'Replaced' : 'Created'} skill "${value.name}"`,
993
+ `directory: ${value.directory}`,
994
+ `SKILL.md: ${value.skillFile}`,
995
+ `root: ${value.rootLabel} (${value.rootId})`,
996
+ value.resources.length === 0 ? 'resources: none' : `resources: ${value.resources.join(', ')}`,
997
+ ];
998
+ return [{ type: 'text', text: lines.join('\n') }];
999
+ },
1000
+ },
1001
+ async execute(args, exec) {
1002
+ const session = exec?.agent?.session;
1003
+ const created = await createSkillBundle(ctx, {
1004
+ sessionId: session?.id,
1005
+ cwd: session?.header?.cwd,
1006
+ name: args.name,
1007
+ description: args.description,
1008
+ whenToUse: args.whenToUse,
1009
+ body: args.body,
1010
+ rootId: args.root,
1011
+ resources: args.resources,
1012
+ overwrite: args.overwrite,
1013
+ modelInvocable: args.modelInvocable,
1014
+ userInvocable: args.userInvocable,
1015
+ });
1016
+ if (created.ok !== true) throw new Error(created.error);
1017
+ return {
1018
+ name: created.name,
1019
+ directory: created.directory,
1020
+ skillFile: created.path,
1021
+ rootId: created.rootId,
1022
+ rootLabel: created.rootLabel,
1023
+ resources: created.resources,
1024
+ replaced: created.replaced,
1025
+ };
1026
+ },
1027
+ };
1028
+ }
1029
+
1030
+ /**
1031
+ * 注册 `skill_create`。没有 tools 服务的组合里只是少一个工具,
1032
+ * 面板与随包技能照常工作。
1033
+ * @param ctx - 宿主上下文。
1034
+ */
1035
+ function mountSkillTools(ctx) {
1036
+ ctx.inject(['tools'], (scoped) => {
1037
+ try {
1038
+ scoped.effect(
1039
+ () => scoped.tools.register(skillCreateTool(scoped)),
1040
+ 'skill-center: skill_create tool',
1041
+ );
1042
+ } catch (error) {
1043
+ scoped.logger?.warn?.(`skill-center: skill_create 未能注册:${error?.message ?? error}`);
1044
+ }
1045
+ });
1046
+ }
1047
+
617
1048
  /** 路由表:op → 处理器。 */
618
1049
  const OPERATIONS = {
619
1050
  ping: async () => ({ ok: true, plugin: 'dsh-skill-center', version: 1 }),
@@ -631,9 +1062,9 @@ const OPERATIONS = {
631
1062
 
632
1063
  /**
633
1064
  * 挂载回环路由,向客户端暴露技能目录与管理操作。
634
- * @param ctx - 宿主上下文(已保证 skills 与 webServer 就绪)。
1065
+ * @param ctx - 宿主上下文(已保证 webServer 就绪)。
635
1066
  */
636
- export function apply(ctx) {
1067
+ function mountRoutes(ctx) {
637
1068
  const webServer = ctx.webServer ?? ctx.get('webServer');
638
1069
  if (webServer === undefined) return;
639
1070
 
@@ -689,3 +1120,14 @@ export function apply(ctx) {
689
1120
  },
690
1121
  }), 'skill-center: loopback routes');
691
1122
  }
1123
+
1124
+ /**
1125
+ * 插件体:随包技能、模型工具、回环路由三块互相独立降级 ——
1126
+ * 任何一块失败都只记日志,不会让宿主启动失败,也不会拖垮另外两块。
1127
+ * @param ctx - 宿主上下文(已保证 skills 就绪)。
1128
+ */
1129
+ export function apply(ctx) {
1130
+ mountBundledSkills(ctx);
1131
+ mountSkillTools(ctx);
1132
+ ctx.inject(['webServer'], (scoped) => mountRoutes(scoped));
1133
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-skill-center",
3
- "version": "0.2.2",
4
- "description": "技能中心 — DeepSeek Harness 技能管理面板:浏览当前会话可见的所有技能、查看正文、开关模型/用户调用、新建与删除。 · Skill Center for DeepSeek Harness: browse and manage the skills visible to the current session.",
3
+ "version": "0.3.1",
4
+ "description": "技能中心 — DeepSeek Harness 技能管理面板:浏览当前会话可见的所有技能、查看正文、开关模型/用户调用、新建与删除;随包附带 skill-authoring 指导技能与 skill_create 模型工具。 · Skill Center for DeepSeek Harness: browse and manage the skills visible to the current session, with a bundled skill-authoring skill and a skill_create model tool.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "lib",
19
19
  "client",
20
+ "skills",
20
21
  "cordis.patch.yml"
21
22
  ],
22
23
  "exports": {
@@ -45,7 +46,9 @@
45
46
  "deepseek",
46
47
  "skills",
47
48
  "skill-center",
48
- "agent-skills"
49
+ "agent-skills",
50
+ "skill-authoring",
51
+ "skill-create"
49
52
  ],
50
53
  "publishConfig": {
51
54
  "access": "public",
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: skill-authoring
3
+ description: "Use when creating, editing, moving, or reviewing a Harness skill: choosing the skill root for project or user scope, writing SKILL.md frontmatter and body, placing the skill's resource files next to it, or shipping a skill inside a plugin."
4
+ ---
5
+
6
+ # Authoring Harness skills
7
+
8
+ A skill is a **directory bundle**: `<root>/<name>/SKILL.md` plus every resource file that `SKILL.md` refers to. The model sees only each skill's `name` and `description` in the session catalog; the body is loaded on demand when the model or the user invokes it, and resource files are never loaded automatically. A skill's body must therefore say which file to read and when.
9
+
10
+ ## Short path
11
+
12
+ 1. Pick the root — see `references/roots.md`. Project-scoped skill: `<projectRoot>/.agents/skills`. Available in every project: `$DSH_AGENTS_HOME/skills` (`~/.agents/skills`).
13
+ 2. Create `<root>/<name>/SKILL.md`. `name` is kebab-case and should equal the directory name.
14
+ 3. Keep the frontmatter minimal: `name` and `description` are required; the body starts after the closing `---`.
15
+ 4. Put resource files in subdirectories of **that same bundle directory** and point at them with relative paths.
16
+ 5. Verify: the frontmatter parses, `name` matches the directory, every referenced file exists, and the skill appears in the Skill Center panel (and in the catalog of the next model step).
17
+
18
+ Use the `skill_create` tool when the target root is outside the current workspace: the file-write tools are sandboxed to the workspace, while the managed skill roots are not, so a direct write to `~/.agents/skills/...` is denied. `skill_create` writes `<root>/<name>/SKILL.md` plus any `resources` you pass, in one call, and returns the absolute bundle directory. Inside the workspace (for example `<projectRoot>/.agents/skills`) ordinary file writes work fine.
19
+
20
+ ## Frontmatter decides who can invoke the skill
21
+
22
+ | Field | Effect |
23
+ |---|---|
24
+ | `description` | the only routing signal in the catalog — write it as "Use when …" and name the tasks, not the implementation |
25
+ | `disable-model-invocation: true` | the skill leaves the model catalog entirely; only an explicit `/name` from the user loads it |
26
+ | `user-invocable: false` | `/name` stops working for humans; the model can still load the skill |
27
+
28
+ The camelCase spellings (`disableModelInvocation`, `modelInvocable`, `userInvocable`) are **rejected**: a skill carrying one is skipped with a warning instead of loading. The full contract, the accepted boolean spellings, and the silent failure modes are in `references/frontmatter.md`.
29
+
30
+ ## Resource placement
31
+
32
+ Everything a skill needs lives under its own bundle directory, because the base directory reported to the model *is* that directory and relative paths resolve against it:
33
+
34
+ ```
35
+ <root>/<name>/
36
+ SKILL.md
37
+ references/ long-form detail the body points at by relative path
38
+ scripts/ commands the body tells the model to run
39
+ assets/ fixtures, templates, images
40
+ ```
41
+
42
+ The loaded skill arrives wrapped in `<skill_content>` with a `<skill_resources>` block that names the absolute base directory; the model is told to resolve relative paths against it before reading. Files under a bundle's resource subtrees are read directly by the file tools when the body asks for them, so editing a reference file needs no catalog refresh — only the skill *list* (names, descriptions, invocation policy) is cached. Details, path rules and the Desktop `app.asar` caveat: `references/resources.md`.
43
+
44
+ ## Shipping a skill inside a plugin
45
+
46
+ A plugin publishes its own skills instead of writing into the user's roots: it registers a skill provider (or a runtime skill) and points each candidate at an asset directory inside the package. Use this when the skill's instructions and resources must version together with the code they describe. `references/plugin-bundled.md` has the provider contract, the candidate fields, the rank, and the two registration APIs.
47
+
48
+ ## Read next
49
+
50
+ Read only the files this task needs; the table is complete.
51
+
52
+ | Task | File |
53
+ |---|---|
54
+ | Which root a skill belongs in, root precedence, project-root detection | `references/roots.md` |
55
+ | Frontmatter fields, validation, and why a skill silently does not load | `references/frontmatter.md` |
56
+ | Where resource files go, how the model is told to find them, Desktop limits | `references/resources.md` |
57
+ | Shipping skill instructions and resources inside a plugin package | `references/plugin-bundled.md` |
58
+ | Starter bundle for a new skill | `templates/minimal/SKILL.md` |