dsh-skillpie 0.0.0-stage → 1.0.0
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 +27 -2
- package/cordis.patch.yml +9 -0
- package/icon.svg +51 -0
- package/lib/client.js +185 -0
- package/lib/index.js +153 -0
- package/lib/market.js +350 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-skillpie
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness(DSH)插件:左侧栏「技能市场」入口。浏览并安装 [SkillPie](https://skillpie.cn) 技能库中的 Agent 技能,一键装入用户级技能目录(`~/.agents/skills/<normalizedName>`),新开会话即可使用。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 左侧栏面板图标「技能市场」→ 主区**内嵌 SkillPie 整站页面**(`https://skillpie.cn/skills`),与 ZCode 的 skill-market 主视图同构:侧栏保持可见,像内嵌浏览器一样直接访问网站。
|
|
8
|
+
- 顶部细工具条:刷新页面、在浏览器中打开(兜底)。
|
|
9
|
+
- Host 半提供 `POST /dsh-skillpie/api`(search / detail / install / installed),供后续对话工具或程序化安装复用;技能安装到 `~/.agents/skills/<normalizedName>`。
|
|
10
|
+
|
|
11
|
+
## 安全
|
|
12
|
+
|
|
13
|
+
- 安装包(zip)逐条目校验路径安全,拒绝绝对路径与 `..` 穿越,包体积与解压总量、条目数均有上限。
|
|
14
|
+
- 目标目录名只允许单个安全路径段,由服务端 `normalizedName` 给出。
|
|
15
|
+
- Host API(`/dsh-skillpie/api`)仅限同源页面访问,且位于 Web GUI 会话鉴权之后;安装动作只能由已登录用户在面板里发起。
|
|
16
|
+
|
|
17
|
+
## 配置
|
|
18
|
+
|
|
19
|
+
在 profile 的 `cordis.patch.yml` 覆盖该行 `config`:
|
|
20
|
+
|
|
21
|
+
| 键 | 默认 | 说明 |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `skillRoot` | `~/.agents/skills` | 技能安装根目录 |
|
|
24
|
+
| `baseUrl` | `https://skillpie.cn` | SkillPie 服务地址(自部署可覆盖) |
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-skillpie bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# One dual-face row: the host half serves POST /dsh-skillpie/api (search,
|
|
4
|
+
# detail, install into ~/.agents/skills), the client half registers the
|
|
5
|
+
# sidebar panel icon and the "Skill marketplace" main view.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: dsh-skillpie
|
|
8
|
+
name: "dsh-skillpie"
|
|
9
|
+
config: {}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<svg width="512" height="512" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<!-- 背景渐变 -->
|
|
4
|
+
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
5
|
+
<stop offset="0%" stop-color="#6A6DFF"/>
|
|
6
|
+
<stop offset="100%" stop-color="#B85EFF"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
|
|
9
|
+
<!-- π 符号渐变 -->
|
|
10
|
+
<linearGradient id="piGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
11
|
+
<stop offset="0%" stop-color="#ffffff"/>
|
|
12
|
+
<stop offset="100%" stop-color="#f0f0ff"/>
|
|
13
|
+
</linearGradient>
|
|
14
|
+
|
|
15
|
+
<!-- 黄色星星渐变 -->
|
|
16
|
+
<linearGradient id="starGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
17
|
+
<stop offset="0%" stop-color="#fbbf24"/>
|
|
18
|
+
<stop offset="100%" stop-color="#f59e0b"/>
|
|
19
|
+
</linearGradient>
|
|
20
|
+
|
|
21
|
+
<!-- 阴影效果 -->
|
|
22
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
23
|
+
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#000000" flood-opacity="0.2"/>
|
|
24
|
+
</filter>
|
|
25
|
+
</defs>
|
|
26
|
+
|
|
27
|
+
<!-- 圆角矩形背景 -->
|
|
28
|
+
<rect x="10" y="10" width="180" height="180" rx="40" fill="url(#bgGrad)" filter="url(#shadow)"/>
|
|
29
|
+
|
|
30
|
+
<!-- π 符号 - 圆润粗体样式(放大0.8倍) -->
|
|
31
|
+
<g transform="translate(97, 90) scale(1.8)" filter="url(#shadow)">
|
|
32
|
+
<!-- 顶部横线(带阴影) -->
|
|
33
|
+
<rect x="-32" y="-22" width="64" height="12" rx="6" fill="url(#piGrad)"/>
|
|
34
|
+
|
|
35
|
+
<!-- 左腿 -->
|
|
36
|
+
<rect x="-22" y="-16" width="12" height="52" rx="6" fill="url(#piGrad)"/>
|
|
37
|
+
|
|
38
|
+
<!-- 右腿 -->
|
|
39
|
+
<rect x="10" y="-16" width="12" height="38" rx="6" fill="url(#piGrad)"/>
|
|
40
|
+
</g>
|
|
41
|
+
|
|
42
|
+
<!-- 顶部横线(无阴影覆盖层) -->
|
|
43
|
+
<g transform="translate(97, 90) scale(1.8)">
|
|
44
|
+
<rect x="-32" y="-22" width="64" height="12" rx="6" fill="url(#piGrad)"/>
|
|
45
|
+
</g>
|
|
46
|
+
|
|
47
|
+
<!-- 黄色四角星(放大0.8倍) -->
|
|
48
|
+
<g transform="translate(161, 65) scale(1.224)" filter="url(#shadow)">
|
|
49
|
+
<path d="M 0 -16 Q 1 -1 10 0 Q 1 1 0 16 Q -1 1 -10 0 Q -1 -1 0 -16" fill="url(#starGrad)"/>
|
|
50
|
+
</g>
|
|
51
|
+
</svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skillpie — Web client half (module-loader bundle).
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - sidebar.panellist icon "skillpie-market" (opens the main view),
|
|
6
|
+
* - the "Skill marketplace" main view: the SkillPie site itself, embedded
|
|
7
|
+
* like an in-app browser page (ZCode's skill-market main view), with a
|
|
8
|
+
* thin toolbar for reload / open-in-browser.
|
|
9
|
+
*
|
|
10
|
+
* The host half's JSON API (/dsh-skillpie/api) stays available for
|
|
11
|
+
* programmatic search/install, e.g. future agent tools.
|
|
12
|
+
*
|
|
13
|
+
* Follows the host UI conventions: theme tokens only, locale service for all
|
|
14
|
+
* visible text, no host primitives imported, no DOM writes outside the React
|
|
15
|
+
* tree. The factory returns the module object (template contract) so the web
|
|
16
|
+
* boot graph can activate it.
|
|
17
|
+
*/
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-skillpie',
|
|
20
|
+
factory(require) {
|
|
21
|
+
var React = require('react')
|
|
22
|
+
var h = React.createElement
|
|
23
|
+
var NS = 'dsh-skillpie'
|
|
24
|
+
var MARKET_URL = 'https://skillpie.cn/skills'
|
|
25
|
+
|
|
26
|
+
var zh = {
|
|
27
|
+
title: '技能市场',
|
|
28
|
+
reload: '刷新',
|
|
29
|
+
openInBrowser: '在浏览器中打开',
|
|
30
|
+
loading: '正在加载技能市场…',
|
|
31
|
+
loadHint: '如果长时间空白,可能是网络或站点限制;也可以「在浏览器中打开」。',
|
|
32
|
+
}
|
|
33
|
+
var en = {
|
|
34
|
+
title: 'Skill marketplace',
|
|
35
|
+
reload: 'Reload',
|
|
36
|
+
openInBrowser: 'Open in browser',
|
|
37
|
+
loading: 'Loading the SkillPie marketplace…',
|
|
38
|
+
loadHint: 'If this stays blank, check the network or open it in a browser instead.',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── icons (currentColor, theme-aware) ────────────────────────────────
|
|
42
|
+
// ZCodeOnline 同款技能市场图标(lucide Sparkles,24 网格 stroke 风格)。
|
|
43
|
+
function PieIcon(props) {
|
|
44
|
+
var size = props && props.size ? props.size : 16
|
|
45
|
+
return h('svg', {
|
|
46
|
+
viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true,
|
|
47
|
+
fill: 'none', stroke: 'currentColor', 'stroke-width': 1.7,
|
|
48
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
49
|
+
style: { display: 'block', flex: 'none' },
|
|
50
|
+
},
|
|
51
|
+
h('path', { d: 'M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z' }),
|
|
52
|
+
h('path', { d: 'M20 2v4' }),
|
|
53
|
+
h('path', { d: 'M22 4h-4' }),
|
|
54
|
+
h('circle', { cx: 4, cy: 20, r: 2 }),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ReloadIcon() {
|
|
59
|
+
return h('svg', {
|
|
60
|
+
viewBox: '0 0 24 24', width: 14, height: 14, 'aria-hidden': true,
|
|
61
|
+
style: { display: 'block', flex: 'none' },
|
|
62
|
+
}, h('path', {
|
|
63
|
+
d: 'M12 4a8 8 0 0 1 7.75 6h-2.1A6 6 0 0 0 6.6 12H4.25A8 8 0 0 1 12 4zm-7.75 8h2.1a6 6 0 0 0 11.05 2H19.75A8 8 0 0 1 4.25 12zM12 20a8 8 0 0 1-7.75-6h2.1A6 6 0 0 0 17.4 12h2.35A8 8 0 0 1 12 20z',
|
|
64
|
+
fill: 'currentColor',
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Bound per-apply so the locale service resolves the current language.
|
|
69
|
+
var pageT = function (key) { return key }
|
|
70
|
+
|
|
71
|
+
/** Full-bleed embedded marketplace page, sidebar stays visible. */
|
|
72
|
+
function EmbeddedMarket() {
|
|
73
|
+
var t = pageT
|
|
74
|
+
var frameRef = React.useRef(null)
|
|
75
|
+
var loadedState = React.useState(false)
|
|
76
|
+
var loaded = loadedState[0]
|
|
77
|
+
var setLoaded = loadedState[1]
|
|
78
|
+
var tickState = React.useState(0)
|
|
79
|
+
var tick = tickState[0]
|
|
80
|
+
var setTick = tickState[1]
|
|
81
|
+
|
|
82
|
+
var reload = React.useCallback(function () {
|
|
83
|
+
setLoaded(false)
|
|
84
|
+
setTick(function (value) { return value + 1 })
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
return h('div', {
|
|
88
|
+
style: {
|
|
89
|
+
height: '100%', minHeight: 0, boxSizing: 'border-box',
|
|
90
|
+
background: 'var(--dsw-alias-bg-base)', color: 'var(--dsw-alias-label-primary)',
|
|
91
|
+
display: 'flex', flexDirection: 'column',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
h('div', {
|
|
95
|
+
style: {
|
|
96
|
+
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px',
|
|
97
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
h(PieIcon, { size: 16 }),
|
|
101
|
+
h('span', { style: { fontSize: 13, fontWeight: 600 } }, t('title')),
|
|
102
|
+
h('span', { style: { flex: 1 } }),
|
|
103
|
+
h('button', {
|
|
104
|
+
type: 'button', onClick: reload,
|
|
105
|
+
title: t('reload'), 'aria-label': t('reload'),
|
|
106
|
+
style: {
|
|
107
|
+
display: 'inline-flex', alignItems: 'center', gap: 6, height: 26, padding: '0 10px',
|
|
108
|
+
borderRadius: 7, border: '1px solid var(--dsw-alias-border-l1)', cursor: 'pointer',
|
|
109
|
+
background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', fontSize: 12,
|
|
110
|
+
},
|
|
111
|
+
}, h(ReloadIcon), t('reload')),
|
|
112
|
+
h('a', {
|
|
113
|
+
href: MARKET_URL, target: '_blank', rel: 'noopener noreferrer',
|
|
114
|
+
style: {
|
|
115
|
+
display: 'inline-flex', alignItems: 'center', height: 26, padding: '0 10px',
|
|
116
|
+
borderRadius: 7, border: '1px solid var(--dsw-alias-border-l1)',
|
|
117
|
+
background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
|
|
118
|
+
fontSize: 12, textDecoration: 'none',
|
|
119
|
+
},
|
|
120
|
+
}, t('openInBrowser')),
|
|
121
|
+
),
|
|
122
|
+
h('div', {
|
|
123
|
+
style: { position: 'relative', flex: 1, minHeight: 0, background: '#ffffff' },
|
|
124
|
+
},
|
|
125
|
+
!loaded ? h('div', {
|
|
126
|
+
style: {
|
|
127
|
+
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
128
|
+
flexDirection: 'column', gap: 6, padding: 24, textAlign: 'center',
|
|
129
|
+
color: 'var(--dsw-alias-label-secondary)', fontSize: 13,
|
|
130
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
h('div', null, t('loading')),
|
|
134
|
+
h('div', { style: { fontSize: 12 } }, t('loadHint')),
|
|
135
|
+
) : null,
|
|
136
|
+
h('iframe', {
|
|
137
|
+
key: 'market-frame-' + tick,
|
|
138
|
+
ref: frameRef,
|
|
139
|
+
src: MARKET_URL,
|
|
140
|
+
title: t('title'),
|
|
141
|
+
// 授权内嵌页使用剪贴板(分享/安装复制);桌面 GUI 走 http 时
|
|
142
|
+
// 站点自身还有 execCommand 降级,这里对安全上下文生效。
|
|
143
|
+
allow: 'clipboard-write; clipboard-read',
|
|
144
|
+
onLoad: function () { setLoaded(true) },
|
|
145
|
+
style: { width: '100%', height: '100%', border: 'none', display: 'block' },
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
name: 'dsh-skillpie',
|
|
153
|
+
inject: ['slots', 'layout', 'locale'],
|
|
154
|
+
apply: function (ctx) {
|
|
155
|
+
ctx.effect(function () {
|
|
156
|
+
return ctx.locale.register(NS, { zh: zh, en: en })
|
|
157
|
+
}, 'dsh-skillpie: dictionaries')
|
|
158
|
+
|
|
159
|
+
pageT = (ctx.locale && typeof ctx.locale.bind === 'function')
|
|
160
|
+
? ctx.locale.bind(NS)
|
|
161
|
+
: function (key) { return (zh[key] != null ? zh[key] : (en[key] != null ? en[key] : key)) }
|
|
162
|
+
|
|
163
|
+
// Sidebar panel icon → main view, mirroring ZCode's sidebar entry.
|
|
164
|
+
ctx.slots.inject('sidebar.panellist', function () {
|
|
165
|
+
return ctx.slots.register({
|
|
166
|
+
name: 'sidebar.panellist',
|
|
167
|
+
id: 'dsh-skillpie',
|
|
168
|
+
order: 25,
|
|
169
|
+
locale: NS,
|
|
170
|
+
label: function () { return pageT('title') },
|
|
171
|
+
}, PieIcon)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Full-page embedded marketplace view (sidebar stays visible).
|
|
175
|
+
ctx.slots.inject('main', function () {
|
|
176
|
+
return ctx.slots.register({
|
|
177
|
+
name: 'main',
|
|
178
|
+
key: 'dsh-skillpie',
|
|
179
|
+
locale: NS,
|
|
180
|
+
}, EmbeddedMarket)
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skillpie — Cordis entry (host half).
|
|
3
|
+
*
|
|
4
|
+
* Serves POST /dsh-skillpie/api for the browser panel:
|
|
5
|
+
* { action: 'search', query, limit } → { ok, results }
|
|
6
|
+
* { action: 'installed' } → { ok, skills }
|
|
7
|
+
* { action: 'install', name } → { ok, result }
|
|
8
|
+
* { action: 'detail', name } → { ok, detail }
|
|
9
|
+
* { action: 'ping' } → { ok, time }
|
|
10
|
+
*
|
|
11
|
+
* The route is same-origin only and lives behind the web GUI's own session
|
|
12
|
+
* auth, so installing from the panel is always an authenticated user action.
|
|
13
|
+
* The market work lives in market.js; this file only wires transport, config
|
|
14
|
+
* and disposal.
|
|
15
|
+
*/
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { createMarket, DEFAULT_BASE_URL, DEFAULT_SKILL_ROOT, SkillMarketError } from './market.js'
|
|
18
|
+
|
|
19
|
+
export const ROUTE = '/dsh-skillpie/api'
|
|
20
|
+
const MAX_BODY = 64 * 1024
|
|
21
|
+
|
|
22
|
+
/** One issue in the Standard Schema config vocabulary. */
|
|
23
|
+
const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Plugin config. Defaults: skillRoot ~/.agents/skills, baseUrl
|
|
27
|
+
* https://skillpie.cn. Both accept a row override in cordis.patch.yml.
|
|
28
|
+
*/
|
|
29
|
+
export const Config = {
|
|
30
|
+
'~standard': {
|
|
31
|
+
version: 1,
|
|
32
|
+
vendor: 'dsh-skillpie',
|
|
33
|
+
validate(input) {
|
|
34
|
+
if (input === undefined || input === null) return { value: {} }
|
|
35
|
+
if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
|
|
36
|
+
const issues = []
|
|
37
|
+
const value = {}
|
|
38
|
+
for (const key of ['skillRoot', 'baseUrl']) {
|
|
39
|
+
const raw = input[key]
|
|
40
|
+
if (raw === undefined) continue
|
|
41
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
42
|
+
issues.push(issue(`expected a non-empty string`, key))
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
value[key] = raw.trim()
|
|
46
|
+
}
|
|
47
|
+
if (value.baseUrl !== undefined) {
|
|
48
|
+
try { new URL(value.baseUrl) } catch {
|
|
49
|
+
issues.push(issue('expected an absolute http(s) URL', 'baseUrl'))
|
|
50
|
+
delete value.baseUrl
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return issues.length ? { issues } : { value }
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a small JSON request body. */
|
|
59
|
+
function readBody(req) {
|
|
60
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
61
|
+
let data = ''
|
|
62
|
+
req.on('data', (chunk) => {
|
|
63
|
+
data += chunk
|
|
64
|
+
if (data.length > MAX_BODY) {
|
|
65
|
+
req.destroy()
|
|
66
|
+
rejectPromise(new Error('request body too large'))
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
req.on('end', () => {
|
|
70
|
+
if (!data.trim()) return resolvePromise({})
|
|
71
|
+
try {
|
|
72
|
+
resolvePromise(JSON.parse(data))
|
|
73
|
+
} catch {
|
|
74
|
+
rejectPromise(new Error('invalid JSON body'))
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
req.on('error', rejectPromise)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function dispatch(market, body) {
|
|
82
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
83
|
+
switch (action) {
|
|
84
|
+
case 'search':
|
|
85
|
+
return { ok: true, results: await market.search(body.query, body.limit) }
|
|
86
|
+
case 'installed':
|
|
87
|
+
return { ok: true, skills: await market.installed() }
|
|
88
|
+
case 'install':
|
|
89
|
+
return { ok: true, result: await market.install(body.name) }
|
|
90
|
+
case 'detail':
|
|
91
|
+
return { ok: true, detail: await market.detail(body.name) }
|
|
92
|
+
case 'ping':
|
|
93
|
+
return { ok: true, time: Date.now() }
|
|
94
|
+
default:
|
|
95
|
+
throw new Error(`unknown action: ${action || '(empty)'}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Cordis plugin entry (service-object export form). */
|
|
100
|
+
export default {
|
|
101
|
+
name: 'dsh-skillpie',
|
|
102
|
+
Config,
|
|
103
|
+
inject: [],
|
|
104
|
+
apply(ctx, config = null) {
|
|
105
|
+
const cfg = config ?? {}
|
|
106
|
+
const market = createMarket({
|
|
107
|
+
skillRoot: cfg.skillRoot ?? DEFAULT_SKILL_ROOT,
|
|
108
|
+
baseUrl: cfg.baseUrl ?? DEFAULT_BASE_URL,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
ctx.effect(() => () => {}, 'dsh-skillpie: lifetime')
|
|
112
|
+
|
|
113
|
+
// HTTP carrier for the browser panel. Optional: headless assemblies keep
|
|
114
|
+
// the module loadable without the web layer.
|
|
115
|
+
ctx.inject(['webServer'], (child) => {
|
|
116
|
+
child.effect(() => child.webServer.register({
|
|
117
|
+
kind: 'exact',
|
|
118
|
+
path: ROUTE,
|
|
119
|
+
handler: async (req, res) => {
|
|
120
|
+
const send = (status, payload) => {
|
|
121
|
+
res.writeHead(status, {
|
|
122
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
123
|
+
'Cache-Control': 'no-store',
|
|
124
|
+
})
|
|
125
|
+
res.end(JSON.stringify(payload))
|
|
126
|
+
}
|
|
127
|
+
// Same-origin only: a cross-origin fetch with a "simple" content
|
|
128
|
+
// type would skip preflight and could otherwise trigger writes.
|
|
129
|
+
const origin = req.headers?.origin
|
|
130
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
131
|
+
let sameOrigin = false
|
|
132
|
+
try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
|
|
133
|
+
if (!sameOrigin) {
|
|
134
|
+
send(403, { ok: false, error: 'cross-origin request refused' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (req.method !== 'POST') {
|
|
139
|
+
send(200, { ok: true, service: 'dsh-skillpie', route: ROUTE })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
send(200, await dispatch(market, await readBody(req)))
|
|
144
|
+
} catch (error) {
|
|
145
|
+
send(200, { ok: false, error: error?.message ?? String(error) })
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}), 'dsh-skillpie: http api')
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { createMarket, DEFAULT_BASE_URL, DEFAULT_SKILL_ROOT, SkillMarketError, dispatch }
|
package/lib/market.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skillpie — SkillPie (https://skillpie.cn) market client + installer (host half core).
|
|
3
|
+
*
|
|
4
|
+
* Public, auth-free HTTP API. The field contract mirrors ZCode's
|
|
5
|
+
* packages/shared/src/skill-market.ts (verified against the live service):
|
|
6
|
+
* GET /api/skills?query=&limit= → { skills: [...] }
|
|
7
|
+
* GET /api/skills/by-normalized-name/<name> → { skill: { ..., version } }
|
|
8
|
+
* GET /api/skills/by-normalized-name/<name>/download-url → { downloadUrl }
|
|
9
|
+
*
|
|
10
|
+
* Install target: the user-level skills root (~/.agents/skills/<normalizedName>),
|
|
11
|
+
* the directory DSH scans for session skills. Install safety mirrors ZCode's
|
|
12
|
+
* skillMarketInstall.ts: safe single-segment target name, per-entry path checks,
|
|
13
|
+
* package/extracted size caps, entry count cap, and a skill directory that must
|
|
14
|
+
* contain exactly one SKILL.md.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { fromBuffer } from 'yauzl'
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_BASE_URL = 'https://skillpie.cn'
|
|
24
|
+
export const DEFAULT_SKILL_ROOT = join(homedir(), '.agents', 'skills')
|
|
25
|
+
|
|
26
|
+
const API_TIMEOUT_MS = 10_000
|
|
27
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000
|
|
28
|
+
const MAX_PACKAGE_BYTES = 20 * 1024 * 1024
|
|
29
|
+
const MAX_EXTRACTED_BYTES = 20 * 1024 * 1024
|
|
30
|
+
const MAX_ARCHIVE_ENTRIES = 4096
|
|
31
|
+
const DEFAULT_SEARCH_LIMIT = 10
|
|
32
|
+
const MAX_SEARCH_LIMIT = 20
|
|
33
|
+
/** Target directory name: one safe path segment, no traversal possible. */
|
|
34
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
35
|
+
|
|
36
|
+
export class SkillMarketError extends Error {}
|
|
37
|
+
|
|
38
|
+
/** Fetch JSON with a hard timeout; non-2xx carries the first bytes of the body. */
|
|
39
|
+
async function fetchJson(fetchImpl, url) {
|
|
40
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(API_TIMEOUT_MS) })
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const body = await response.text().catch(() => '')
|
|
43
|
+
throw new SkillMarketError(`skill market request failed (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
|
|
44
|
+
}
|
|
45
|
+
return await response.json()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function joinMarketUrl(baseUrl, path) {
|
|
49
|
+
return `${baseUrl.replace(/\/+$/u, '')}${path}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveAbsoluteMarketUrl(baseUrl, url) {
|
|
53
|
+
if (/^https?:\/\//iu.test(url)) return url
|
|
54
|
+
return joinMarketUrl(baseUrl, url)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toFiniteNumber(value, fallback = 0) {
|
|
58
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Server fields are loosely mapped; missing fields fall back here, never throw. */
|
|
62
|
+
function toSearchResult(baseUrl, skill) {
|
|
63
|
+
return {
|
|
64
|
+
id: typeof skill.id === 'string' ? skill.id : '',
|
|
65
|
+
name: typeof skill.name === 'string' ? skill.name : '',
|
|
66
|
+
normalizedName: typeof skill.normalizedName === 'string' ? skill.normalizedName : '',
|
|
67
|
+
category: typeof skill.category === 'string' && skill.category !== '' ? skill.category : null,
|
|
68
|
+
description: typeof skill.description === 'string' ? skill.description : '',
|
|
69
|
+
ownerDisplayName: typeof skill.ownerDisplayName === 'string' ? skill.ownerDisplayName : '',
|
|
70
|
+
downloadCount: toFiniteNumber(skill.downloadCount),
|
|
71
|
+
likeCount: toFiniteNumber(skill.likeCount),
|
|
72
|
+
versionNo: toFiniteNumber(skill.version?.versionNo),
|
|
73
|
+
isFree: skill.isFree !== false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Loose SKILL.md frontmatter parse for the installed-skills list. */
|
|
78
|
+
function parseFrontmatter(text) {
|
|
79
|
+
const meta = { title: null, description: null, category: null }
|
|
80
|
+
if (!text.startsWith('---')) return meta
|
|
81
|
+
const end = text.indexOf('\n---', 3)
|
|
82
|
+
if (end < 0) return meta
|
|
83
|
+
for (const line of text.slice(4, end).split('\n')) {
|
|
84
|
+
const match = /^(name|title|description|category):\s*(.+?)\s*$/u.exec(line)
|
|
85
|
+
if (match) {
|
|
86
|
+
const key = match[1] === 'name' ? 'title' : match[1]
|
|
87
|
+
if (meta[key] == null) meta[key] = match[2].replace(/^["']|["']$/gu, '')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return meta
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createMarket(options = {}) {
|
|
94
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
95
|
+
const skillRoot = options.skillRoot ?? DEFAULT_SKILL_ROOT
|
|
96
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
|
97
|
+
|
|
98
|
+
const assertSafeNormalizedName = (normalizedName) => {
|
|
99
|
+
const name = typeof normalizedName === 'string' ? normalizedName.trim() : ''
|
|
100
|
+
if (!SAFE_NAME.test(name)) {
|
|
101
|
+
throw new SkillMarketError(`unsafe skill market name: ${normalizedName}`)
|
|
102
|
+
}
|
|
103
|
+
return name
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const market = {
|
|
107
|
+
/** Search the public catalog. Empty query returns an empty list. */
|
|
108
|
+
async search(query, limit) {
|
|
109
|
+
const trimmed = typeof query === 'string' ? query.trim() : ''
|
|
110
|
+
if (!trimmed) return []
|
|
111
|
+
const capped = Math.min(Math.max(Number(limit) || DEFAULT_SEARCH_LIMIT, 1), MAX_SEARCH_LIMIT)
|
|
112
|
+
const params = new URLSearchParams({ query: trimmed, limit: String(capped) })
|
|
113
|
+
const data = await fetchJson(fetchImpl, joinMarketUrl(baseUrl, `/api/skills?${params.toString()}`))
|
|
114
|
+
return (Array.isArray(data?.skills) ? data.skills : []).map((skill) => toSearchResult(baseUrl, skill))
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/** Skill detail; also the source of the paid check and package URL. */
|
|
118
|
+
async detail(normalizedName) {
|
|
119
|
+
const name = assertSafeNormalizedName(normalizedName)
|
|
120
|
+
const data = await fetchJson(
|
|
121
|
+
fetchImpl,
|
|
122
|
+
joinMarketUrl(baseUrl, `/api/skills/by-normalized-name/${encodeURIComponent(name)}`),
|
|
123
|
+
)
|
|
124
|
+
const skill = data?.skill
|
|
125
|
+
if (!skill) throw new SkillMarketError(`skill not found in market: ${name}`)
|
|
126
|
+
const version = skill.version && typeof skill.version === 'object' ? skill.version : null
|
|
127
|
+
const packageDownloadUrl =
|
|
128
|
+
typeof version?.packageDownloadUrl === 'string' && version.packageDownloadUrl !== ''
|
|
129
|
+
? resolveAbsoluteMarketUrl(baseUrl, version.packageDownloadUrl)
|
|
130
|
+
: null
|
|
131
|
+
return {
|
|
132
|
+
id: typeof skill.id === 'string' ? skill.id : '',
|
|
133
|
+
name: typeof skill.name === 'string' && skill.name !== '' ? skill.name : name,
|
|
134
|
+
normalizedName: name,
|
|
135
|
+
category: typeof skill.category === 'string' && skill.category !== '' ? skill.category : null,
|
|
136
|
+
description: typeof skill.description === 'string' ? skill.description : '',
|
|
137
|
+
ownerDisplayName: typeof skill.ownerDisplayName === 'string' ? skill.ownerDisplayName : '',
|
|
138
|
+
downloadCount: toFiniteNumber(skill.downloadCount),
|
|
139
|
+
likeCount: toFiniteNumber(skill.likeCount),
|
|
140
|
+
isFree: skill.isFree !== false,
|
|
141
|
+
usageInstructions: typeof skill.usageInstructions === 'string' ? skill.usageInstructions : null,
|
|
142
|
+
version: version
|
|
143
|
+
? {
|
|
144
|
+
versionNo: toFiniteNumber(version.versionNo),
|
|
145
|
+
packageDownloadUrl,
|
|
146
|
+
packageSize: toFiniteNumber(version.packageSize, 0) || null,
|
|
147
|
+
}
|
|
148
|
+
: null,
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/** Install one free skill into <skillRoot>/<normalizedName>. */
|
|
153
|
+
async install(normalizedName) {
|
|
154
|
+
const name = assertSafeNormalizedName(normalizedName)
|
|
155
|
+
const targetPath = resolve(skillRoot, name)
|
|
156
|
+
if (!targetPath.startsWith(resolve(skillRoot) + '/') && targetPath !== resolve(skillRoot)) {
|
|
157
|
+
throw new SkillMarketError('unsafe skill install target')
|
|
158
|
+
}
|
|
159
|
+
if (existsSync(targetPath)) {
|
|
160
|
+
return { status: 'already-installed', normalizedName: name, path: targetPath }
|
|
161
|
+
}
|
|
162
|
+
const detail = await market.detail(name)
|
|
163
|
+
if (!detail.isFree) {
|
|
164
|
+
throw new SkillMarketError('付费技能必须前往 skillpie.cn 技能市场获取 / paid skills must be acquired in the SkillPie marketplace')
|
|
165
|
+
}
|
|
166
|
+
let downloadUrl = detail.version?.packageDownloadUrl ?? null
|
|
167
|
+
if (!downloadUrl) {
|
|
168
|
+
// Detail without a package URL: fall back to the public download-url endpoint.
|
|
169
|
+
const data = await fetchJson(
|
|
170
|
+
fetchImpl,
|
|
171
|
+
joinMarketUrl(baseUrl, `/api/skills/by-normalized-name/${encodeURIComponent(name)}/download-url`),
|
|
172
|
+
)
|
|
173
|
+
downloadUrl = typeof data?.downloadUrl === 'string' && data.downloadUrl !== ''
|
|
174
|
+
? resolveAbsoluteMarketUrl(baseUrl, data.downloadUrl)
|
|
175
|
+
: null
|
|
176
|
+
}
|
|
177
|
+
if (!downloadUrl) throw new SkillMarketError('skill package download url is unavailable')
|
|
178
|
+
const response = await fetchImpl(downloadUrl, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
|
|
179
|
+
if (!response.ok) throw new SkillMarketError(`skill package download failed (${response.status})`)
|
|
180
|
+
const archive = Buffer.from(await response.arrayBuffer())
|
|
181
|
+
return await installArchive({ archive, normalizedName: name, skillRoot })
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** List locally installed skills under the skills root. */
|
|
185
|
+
async installed() {
|
|
186
|
+
if (!existsSync(skillRoot)) return []
|
|
187
|
+
const entries = await readdir(skillRoot, { withFileTypes: true })
|
|
188
|
+
const skills = []
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (!entry.isDirectory()) continue
|
|
191
|
+
const skillFile = join(skillRoot, entry.name, 'SKILL.md')
|
|
192
|
+
if (!existsSync(skillFile)) continue
|
|
193
|
+
let meta = { title: null, description: null, category: null }
|
|
194
|
+
try {
|
|
195
|
+
meta = parseFrontmatter(await readFile(skillFile, 'utf8').then((text) => text.slice(0, 4096)))
|
|
196
|
+
} catch {
|
|
197
|
+
// Unreadable SKILL.md still lists the directory name.
|
|
198
|
+
}
|
|
199
|
+
skills.push({ normalizedName: entry.name, title: meta.title, description: meta.description, category: meta.category })
|
|
200
|
+
}
|
|
201
|
+
skills.sort((a, b) => a.normalizedName.localeCompare(b.normalizedName))
|
|
202
|
+
return skills
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
return market
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Extract a downloaded skill zip and copy the SKILL.md directory into place. */
|
|
209
|
+
export async function installArchive({ archive, normalizedName, skillRoot }) {
|
|
210
|
+
if (archive.byteLength === 0) throw new SkillMarketError('skill package is empty')
|
|
211
|
+
if (archive.byteLength > MAX_PACKAGE_BYTES) throw new SkillMarketError('skill package exceeds size limit')
|
|
212
|
+
if (!SAFE_NAME.test(normalizedName)) throw new SkillMarketError(`unsafe skill market directory name: ${normalizedName}`)
|
|
213
|
+
const targetPath = resolve(skillRoot, normalizedName)
|
|
214
|
+
if (existsSync(targetPath)) {
|
|
215
|
+
return { status: 'already-installed', normalizedName, path: targetPath }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tempRoot = await mkdtemp(join(tmpdir(), 'dsh-skillpie-'))
|
|
219
|
+
try {
|
|
220
|
+
const extractRoot = join(tempRoot, 'package')
|
|
221
|
+
await extractZipArchive(archive, extractRoot)
|
|
222
|
+
const sourceDir = await locateSkillDirectory(extractRoot)
|
|
223
|
+
await mkdir(skillRoot, { recursive: true })
|
|
224
|
+
await cp(sourceDir, targetPath, { recursive: true, errorOnExist: true, force: false })
|
|
225
|
+
return { status: 'installed', normalizedName, path: targetPath }
|
|
226
|
+
} finally {
|
|
227
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Per-entry path safety + total size / entry count caps, then stream out. */
|
|
232
|
+
async function extractZipArchive(archive, targetDir) {
|
|
233
|
+
const zip = await openZipBuffer(archive)
|
|
234
|
+
let extractedBytes = 0
|
|
235
|
+
let entryCount = 0
|
|
236
|
+
const resolvedTarget = resolve(targetDir)
|
|
237
|
+
|
|
238
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
239
|
+
const fail = (error) => {
|
|
240
|
+
rejectPromise(error instanceof Error ? error : new Error(String(error)))
|
|
241
|
+
try { zip.close() } catch { /* already closed */ }
|
|
242
|
+
}
|
|
243
|
+
const safeTargetFor = (fileName) => {
|
|
244
|
+
if (fileName.startsWith('/') || fileName.includes('\\')) {
|
|
245
|
+
throw new SkillMarketError(`unsafe skill package entry: ${fileName}`)
|
|
246
|
+
}
|
|
247
|
+
const segments = fileName.split('/')
|
|
248
|
+
if (segments.some((segment) => segment === '' || segment === '..' || segment === '.')) {
|
|
249
|
+
throw new SkillMarketError(`unsafe skill package entry: ${fileName}`)
|
|
250
|
+
}
|
|
251
|
+
const target = resolve(resolvedTarget, ...segments)
|
|
252
|
+
if (!target.startsWith(resolvedTarget + '/')) {
|
|
253
|
+
throw new SkillMarketError(`unsafe skill package entry: ${fileName}`)
|
|
254
|
+
}
|
|
255
|
+
return target
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
zip.on('error', fail)
|
|
259
|
+
zip.on('entry', (entry) => {
|
|
260
|
+
entryCount += 1
|
|
261
|
+
if (entryCount > MAX_ARCHIVE_ENTRIES) {
|
|
262
|
+
fail(new SkillMarketError('skill package has too many entries'))
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (extractedBytes + entry.uncompressedSize > MAX_EXTRACTED_BYTES) {
|
|
266
|
+
fail(new SkillMarketError('skill package exceeds extracted size limit'))
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
let targetPath
|
|
270
|
+
const isDirectory = entry.fileName.endsWith('/')
|
|
271
|
+
try {
|
|
272
|
+
// Directory entries carry a trailing slash; strip it before the
|
|
273
|
+
// segment check so 'dir/' does not look like an empty segment.
|
|
274
|
+
targetPath = safeTargetFor(isDirectory ? entry.fileName.slice(0, -1) : entry.fileName)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
fail(error)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
void (async () => {
|
|
280
|
+
try {
|
|
281
|
+
if (isDirectory) {
|
|
282
|
+
await mkdir(targetPath, { recursive: true })
|
|
283
|
+
} else {
|
|
284
|
+
const stream = await openZipEntryStream(zip, entry)
|
|
285
|
+
const chunks = []
|
|
286
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
|
|
287
|
+
const content = Buffer.concat(chunks)
|
|
288
|
+
extractedBytes += content.byteLength
|
|
289
|
+
if (extractedBytes > MAX_EXTRACTED_BYTES) {
|
|
290
|
+
throw new SkillMarketError('skill package exceeds extracted size limit')
|
|
291
|
+
}
|
|
292
|
+
await mkdir(targetPath.slice(0, targetPath.lastIndexOf('/')), { recursive: true })
|
|
293
|
+
await writeFile(targetPath, content)
|
|
294
|
+
}
|
|
295
|
+
zip.readEntry()
|
|
296
|
+
} catch (error) {
|
|
297
|
+
fail(error)
|
|
298
|
+
}
|
|
299
|
+
})()
|
|
300
|
+
})
|
|
301
|
+
zip.on('end', () => resolvePromise())
|
|
302
|
+
zip.readEntry()
|
|
303
|
+
})
|
|
304
|
+
try { zip.close() } catch { /* already closed */ }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function openZipBuffer(buffer) {
|
|
308
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
309
|
+
fromBuffer(buffer, { lazyEntries: true, autoClose: false }, (error, zip) => {
|
|
310
|
+
if (error || !zip) {
|
|
311
|
+
rejectPromise(error ?? new Error('failed to open skill package'))
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
resolvePromise(zip)
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function openZipEntryStream(zip, entry) {
|
|
320
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
321
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
322
|
+
if (error || !stream) {
|
|
323
|
+
rejectPromise(error ?? new Error('failed to read skill package entry'))
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
resolvePromise(stream)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The zip root is the skill (SKILL.md at root), or exactly one child dir has it. */
|
|
332
|
+
async function locateSkillDirectory(extractRoot) {
|
|
333
|
+
const skillFile = join(extractRoot, 'SKILL.md')
|
|
334
|
+
if (existsSync(skillFile)) return extractRoot
|
|
335
|
+
const candidates = []
|
|
336
|
+
const entries = await readdir(extractRoot, { withFileTypes: true })
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
if (!entry.isDirectory()) continue
|
|
339
|
+
const candidate = join(extractRoot, entry.name)
|
|
340
|
+
if (existsSync(join(candidate, 'SKILL.md'))) candidates.push(candidate)
|
|
341
|
+
}
|
|
342
|
+
if (candidates.length !== 1) {
|
|
343
|
+
throw new SkillMarketError(
|
|
344
|
+
candidates.length === 0
|
|
345
|
+
? 'skill package has no SKILL.md'
|
|
346
|
+
: 'skill package has multiple skill directories',
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
return candidates[0]
|
|
350
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skillpie",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SkillPie marketplace for DeepSeek Harness: search and install agent skills from the sidebar.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"dsh-plugin",
|
|
11
|
+
"skillpie",
|
|
12
|
+
"skills",
|
|
13
|
+
"marketplace",
|
|
14
|
+
"agent-skills"
|
|
15
|
+
],
|
|
16
|
+
"main": "./lib/index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./lib/index.js",
|
|
19
|
+
"./client": "./lib/client.js",
|
|
20
|
+
"./locale/*.json": "./locale/*.json",
|
|
21
|
+
"./icon": "./icon.svg"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib",
|
|
25
|
+
"locale/*.json",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.13",
|
|
32
|
+
"dsh": ">=0.2.1-rc.3"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"yauzl": "^3.2.0"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-locale",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/skillpie/dsh-plugins.git",
|
|
56
|
+
"directory": "dsh-skillpie"
|
|
57
|
+
}
|
|
58
|
+
}
|