dsh-plugin-capabilities 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-capabilities",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Manage skills and MCP servers from the Web UI Settings. 在设置页管理 dsh 的技能与 MCP 服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -63,6 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.6",
66
+ "dompurify": "^3.4.13",
67
+ "marked": "^18.0.10",
66
68
  "smol-toml": "^1.3.1",
67
69
  "yaml": "^2.6.0"
68
70
  },
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Local Markdown preview for the skill editor: marked + DOMPurify, bundled
3
+ * into the plugin. The host exports its own Markdown renderer (the chat
4
+ * pipeline), but whether a given host build serves it through the frozen
5
+ * platform table is not guaranteed — bundling our own keeps the preview
6
+ * working on every host version. Skill bodies come from third-party
7
+ * repositories, so the rendered HTML is sanitized before it mounts.
8
+ */
9
+
10
+ import { useMemo } from 'react'
11
+ import type { ReactElement } from 'react'
12
+ import DOMPurify from 'dompurify'
13
+ import { marked } from 'marked'
14
+
15
+ const parse = (text: string): string =>
16
+ DOMPurify.sanitize(marked.parse(text, { async: false, gfm: true, breaks: false }) as string)
17
+
18
+ export function MarkdownPreview(props: { text: string }): ReactElement {
19
+ const html = useMemo(() => parse(props.text), [props.text])
20
+ return <div className="dpc-mdBody" dangerouslySetInnerHTML={{ __html: html }} />
21
+ }
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'
7
7
  import type { ReactElement } from 'react'
