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 CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # dsh-skillpie
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Skill marketplace",
4
+ "description": "Browse and install SkillPie agent skills from the sidebar."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "技能市场",
4
+ "description": "在侧边栏浏览并安装 SkillPie 技能库中的 Agent 技能。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "dsh-skillpie",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }