dsh-plugin-capabilities 0.3.3 → 0.3.5
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.md +1 -1
- package/lib/client.js +3134 -324
- package/lib/client.js.map +4 -4
- package/lib/index.js +133 -56
- package/lib/index.js.map +4 -4
- package/package.json +3 -1
- package/src/client/MarkdownPreview.tsx +21 -0
- package/src/client/SkillsTab.tsx +7 -5
- package/src/client/css.ts +15 -0
- package/src/client/locales.ts +2 -2
- package/src/client/primitives.d.ts +0 -6
- package/src/repos.test.ts +5 -2
- package/src/repos.ts +6 -5
- package/src/rmtree.test.ts +26 -0
- package/src/rmtree.ts +46 -0
- package/src/routes.ts +56 -10
- package/src/skills.test.ts +24 -1
- package/src/skills.ts +23 -0
- package/src/smoke.test.ts +16 -1
- package/src/state.test.ts +8 -3
- package/src/state.ts +12 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-capabilities",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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
|
+
}
|
package/src/client/SkillsTab.tsx
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { useEffect, useState } from 'react'
|
|
7
7
|
import type { ReactElement } from 'react'
|
|
8
|
-
import { Button, IconRefreshOutline14, IconSkillOutline16,
|
|
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. */
|
|
@@ -369,7 +373,7 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
369
373
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void openExisting(skill)}>
|
|
370
374
|
{skill.editable ? t('edit') : t('view')}
|
|
371
375
|
</Button>
|
|
372
|
-
{skill.
|
|
376
|
+
{skill.removable && (
|
|
373
377
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmName(skill.name)}>{t('delete')}</Button>
|
|
374
378
|
)}
|
|
375
379
|
</div>
|
|
@@ -505,9 +509,7 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
505
509
|
</div>
|
|
506
510
|
</div>
|
|
507
511
|
{preview
|
|
508
|
-
?
|
|
509
|
-
? <div className="dpc-mdPreview"><MarkdownText text={editor.content} /></div>
|
|
510
|
-
: <textarea className="dpc-textarea" value={editor.content} readOnly />)
|
|
512
|
+
? <div className="dpc-mdPreview"><MarkdownPreview text={editor.content} /></div>
|
|
511
513
|
: (
|
|
512
514
|
<textarea
|
|
513
515
|
className="dpc-textarea"
|
package/src/client/css.ts
CHANGED
|
@@ -62,6 +62,21 @@ export const CSS = `
|
|
|
62
62
|
.dpc-modalForm.dpc-modalForm{width:min(760px,100%)}
|
|
63
63
|
.dpc-modalScroll.dpc-modalScroll{max-height:calc(100vh - 160px);overflow-y:auto}
|
|
64
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}
|
|
65
80
|
.dpc-importScroll{display:flex;flex-direction:column;gap:14px;max-height:min(400px,52vh);overflow-y:auto;padding:2px 4px 2px 2px}
|
|
66
81
|
.dpc-importGroup{display:flex;flex-direction:column;gap:8px}
|
|
67
82
|
.dpc-importHead{display:flex;align-items:center;gap:8px;padding:0 2px}
|
package/src/client/locales.ts
CHANGED
|
@@ -6,7 +6,7 @@ export const zh = {
|
|
|
6
6
|
mcpTab: 'MCP',
|
|
7
7
|
marketTab: '市场',
|
|
8
8
|
skillsTitle: '技能管理',
|
|
9
|
-
skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills
|
|
9
|
+
skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills)可在此新建、修改、删除,市场/本地仓库安装的技能也能就地编辑(写回时保留文件里其余 frontmatter 键),文件被监听,保存即生效。若存在 ~/.claude/skills 或 ~/.codex/skills,会自动纳入扫描(零拷贝、实时同步)。卡片上的开关控制是否加载:停用即从模型与用户目录同时摘除,开启恢复默认。',
|
|
10
10
|
newSkill: '新建技能',
|
|
11
11
|
editSkill: '编辑技能',
|
|
12
12
|
viewSkill: '查看技能',
|
|
@@ -139,7 +139,7 @@ export const en = {
|
|
|
139
139
|
mcpTab: 'MCP',
|
|
140
140
|
marketTab: 'Market',
|
|
141
141
|
skillsTitle: 'Skills',
|
|
142
|
-
skillsIntro: 'View and edit the skills dsh discovers; user-level skills (DSH_HOME/skills) can be created, edited, and deleted here
|
|
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.',
|
|
143
143
|
newSkill: 'New skill',
|
|
144
144
|
editSkill: 'Edit skill',
|
|
145
145
|
viewSkill: 'View skill',
|
|
@@ -40,12 +40,6 @@ declare module '@deepseek-ai/dsh-client-ui-primitives' {
|
|
|
40
40
|
className?: string | undefined
|
|
41
41
|
}): ReactElement
|
|
42
42
|
|
|
43
|
-
/** Untrusted-Markdown renderer over the app's own mdast pipeline. */
|
|
44
|
-
export function MarkdownText(props: {
|
|
45
|
-
text: string
|
|
46
|
-
streaming?: boolean | undefined
|
|
47
|
-
}): ReactElement
|
|
48
|
-
|
|
49
43
|
export function IconRefreshOutline14(props: {
|
|
50
44
|
className?: string | undefined
|
|
51
45
|
size?: number | undefined
|
package/src/repos.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
6
6
|
import { addGitRepo, addLocalRepo, detectSkillRoots, parseGitHubSource } from './repos.ts'
|
|
7
7
|
import { loadState, removeSkillRoot } from './state.ts'
|
|
8
|
+
import { removeTree } from './rmtree.ts'
|
|
8
9
|
|
|
9
10
|
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-repos-'))
|
|
10
11
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
@@ -129,8 +130,10 @@ describe('addLocalRepo', () => {
|
|
|
129
130
|
expect(children).toHaveLength(1)
|
|
130
131
|
expect(readFileSync(join(entry.roots[0], children[0], 'SKILL.md'), 'utf8')).toContain('solo')
|
|
131
132
|
|
|
132
|
-
// Removing the entry
|
|
133
|
-
|
|
133
|
+
// Removing the entry deregisters; the caller then deletes the material
|
|
134
|
+
// dir (the junction wrapper) — never the linked source.
|
|
135
|
+
expect(removeSkillRoot(entry.id, home)).toMatchObject({ id: entry.id })
|
|
136
|
+
removeTree(entry.roots[0])
|
|
134
137
|
expect(existsSync(entry.roots[0])).toBe(false)
|
|
135
138
|
expect(existsSync(join(source, 'SKILL.md'))).toBe(true)
|
|
136
139
|
})
|
package/src/repos.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* provider.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync,
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync } from 'node:fs'
|
|
12
12
|
import { basename, join, resolve } from 'node:path'
|
|
13
|
+
import { removeTree } from './rmtree.ts'
|
|
13
14
|
import { addSkillRoot, findRootByUrl, materialDirFor, newEntryId, type SkillRootEntry } from './state.ts'
|
|
14
15
|
import { extractTarGz } from './tar.ts'
|
|
15
16
|
|
|
@@ -141,12 +142,12 @@ export async function addLocalRepo(path: string, dshHome?: string): Promise<Skil
|
|
|
141
142
|
// the path in a dedicated directory holding a single link to it.
|
|
142
143
|
const id = newEntryId('local')
|
|
143
144
|
const material = materialDirFor(id, dshHome)
|
|
144
|
-
|
|
145
|
+
removeTree(material)
|
|
145
146
|
mkdirSync(material, { recursive: true })
|
|
146
147
|
try {
|
|
147
148
|
symlinkSync(resolved, join(material, 'skill'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
148
149
|
} catch {
|
|
149
|
-
|
|
150
|
+
removeTree(material)
|
|
150
151
|
throw new Error('single-skill local folders need a directory link; try adding their parent folder instead')
|
|
151
152
|
}
|
|
152
153
|
return addSkillRoot({ id, kind: 'local', label: basename(resolved), path: resolved, roots: [material], materialDir: material }, dshHome)
|
|
@@ -175,7 +176,7 @@ export async function addGitRepo(
|
|
|
175
176
|
|
|
176
177
|
const id = newEntryId('git')
|
|
177
178
|
const material = materialDirFor(id, options.dshHome)
|
|
178
|
-
|
|
179
|
+
removeTree(material)
|
|
179
180
|
const checkout = join(material, 'repo')
|
|
180
181
|
try {
|
|
181
182
|
mkdirSync(checkout, { recursive: true })
|
|
@@ -192,7 +193,7 @@ export async function addGitRepo(
|
|
|
192
193
|
options.dshHome,
|
|
193
194
|
)
|
|
194
195
|
} catch (error) {
|
|
195
|
-
|
|
196
|
+
removeTree(material)
|
|
196
197
|
throw error
|
|
197
198
|
}
|
|
198
199
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
import { removeTree } from './rmtree.ts'
|
|
6
|
+
|
|
7
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-rmtree-'))
|
|
8
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
9
|
+
|
|
10
|
+
describe('removeTree', () => {
|
|
11
|
+
it('deletes a tree holding read-only files and folders', () => {
|
|
12
|
+
const dir = join(root, 'readonly')
|
|
13
|
+
mkdirSync(join(dir, 'nested'), { recursive: true })
|
|
14
|
+
writeFileSync(join(dir, 'nested', 'SKILL.md'), '---\nname: ro\n---\n')
|
|
15
|
+
// Read-only entries are the Windows EPERM source: plain rmSync cannot
|
|
16
|
+
// unlink them, removeTree clears the attribute before removing.
|
|
17
|
+
chmodSync(join(dir, 'nested', 'SKILL.md'), 0o444)
|
|
18
|
+
chmodSync(join(dir, 'nested'), 0o555)
|
|
19
|
+
removeTree(dir)
|
|
20
|
+
expect(existsSync(dir)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('treats a missing path as done (force semantics)', () => {
|
|
24
|
+
expect(() => removeTree(join(root, 'never-existed'))).not.toThrow()
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/rmtree.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows-hardened recursive delete for repository material. The skill
|
|
3
|
+
* provider watches these trees live, and antivirus or search indexers
|
|
4
|
+
* briefly hold handles onto freshly written files, so a plain rmSync
|
|
5
|
+
* races open handles and dies with EPERM. Two mitigations: clear the
|
|
6
|
+
* read-only attribute first (unlinking a read-only entry fails on
|
|
7
|
+
* Windows), then retry the removal so short-lived locks run out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, readdirSync, rmSync, type Dirent } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** Windows lock holders usually let go within a second or two. */
|
|
14
|
+
const RETRIES = { maxRetries: 10, retryDelay: 200 } as const
|
|
15
|
+
|
|
16
|
+
function clearReadOnly(dir: string): void {
|
|
17
|
+
let entries: Dirent[]
|
|
18
|
+
try {
|
|
19
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
20
|
+
} catch {
|
|
21
|
+
return // unreadable: rmSync will surface the real error
|
|
22
|
+
}
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const child = join(dir, entry.name)
|
|
25
|
+
// Links read as neither file nor directory here; chmod would hit the
|
|
26
|
+
// link TARGET (possibly the user's own folder), so they are skipped —
|
|
27
|
+
// rmSync removes the link itself without following it.
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
try { chmodSync(child, 0o777) } catch { /* racing delete */ }
|
|
30
|
+
clearReadOnly(child)
|
|
31
|
+
} else if (entry.isFile()) {
|
|
32
|
+
try { chmodSync(child, 0o666) } catch { /* racing delete */ }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try { chmodSync(dir, 0o777) } catch { /* racing delete */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Delete one directory tree (or lone file), tolerating Windows lock races. */
|
|
39
|
+
export function removeTree(path: string): void {
|
|
40
|
+
try {
|
|
41
|
+
clearReadOnly(path)
|
|
42
|
+
} catch {
|
|
43
|
+
// walk failure: rmSync reports the underlying error anyway
|
|
44
|
+
}
|
|
45
|
+
rmSync(path, { recursive: true, force: true, ...RETRIES })
|
|
46
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -2,26 +2,52 @@
|
|
|
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'
|
|
14
|
+
import { removeTree } from './rmtree.ts'
|
|
13
15
|
import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
14
16
|
import type { CapabilitiesHost, HostSkill } from './types.ts'
|
|
15
17
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A 'custom' skill is writable only when its folder sits inside a root this
|
|
20
|
+
* plugin manages: the materialized repositories under the plugin state dir,
|
|
21
|
+
* or a registered local root. Vendored skills shipped inside the plugin
|
|
22
|
+
* package (under node_modules) are custom-sourced too but stay read-only —
|
|
23
|
+
* edits there would die with the next plugin update.
|
|
24
|
+
*/
|
|
25
|
+
function customSkillWritable(dir: string, dshHome: string | undefined): boolean {
|
|
26
|
+
const state = pluginStateDir(dshHome)
|
|
27
|
+
if (dir === state || dir.startsWith(state + sep)) return true
|
|
28
|
+
return loadState(dshHome).skillRoots.some(entry =>
|
|
29
|
+
entry.roots.some(root => dir === root || dir.startsWith(root + sep)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether the save route may write this catalog row back to disk. */
|
|
33
|
+
function skillWritable(skill: HostSkill, dir: string | undefined, dshHome: string | undefined): boolean {
|
|
34
|
+
if (dir === undefined) return false
|
|
35
|
+
if (skill.source === 'user-dsh') return true
|
|
36
|
+
return skill.source === 'custom' && customSkillWritable(dir, dshHome)
|
|
37
|
+
}
|
|
18
38
|
|
|
19
39
|
/** One catalog skill as the browser sees it (editable/dir flags added). */
|
|
20
|
-
type SkillRow = HostSkill & { editable: boolean; dir?: string; policyEditable: boolean }
|
|
40
|
+
type SkillRow = HostSkill & { editable: boolean; removable: boolean; dir?: string; policyEditable: boolean }
|
|
21
41
|
|
|
22
|
-
function toSkillRow(skill: HostSkill): SkillRow {
|
|
42
|
+
function toSkillRow(skill: HostSkill, dshHome: string | undefined): SkillRow {
|
|
23
43
|
const dir = skill.resourceBase?.kind === 'directory' ? skill.resourceBase.path : undefined
|
|
24
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
...skill,
|
|
46
|
+
editable: skillWritable(skill, dir, dshHome),
|
|
47
|
+
removable: skill.source === 'user-dsh',
|
|
48
|
+
...(dir !== undefined ? { dir } : {}),
|
|
49
|
+
policyEditable: dir !== undefined,
|
|
50
|
+
}
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
/** One registered repository plus a liveness flag (roots can go stale). */
|
|
@@ -49,7 +75,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
49
75
|
}
|
|
50
76
|
try {
|
|
51
77
|
const skills = await host.skills.list()
|
|
52
|
-
sendJson(response, 200, { skills: skills.map(toSkillRow) })
|
|
78
|
+
sendJson(response, 200, { skills: skills.map(skill => toSkillRow(skill, process.env.DSH_HOME)) })
|
|
53
79
|
} catch (error) {
|
|
54
80
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
55
81
|
}
|
|
@@ -108,7 +134,22 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
108
134
|
sendJson(response, 400, { error: invalid })
|
|
109
135
|
return
|
|
110
136
|
}
|
|
111
|
-
|
|
137
|
+
// An existing skill edits in place (its own folder, whichever
|
|
138
|
+
// editable source it comes from — preserving frontmatter keys the
|
|
139
|
+
// editor does not own); a new name creates in the user root. The
|
|
140
|
+
// file location is resolved server-side from the catalog, never
|
|
141
|
+
// taken from the request.
|
|
142
|
+
const existing = (await host.skills.list()).find(skill => skill.name === input.name)
|
|
143
|
+
if (existing !== undefined) {
|
|
144
|
+
const dir = existing.resourceBase?.kind === 'directory' ? existing.resourceBase.path : undefined
|
|
145
|
+
if (!skillWritable(existing, dir, process.env.DSH_HOME)) {
|
|
146
|
+
sendJson(response, 403, { error: `skills from source '${existing.source}' are read-only` })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
updateSkillFile(join(dir as string, 'SKILL.md'), input)
|
|
150
|
+
} else {
|
|
151
|
+
writeSkill(input)
|
|
152
|
+
}
|
|
112
153
|
sendJson(response, 200, { ok: true, name: input.name })
|
|
113
154
|
} catch (error) {
|
|
114
155
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -314,12 +355,17 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
314
355
|
sendJson(response, 400, { error: 'id is required' })
|
|
315
356
|
return
|
|
316
357
|
}
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
358
|
+
const removed = removeSkillRoot(body.id)
|
|
359
|
+
if (removed === undefined) {
|
|
319
360
|
sendJson(response, 404, { error: 'repository not found' })
|
|
320
361
|
return
|
|
321
362
|
}
|
|
363
|
+
// Unwatch before unlink: the provider's directory watchers hold
|
|
364
|
+
// handles on the material tree, and removing a watched tree on
|
|
365
|
+
// Windows fails with EPERM (the state is already saved by then,
|
|
366
|
+
// which is why the repo still disappears despite the error).
|
|
322
367
|
await config.remountProvider()
|
|
368
|
+
if (removed.materialDir !== undefined) removeTree(removed.materialDir)
|
|
323
369
|
sendJson(response, 200, { ok: true })
|
|
324
370
|
} catch (error) {
|
|
325
371
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
package/src/skills.test.ts
CHANGED
|
@@ -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`)
|
package/src/state.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
5
|
import { addSkillRoot, findRootByUrl, loadState, materialDirFor, newEntryId, removeSkillRoot, saveState } from './state.ts'
|
|
6
|
+
import { removeTree } from './rmtree.ts'
|
|
6
7
|
|
|
7
8
|
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-state-'))
|
|
8
9
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
@@ -23,8 +24,8 @@ describe('state round-trip', () => {
|
|
|
23
24
|
expect(findRootByUrl('a/b', home)?.id).toBe(entry.id)
|
|
24
25
|
expect(findRootByUrl('other/repo', home)).toBeUndefined()
|
|
25
26
|
|
|
26
|
-
expect(removeSkillRoot(entry.id, home)).
|
|
27
|
-
expect(removeSkillRoot(entry.id, home)).
|
|
27
|
+
expect(removeSkillRoot(entry.id, home)).toMatchObject({ id: entry.id })
|
|
28
|
+
expect(removeSkillRoot(entry.id, home)).toBeUndefined()
|
|
28
29
|
expect(loadState(home).skillRoots).toHaveLength(0)
|
|
29
30
|
})
|
|
30
31
|
|
|
@@ -55,7 +56,11 @@ describe('removeSkillRoot with link material', () => {
|
|
|
55
56
|
symlinkSync(source, join(material, 'skill'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
56
57
|
addSkillRoot({ id, kind: 'local', label: 'keep', path: source, roots: [material], materialDir: material }, home)
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
// Deregistration only drops the state row; the caller deletes the
|
|
60
|
+
// material itself once the provider stopped watching it.
|
|
61
|
+
expect(removeSkillRoot(id, home)).toMatchObject({ id })
|
|
62
|
+
expect(existsSync(material)).toBe(true)
|
|
63
|
+
removeTree(material)
|
|
59
64
|
expect(existsSync(material)).toBe(false)
|
|
60
65
|
expect(existsSync(join(source, 'SKILL.md'))).toBe(true)
|
|
61
66
|
// State file persisted without the entry.
|
package/src/state.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* off this file, so it stays small, JSON, and hand-recoverable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { randomBytes } from 'node:crypto'
|
|
@@ -90,17 +90,21 @@ export function addSkillRoot(entry: Omit<SkillRootEntry, 'addedAt'>, dshHome?: s
|
|
|
90
90
|
return stored
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Deregister one entry by id and persist immediately. The material dir is
|
|
95
|
+
* deliberately NOT deleted here: the skill provider still watches it, and
|
|
96
|
+
* removing a watched tree on Windows fails with EPERM even though the
|
|
97
|
+
* state is already saved. The caller unmounts (remountProvider) first,
|
|
98
|
+
* then runs removeTree over the returned entry's materialDir. Returns the
|
|
99
|
+
* removed entry, or undefined when the id is unknown.
|
|
100
|
+
*/
|
|
101
|
+
export function removeSkillRoot(id: string, dshHome?: string): SkillRootEntry | undefined {
|
|
95
102
|
const state = loadState(dshHome)
|
|
96
103
|
const at = state.skillRoots.findIndex(entry => entry.id === id)
|
|
97
|
-
if (at === -1) return
|
|
104
|
+
if (at === -1) return undefined
|
|
98
105
|
const [removed] = state.skillRoots.splice(at, 1)
|
|
99
106
|
saveState(state, dshHome)
|
|
100
|
-
|
|
101
|
-
rmSync(removed.materialDir, { recursive: true, force: true, maxRetries: 2 })
|
|
102
|
-
}
|
|
103
|
-
return true
|
|
107
|
+
return removed
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
/** Whether a git URL is already registered (market “installed” state). */
|