8
8
  import { Button, IconRefreshOutline14, IconSkillOutline16, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
9
9
  import { CSS } from './css.ts'
10
+ import { MarkdownPreview } from './MarkdownPreview.tsx'
10
11
  import type { OpenTarget, Translate } from './index.ts'
11
12
  import type { RootRowView } from './MarketTab.tsx'
12
13
 
@@ -19,6 +20,9 @@ export interface SkillRowView {
19
20
  source: string
20
21
  provider: string
21
22
  editable: boolean
23
+ /** Only user-root skills can be deleted (the delete route removes one
24
+ * directory under $DSH_HOME/skills); other editable sources just save. */
25
+ removable: boolean
22
26
  /** The skill's folder when it lives on disk (drives open-folder). */
23
27
  dir?: string
24
28
  /** File-backed skills can be enabled/disabled through the policy route. */
@@ -77,6 +81,8 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
77
81
  const { t, injected } = props
78
82
  const [skills, setSkills] = useState<SkillRowView[] | null>(null)
79
83
  const [editor, setEditor] = useState<EditorState | null>(null)
84
+ /** Content pane of the editor modal: rendered Markdown vs the raw editor. */
85
+ const [preview, setPreview] = useState(false)
80
86
  const [confirmName, setConfirmName] = useState<string | null>(null)
81
87
  const [busy, setBusy] = useState(false)
82
88
  const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
@@ -106,6 +112,7 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
106
112
 
107
113
  const openCreate = (): void => {
108
114
  setFormError(null)
115
+ setPreview(false)
109
116
  setEditor({ mode: 'create', name: '', description: '', whenToUse: '', modelInvocable: true, userInvocable: true, content: '' })
110
117
  }
111
118
 
@@ -114,6 +121,9 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
114
121
  setFormError(null)
115
122
  try {
116
123
  const body = await injected.get(skill.name)
124
+ // Read-only sources open on the rendered preview; editable ones on the
125
+ // plain-text editor. The toggle in the content row flips either way.
126
+ setPreview(!skill.editable)
117
127
  setEditor({
118
128
  mode: skill.editable ? 'edit' : 'view',
119
129
  name: skill.name,
@@ -363,7 +373,7 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
363
373
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => void openExisting(skill)}>
364
374
  {skill.editable ? t('edit') : t('view')}
365
375
  </Button>
366
- {skill.editable && (
376
+ {skill.removable && (
367
377
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmName(skill.name)}>{t('delete')}</Button>
368
378
  )}
369
379
  </div>
@@ -485,15 +495,30 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
485
495
  {t('userInvocable')}
486
496
  </label>
487
497
  </div>
488
- <label className="dpc-label">
489
- <span>{t('skillContent')}</span>
490
- <textarea
491
- className="dpc-textarea"
492
- value={editor.content}
493
- readOnly={readOnly}
494
- onChange={(event) => setEditor({ ...editor, content: event.target.value })}
495
- />
496
- </label>
498
+ <div className="dpc-label">
499
+ <div className="dpc-cardRow">
500
+ <span>{t('skillContent')}</span>
501
+ <span className="dpc-spacer" />
502
+ <div className="dpc-segments" role="tablist" aria-label={t('skillContent')}>
503
+ <button type="button" role="tab" aria-selected={preview} className="dpc-segment" data-active={preview ? 'true' : undefined} onClick={() => setPreview(true)}>
504
+ {t('skillPreview')}
505
+ </button>
506
+ <button type="button" role="tab" aria-selected={!preview} className="dpc-segment" data-active={!preview ? 'true' : undefined} onClick={() => setPreview(false)}>
507
+ {readOnly ? t('skillPlainText') : t('edit')}
508
+ </button>
509
+ </div>
510
+ </div>
511
+ {preview
512
+ ? <div className="dpc-mdPreview"><MarkdownPreview text={editor.content} /></div>
513
+ : (
514
+ <textarea
515
+ className="dpc-textarea"
516
+ value={editor.content}
517
+ readOnly={readOnly}
518
+ onChange={(event) => setEditor({ ...editor, content: event.target.value })}
519
+ />
520
+ )}
521
+ </div>
497
522
  {formError !== null && <p className="dpc-formError">{formError}</p>}
498
523
  <div className="dpc-cardRow">
499
524
  <span className="dpc-spacer" />
package/src/client/css.ts CHANGED
@@ -46,7 +46,7 @@ export const CSS = `
46
46
  .dpc-label{display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
47
47
  .dpc-label>span:first-child{color:var(--dsw-alias-label-tertiary)}
48
48
  .dpc-input,.dpc-textarea,.dpc-select{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 10px;outline:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
49
- .dpc-textarea{min-height:240px;resize:vertical;font-family:var(--ds-font-family-code);line-height:1.5}
49
+ .dpc-textarea{min-height:320px;resize:vertical;font-family:var(--ds-font-family-code);line-height:1.5}
50
50
  .dpc-textarea[data-short='true']{min-height:96px}
51
51
  .dpc-input:focus-visible,.dpc-textarea:focus-visible,.dpc-select:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
52
52
  .dpc-checks{display:flex;gap:16px;font-size:13px;line-height:20px}
@@ -59,8 +59,24 @@ export const CSS = `
59
59
  /* Editor dialogs (new skill / server): 640px wide so markdown bodies and
60
60
  command/arg/env lines stop wrapping mid-token; the content column scrolls
61
61
  on short viewports instead of clipping past the dialog edge. */
62
- .dpc-modalForm.dpc-modalForm{width:min(640px,100%)}
62
+ .dpc-modalForm.dpc-modalForm{width:min(760px,100%)}
63
63
  .dpc-modalScroll.dpc-modalScroll{max-height:calc(100vh - 160px);overflow-y:auto}
64
+ .dpc-mdPreview{min-height:320px;max-height:60vh;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-1);font-size:13px}
65
+ /* Rendered-markdown body (bundled marked + DOMPurify). */
66
+ .dpc-mdBody h1,.dpc-mdBody h2,.dpc-mdBody h3,.dpc-mdBody h4{margin:14px 0 6px;color:var(--dsw-alias-label-primary);line-height:1.4}
67
+ .dpc-mdBody h1{font-size:18px}.dpc-mdBody h2{font-size:16px}.dpc-mdBody h3{font-size:14px}.dpc-mdBody h4{font-size:13px}
68
+ .dpc-mdBody p{margin:6px 0;line-height:20px}
69
+ .dpc-mdBody ul,.dpc-mdBody ol{margin:6px 0;padding-left:20px}
70
+ .dpc-mdBody li{margin:2px 0;line-height:19px}
71
+ .dpc-mdBody code{font-family:var(--ds-font-family-code);font-size:12px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;padding:1px 5px}
72
+ .dpc-mdBody pre{margin:8px 0;padding:10px 12px;overflow-x:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3)}
73
+ .dpc-mdBody pre code{padding:0;background:transparent;font-size:12px;line-height:18px}
74
+ .dpc-mdBody a{color:var(--dsw-alias-state-business-primary)}
75
+ .dpc-mdBody blockquote{margin:8px 0;padding:2px 12px;border-left:3px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
76
+ .dpc-mdBody table{border-collapse:collapse;margin:8px 0}
77
+ .dpc-mdBody th,.dpc-mdBody td{border:1px solid var(--dsw-alias-border-l2);padding:4px 10px;font-size:12px;line-height:18px}
78
+ .dpc-mdBody hr{border:0;border-top:1px solid var(--dsw-alias-border-l2);margin:12px 0}
79
+ .dpc-mdBody>*:first-child{margin-top:0}
64
80
  .dpc-importScroll{display:flex;flex-direction:column;gap:14px;max-height:min(400px,52vh);overflow-y:auto;padding:2px 4px 2px 2px}
65
81
  .dpc-importGroup{display:flex;flex-direction:column;gap:8px}
66
82
  .dpc-importHead{display:flex;align-items:center;gap:8px;padding:0 2px}
@@ -6,7 +6,7 @@ export const zh = {
6
6
  mcpTab: 'MCP',
7
7
  marketTab: '市场',
8
8
  skillsTitle: '技能管理',
9
- skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills)可在此新建、修改、删除,文件被监听,保存即生效。若存在 ~/.claude/skills 或 ~/.codex/skills,会自动纳入扫描(零拷贝、实时同步)。卡片上的开关控制是否加载:停用即从模型与用户目录同时摘除,开启恢复默认。',
9
+ skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills)可在此新建、修改、删除,市场/本地仓库安装的技能也能就地编辑(写回时保留文件里其余 frontmatter 键),文件被监听,保存即生效。若存在 ~/.claude/skills 或 ~/.codex/skills,会自动纳入扫描(零拷贝、实时同步)。卡片上的开关控制是否加载:停用即从模型与用户目录同时摘除,开启恢复默认。',
10
10
  newSkill: '新建技能',
11
11
  editSkill: '编辑技能',
12
12
  viewSkill: '查看技能',
@@ -14,6 +14,8 @@ export const zh = {
14
14
  skillDescription: '描述',
15
15
  skillWhenToUse: '使用时机(可选)',
16
16
  skillContent: '正文(Markdown)',
17
+ skillPreview: 'Markdown 预览',
18
+ skillPlainText: '纯文本',
17
19
  modelInvocable: '允许模型调用',
18
20
  userInvocable: '允许用户 / 调用',
19
21
  save: '保存',
@@ -137,7 +139,7 @@ export const en = {
137
139
  mcpTab: 'MCP',
138
140
  marketTab: 'Market',
139
141
  skillsTitle: 'Skills',
140
- skillsIntro: 'View and edit the skills dsh discovers; user-level skills (DSH_HOME/skills) can be created, edited, and deleted here the directory is watched, saves apply without a restart. ~/.claude/skills and ~/.codex/skills are scanned too when present (zero-copy, live-synced). The switch on each card toggles loading: disabling removes the skill from both the model and user catalogs; enabling restores the default policy.',
142
+ skillsIntro: 'View and edit the skills dsh discovers; user-level skills (DSH_HOME/skills) can be created, edited, and deleted here, and skills installed from market/local repositories edit in place too (their other frontmatter keys survive the write). ~/.claude/skills and ~/.codex/skills are scanned too when present (zero-copy, live-synced). The switch on each card toggles loading: disabling removes the skill from both the model and user catalogs; enabling restores the default policy.',
141
143
  newSkill: 'New skill',
142
144
  editSkill: 'Edit skill',
143
145
  viewSkill: 'View skill',
@@ -145,6 +147,8 @@ export const en = {
145
147
  skillDescription: 'Description',
146
148
  skillWhenToUse: 'When to use (optional)',
147
149
  skillContent: 'Body (Markdown)',
150
+ skillPreview: 'Markdown preview',
151
+ skillPlainText: 'Plain text',
148
152
  modelInvocable: 'Model-invocable',
149
153
  userInvocable: 'User-invocable (/)',
150
154
  save: 'Save',
package/src/routes.ts CHANGED
@@ -2,26 +2,51 @@
2
2
 
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import { mkdirSync } from 'node:fs'
5
+ import { join, sep } from 'node:path'
5
6
  import { scanAllMcp } from './agents.ts'
6
7
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
7
8
  import { loadMarketIndex, type MarketMcpServer } from './market.ts'
8
9
  import { openDirectory } from './opener.ts'
9
10
  import { addGitRepo, addLocalRepo, rootExists } from './repos.ts'
10
11
  import { dshLaunch, restartOwnedByShell, scheduleRestart, trustedRestartRequest } from './restart.ts'
11
- import { deleteSkill, setSkillPolicy, userSkillsDir, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
12
+ import { deleteSkill, setSkillPolicy, updateSkillFile, userSkillsDir, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
12
13
  import { findRootByUrl, loadState, pluginStateDir, removeSkillRoot, type SkillRootEntry } from './state.ts'
13
14
  import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
14
15
  import type { CapabilitiesHost, HostSkill } from './types.ts'
15
16
 
16
- /** Only this source is writable from the Settings page (provider rank 400). */
17
- const EDITABLE_SOURCE = 'user-dsh'
17
+ /**
18
+ * A 'custom' skill is writable only when its folder sits inside a root this
19
+ * plugin manages: the materialized repositories under the plugin state dir,
20
+ * or a registered local root. Vendored skills shipped inside the plugin
21
+ * package (under node_modules) are custom-sourced too but stay read-only —
22
+ * edits there would die with the next plugin update.
23
+ */
24
+ function customSkillWritable(dir: string, dshHome: string | undefined): boolean {
25
+ const state = pluginStateDir(dshHome)
26
+ if (dir === state || dir.startsWith(state + sep)) return true
27
+ return loadState(dshHome).skillRoots.some(entry =>
28
+ entry.roots.some(root => dir === root || dir.startsWith(root + sep)))
29
+ }
30
+
31
+ /** Whether the save route may write this catalog row back to disk. */
32
+ function skillWritable(skill: HostSkill, dir: string | undefined, dshHome: string | undefined): boolean {
33
+ if (dir === undefined) return false
34
+ if (skill.source === 'user-dsh') return true
35
+ return skill.source === 'custom' && customSkillWritable(dir, dshHome)
36
+ }
18
37
 
19
38
  /** One catalog skill as the browser sees it (editable/dir flags added). */
20
- type SkillRow = HostSkill & { editable: boolean; dir?: string; policyEditable: boolean }
39
+ type SkillRow = HostSkill & { editable: boolean; removable: boolean; dir?: string; policyEditable: boolean }
21
40
 
22
- function toSkillRow(skill: HostSkill): SkillRow {
41
+ function toSkillRow(skill: HostSkill, dshHome: string | undefined): SkillRow {
23
42
  const dir = skill.resourceBase?.kind === 'directory' ? skill.resourceBase.path : undefined
24
- return { ...skill, editable: skill.source === EDITABLE_SOURCE, ...(dir !== undefined ? { dir } : {}), policyEditable: dir !== undefined }
43
+ return {
44
+ ...skill,
45
+ editable: skillWritable(skill, dir, dshHome),
46
+ removable: skill.source === 'user-dsh',
47
+ ...(dir !== undefined ? { dir } : {}),
48
+ policyEditable: dir !== undefined,
49
+ }
25
50
  }
26
51
 
27
52
  /** One registered repository plus a liveness flag (roots can go stale). */
@@ -49,7 +74,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
49
74
  }
50
75
  try {
51
76
  const skills = await host.skills.list()
52
- sendJson(response, 200, { skills: skills.map(toSkillRow) })
77
+ sendJson(response, 200, { skills: skills.map(skill => toSkillRow(skill, process.env.DSH_HOME)) })
53
78
  } catch (error) {
54
79
  sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
55
80
  }
@@ -108,7 +133,22 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
108
133
  sendJson(response, 400, { error: invalid })
109
134
  return
110
135
  }
111
- writeSkill(input)
136
+ // An existing skill edits in place (its own folder, whichever
137
+ // editable source it comes from — preserving frontmatter keys the
138
+ // editor does not own); a new name creates in the user root. The
139
+ // file location is resolved server-side from the catalog, never
140
+ // taken from the request.
141
+ const existing = (await host.skills.list()).find(skill => skill.name === input.name)
142
+ if (existing !== undefined) {
143
+ const dir = existing.resourceBase?.kind === 'directory' ? existing.resourceBase.path : undefined
144
+ if (!skillWritable(existing, dir, process.env.DSH_HOME)) {
145
+ sendJson(response, 403, { error: `skills from source '${existing.source}' are read-only` })
146
+ return
147
+ }
148
+ updateSkillFile(join(dir as string, 'SKILL.md'), input)
149
+ } else {
150
+ writeSkill(input)
151
+ }
112
152
  sendJson(response, 200, { ok: true, name: input.name })
113
153
  } catch (error) {
114
154
  sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync, mkdirSync
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterAll, describe, expect, it } from 'vitest'
5
- import { deleteSkill, serializeSkill, setSkillPolicy, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
5
+ import { deleteSkill, serializeSkill, setSkillPolicy, updateSkillFile, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
6
6
 
7
7
  const root = mkdtempSync(join(tmpdir(), 'dsh-caps-skills-'))
8
8
  afterAll(() => rmSync(root, { recursive: true, force: true }))
@@ -58,6 +58,29 @@ describe('writeSkill / deleteSkill', () => {
58
58
  expect(existsSync(join(root, 'skills', 'my-skill'))).toBe(false)
59
59
  expect(deleteSkill('my-skill', root)).toBe(false)
60
60
  })
61
+
62
+ it('updateSkillFile edits in place, preserving keys the editor does not own', () => {
63
+ const dir = join(root, 'keep-skill')
64
+ mkdirSync(dir, { recursive: true })
65
+ const file = join(dir, 'SKILL.md')
66
+ writeFileSync(file, '---\nname: keep-skill\nlicense: MIT\nallowed-tools: Bash\ndescription: "old one"\n---\n\n# Old\n')
67
+ updateSkillFile(file, {
68
+ name: 'keep-skill',
69
+ description: 'new one',
70
+ whenToUse: undefined,
71
+ modelInvocable: false,
72
+ userInvocable: true,
73
+ content: '# New body',
74
+ })
75
+ const after = readFileSync(file, 'utf8')
76
+ expect(after).toContain('license: MIT')
77
+ expect(after).toContain('allowed-tools: Bash')
78
+ expect(after).toContain('description: "new one"')
79
+ expect(after).toContain('disable-model-invocation: true')
80
+ expect(after).not.toContain('user-invocable')
81
+ expect(after).toContain('# New body')
82
+ expect(after).not.toContain('# Old')
83
+ })
61
84
  it('never touches sibling directories on delete', () => {
62
85
  mkdirSync(join(root, 'skills', 'other'), { recursive: true })
63
86
  writeFileSync(join(root, 'skills', 'other', 'SKILL.md'), '---\nname: other\ndescription: d\n---\n\nx')
package/src/skills.ts CHANGED
@@ -100,3 +100,26 @@ export function setSkillPolicy(file: string, enabled: boolean): void {
100
100
  if (!enabled) kept.push('disable-model-invocation: true', 'user-invocable: false')
101
101
  writeFileSync(file, `---${newline}${kept.join(newline)}${newline}---${body}`, 'utf8')
102
102
  }
103
+
104
+ /**
105
+ * Edit an existing skill file in place — for skills that do not live in the
106
+ * user root (market/custom repositories). Only the keys the editor owns
107
+ * (name, description, whenToUse, the two invocation flags) are rewritten;
108
+ * every other frontmatter key — license, allowed-tools, anything a
109
+ * repository ships — survives in order, like setSkillPolicy. The body is
110
+ * replaced by the editor's content.
111
+ */
112
+ export function updateSkillFile(file: string, input: SkillInput): void {
113
+ const text = readFileSync(file, 'utf8')
114
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
115
+ if (match === null) throw new Error('skill file has no frontmatter block')
116
+ const newline = text.includes('\r\n---') ? '\r\n' : '\n'
117
+ const lines = match[1].split(/\r?\n/)
118
+ const kept = lines.filter(line => !/^(name|description|whenToUse|disable-model-invocation|user-invocable):/.test(line))
119
+ const head = [`name: ${input.name}`, `description: ${quote(input.description)}`]
120
+ if (input.whenToUse !== undefined && input.whenToUse !== '') head.push(`whenToUse: ${quote(input.whenToUse)}`)
121
+ if (!input.modelInvocable) kept.push('disable-model-invocation: true')
122
+ if (!input.userInvocable) kept.push('user-invocable: false')
123
+ const body = input.content.replace(/\r\n/g, '\n').trim()
124
+ writeFileSync(file, `---${newline}${[...head, ...kept].join(newline)}${newline}---${newline}${newline}${body}${newline}`, 'utf8')
125
+ }
package/src/smoke.test.ts CHANGED
@@ -158,7 +158,7 @@ describe.skipIf(process.env.DSH_DESKTOP_PLUGIN_SMOKE !== '1' || !guard || !nodeO
158
158
  // skill enters the catalog under source "custom".
159
159
  const repoDir = join(smokeRoot, 'my-repo')
160
160
  mkdirSync(join(repoDir, 'repo-skill'), { recursive: true })
161
- writeFileSync(join(repoDir, 'repo-skill', 'SKILL.md'), '---\nname: repo-skill\ndescription: from a local repo\n---\n\nhi')
161
+ writeFileSync(join(repoDir, 'repo-skill', 'SKILL.md'), '---\nname: repo-skill\ndescription: from a local repo\nlicense: MIT\n---\n\nhi')
162
162
  const addRoot = await post('/dsh-plugin-capabilities/roots/add', { kind: 'local', path: repoDir })
163
163
  expect(addRoot.status).toBe(200)
164
164
  expect(readFileSync(join(smokeRoot, 'dsh-plugin-capabilities', 'state.json'), 'utf8')).toContain('my-repo') // roots recorded
@@ -185,6 +185,21 @@ describe.skipIf(process.env.DSH_DESKTOP_PLUGIN_SMOKE !== '1' || !guard || !nodeO
185
185
  const policyRestore = await post('/dsh-plugin-capabilities/skill/policy', { name: 'repo-skill', enabled: true })
186
186
  expect(policyRestore.status).toBe(200)
187
187
 
188
+ // 7b. Custom-repo skills edit in place through the save route; the
189
+ // frontmatter keys the editor does not own (license) survive the write.
190
+ const edit = await post('/dsh-plugin-capabilities/skill/save', {
191
+ name: 'repo-skill',
192
+ description: 'edited in place',
193
+ modelInvocable: true,
194
+ userInvocable: true,
195
+ content: '# Edited\n\nnew body',
196
+ })
197
+ expect(edit.status).toBe(200)
198
+ const editedFile = readFileSync(join(repoDir, 'repo-skill', 'SKILL.md'), 'utf8')
199
+ expect(editedFile).toContain('license: MIT')
200
+ expect(editedFile).toContain('description: "edited in place"')
201
+ expect(editedFile).toContain('# Edited')
202
+
188
203
  // 8. Market: MCP index serves (remote or bundled fallback) and one-click
189
204
  // install writes a profile row.
190
205
  const marketIndex = await fetch(`${base}/dsh-plugin-capabilities/market/mcp`)