dsh-skill-importer 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-importer",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Web UI plugin for DeepSeek Harness: import skills from local Markdown files or URLs into the skill roots the harness discovers automatically",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -47,6 +47,9 @@
47
47
  "build": "rm -rf lib && tsc -p tsconfig.json && tsdown",
48
48
  "test:batch": "node --experimental-strip-types tests/batch-import.test.mjs",
49
49
  "test:atomic-delete": "node --experimental-strip-types tests/atomic-skill-delete.test.mjs",
50
+ "test:update": "node --experimental-strip-types tests/update.test.mjs",
51
+ "test:list": "node --experimental-strip-types tests/skill-list.test.mjs",
52
+ "test:effective": "node --experimental-strip-types tests/effective-skills.test.mjs",
50
53
  "watch": "tsdown --watch"
51
54
  },
52
55
  "license": "MIT",
package/src/server.ts CHANGED
@@ -18,7 +18,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
18
18
  import { isValidSkillName, parseSkillFile } from './frontmatter.ts'
19
19
  import type {
20
20
  BatchCommitEntry, BatchScanEntry, BatchScanRequest,
21
- ImportRequest, ImportTarget, ImportUrlRequest, SkillListEntry,
21
+ ImportRequest, ImportTarget, ImportUrlRequest, SkillListEntry, SkillListIssue,
22
22
  } from './types.ts'
23
23
 
24
24
  /** Hard cap for one imported skill body (matches the client preview limit). */
@@ -44,18 +44,31 @@ export function dshHomeDir(): string {
44
44
  /** Absolute skill root for one target under one workspace. */
45
45
  export function skillRoot(target: ImportTarget, workspacePath: string): string {
46
46
  switch (target) {
47
- case 'user':
48
- return join(dshHomeDir(), 'skills')
47
+ case 'project-dsh':
48
+ return join(workspacePath, '.dsh', 'skills')
49
49
  case 'project-agents':
50
50
  return join(workspacePath, '.agents', 'skills')
51
+ case 'user-dsh':
52
+ return join(dshHomeDir(), 'skills')
53
+ case 'user-agents':
54
+ return join(process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'), 'skills')
51
55
  }
52
56
  }
53
57
 
54
58
  /** Discovery rank per root (lower wins), mirroring dsh-skill-filesystem. */
55
- const ROOT_RANK: Record<ImportTarget, number> = { 'project-agents': 200, user: 400 }
59
+ const ROOT_RANK: Record<ImportTarget, number> = {
60
+ 'project-dsh': 100,
61
+ 'project-agents': 200,
62
+ 'user-dsh': 400,
63
+ 'user-agents': 500,
64
+ }
56
65
 
57
- /** Scan order: project roots first so project skills win duplicate names. */
58
- const SCAN_ORDER: readonly ImportTarget[] = ['project-agents', 'user']
66
+ /** Official default-root order (custom rank 300 is not a stable import destination). */
67
+ const SCAN_ORDER: readonly ImportTarget[] = ['project-dsh', 'project-agents', 'user-dsh', 'user-agents']
68
+
69
+ export function isProjectTarget(target: ImportTarget): boolean {
70
+ return target === 'project-dsh' || target === 'project-agents'
71
+ }
59
72
 
60
73
  /**
61
74
  * Quote one frontmatter string value when plain YAML would mis-parse it:
@@ -113,7 +126,7 @@ export function normalizeSkillText(text: string): string {
113
126
  export function writeSkillFile(name: string, target: ImportTarget, content: string, workspacePath?: string): string {
114
127
  if (!KEBAB_CASE.test(name)) throw new Error('技能名必须是 kebab-case(小写字母、数字、短横线)')
115
128
  if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) throw new Error(`内容超过 ${MAX_CONTENT_BYTES / 1024} KB`)
116
- if (target !== 'user' && workspacePath === undefined) {
129
+ if (isProjectTarget(target) && workspacePath === undefined) {
117
130
  throw new Error('项目目标需要 workspacePath(当前工作区路径)')
118
131
  }
119
132
  const normalized = normalizeSkillText(content)
@@ -127,30 +140,55 @@ export function writeSkillFile(name: string, target: ImportTarget, content: stri
127
140
  }
128
141
 
129
142
  /** Scan one skill root and fold its skills into the name-keyed map (rank-aware). */
130
- function scanRoot(root: string, source: ImportTarget, out: Map<string, SkillListEntry>): void {
131
- if (!existsSync(root)) return
132
- for (const entry of readdirSync(root, { withFileTypes: true })) {
133
- const entryPath = join(root, entry.name)
134
- let file: string | undefined
135
- if (entry.isDirectory()) {
136
- const candidate = join(entryPath, 'SKILL.md')
137
- if (existsSync(candidate)) file = candidate
138
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
139
- file = entryPath
143
+ function scanRoot(
144
+ root: string,
145
+ source: ImportTarget,
146
+ workspacePath: string | undefined,
147
+ out: Map<string, SkillListEntry>,
148
+ issues: SkillListIssue[],
149
+ ): void {
150
+ try {
151
+ if (!existsSync(root)) return
152
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
153
+ const entryPath = join(root, entry.name)
154
+ let file: string | undefined
155
+ if (entry.isDirectory()) {
156
+ const candidate = join(entryPath, 'SKILL.md')
157
+ if (existsSync(candidate)) file = candidate
158
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
159
+ file = entryPath
160
+ }
161
+ if (file === undefined) continue
162
+ try {
163
+ const { frontmatter } = parseSkillFile(readFileSync(file, 'utf8'))
164
+ if (frontmatter.name === undefined || frontmatter.description === undefined) continue
165
+ if (!isValidSkillName(frontmatter.name)) continue
166
+ const existing = out.get(frontmatter.name)
167
+ if (existing !== undefined && ROOT_RANK[existing.source] <= ROOT_RANK[source]) continue
168
+ out.set(frontmatter.name, {
169
+ name: frontmatter.name,
170
+ description: frontmatter.description,
171
+ ...(frontmatter.whenToUse !== undefined ? { whenToUse: frontmatter.whenToUse } : {}),
172
+ modelInvocable: frontmatter.disableModelInvocation !== true,
173
+ userInvocable: frontmatter.userInvocable !== false,
174
+ source,
175
+ ...(workspacePath === undefined ? {} : { workspacePath }),
176
+ })
177
+ } catch (error) {
178
+ issues.push({
179
+ source,
180
+ path: file,
181
+ ...(workspacePath === undefined ? {} : { workspacePath }),
182
+ message: error instanceof Error ? error.message : String(error),
183
+ })
184
+ }
140
185
  }
141
- if (file === undefined) continue
142
- const { frontmatter } = parseSkillFile(readFileSync(file, 'utf8'))
143
- if (frontmatter.name === undefined || frontmatter.description === undefined) continue
144
- if (!isValidSkillName(frontmatter.name)) continue
145
- const existing = out.get(frontmatter.name)
146
- if (existing !== undefined && ROOT_RANK[existing.source] <= ROOT_RANK[source]) continue
147
- out.set(frontmatter.name, {
148
- name: frontmatter.name,
149
- description: frontmatter.description,
150
- ...(frontmatter.whenToUse !== undefined ? { whenToUse: frontmatter.whenToUse } : {}),
151
- modelInvocable: frontmatter.disableModelInvocation !== true,
152
- userInvocable: frontmatter.userInvocable !== false,
186
+ } catch (error) {
187
+ issues.push({
153
188
  source,
189
+ path: root,
190
+ ...(workspacePath === undefined ? {} : { workspacePath }),
191
+ message: error instanceof Error ? error.message : String(error),
154
192
  })
155
193
  }
156
194
  }
@@ -162,16 +200,26 @@ function scanRoot(root: string, source: ImportTarget, out: Map<string, SkillList
162
200
  * rank at discovery time). Display order groups by source, then name.
163
201
  * @param workspacePaths - canonical paths of the registered workspaces.
164
202
  */
165
- export function listSkills(workspacePaths: readonly string[]): SkillListEntry[] {
203
+ export function listSkills(workspacePaths: readonly string[]): { skills: SkillListEntry[]; issues: SkillListIssue[] } {
166
204
  const rows: SkillListEntry[] = []
205
+ const issues: SkillListIssue[] = []
167
206
  for (const target of SCAN_ORDER) {
168
- const out = new Map<string, SkillListEntry>()
169
- for (const path of target === 'user' ? [dshHomeDir()] : workspacePaths) {
170
- scanRoot(skillRoot(target, path), target, out)
207
+ if (!isProjectTarget(target)) {
208
+ const out = new Map<string, SkillListEntry>()
209
+ scanRoot(skillRoot(target, ''), target, undefined, out, issues)
210
+ rows.push(...out.values())
211
+ continue
171
212
  }
172
- rows.push(...out.values())
213
+ for (const workspacePath of workspacePaths) {
214
+ const out = new Map<string, SkillListEntry>()
215
+ scanRoot(skillRoot(target, workspacePath), target, workspacePath, out, issues)
216
+ rows.push(...out.values())
217
+ }
218
+ }
219
+ return {
220
+ skills: rows.sort((a, b) => a.name.localeCompare(b.name)),
221
+ issues,
173
222
  }
174
- return rows.sort((a, b) => a.name.localeCompare(b.name))
175
223
  }
176
224
 
177
225
  /**
@@ -184,7 +232,7 @@ export function listSkills(workspacePaths: readonly string[]): SkillListEntry[]
184
232
  */
185
233
  export function deleteSkillFile(name: string, source: ImportTarget, workspacePath?: string): boolean {
186
234
  if (!KEBAB_CASE.test(name)) throw new Error('技能名必须是 kebab-case(小写字母、数字、短横线)')
187
- if (source !== 'user' && workspacePath === undefined) {
235
+ if (isProjectTarget(source) && workspacePath === undefined) {
188
236
  throw new Error('项目来源需要 workspacePath(当前工作区路径)')
189
237
  }
190
238
  const root = skillRoot(source, workspacePath ?? '')
@@ -290,7 +338,7 @@ export function scanBatch(request: BatchScanRequest): BatchScanSession {
290
338
  if (!isSkillsRoot && !isSkillDirectory) {
291
339
  throw new Error('批量导入仅支持名为 skills 的目录或其中的单个技能目录')
292
340
  }
293
- if (request.target !== 'user' && request.workspacePath === undefined) throw new Error('项目目标需要 workspacePath(当前工作区路径)')
341
+ if (isProjectTarget(request.target) && request.workspacePath === undefined) throw new Error('项目目标需要 workspacePath(当前工作区路径)')
294
342
  const sources = batchSources(sourcePath)
295
343
  if (sources.length === 0) throw new Error('所选目录中没有找到可导入的技能')
296
344
  if (sources.length > MAX_BATCH_SKILLS) throw new Error(`一次最多扫描 ${MAX_BATCH_SKILLS} 个技能`)
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Where the imported skill file should land. */
8
- export type ImportTarget = 'user' | 'project-agents'
8
+ export type ImportTarget = 'project-dsh' | 'project-agents' | 'user-dsh' | 'user-agents'
9
9
 
10
10
  /** One installed skill row served by `/skill-importer/list`. */
11
11
  export interface SkillListEntry {
@@ -19,8 +19,18 @@ export interface SkillListEntry {
19
19
  readonly modelInvocable: boolean
20
20
  /** False marks a model-only skill (`user-invocable: false`). */
21
21
  readonly userInvocable: boolean
22
- /** Skill root (`'project-agents'` or `'user'`). */
22
+ /** Writable DSH skill root that owns this copy. */
23
23
  readonly source: ImportTarget
24
+ /** Exact registered workspace that owns a project-scoped copy. */
25
+ readonly workspacePath?: string
26
+ }
27
+
28
+ /** One skill root or entry that could not be inspected without failing the whole list. */
29
+ export interface SkillListIssue {
30
+ readonly source: ImportTarget
31
+ readonly path: string
32
+ readonly workspacePath?: string
33
+ readonly message: string
24
34
  }
25
35
 
26
36
  /** File-import request body. */
@@ -36,7 +46,7 @@ export interface ImportRequest {
36
46
  /** Delete-request body. */
37
47
  export interface DeleteRequest {
38
48
  readonly name: string
39
- /** Which root the copy lives in (`'project-agents'` or `'user'`). */
49
+ /** Which writable DSH root owns this copy. */
40
50
  readonly source: ImportTarget
41
51
  /** Canonical workspace path for project sources (host-validated). */
42
52
  readonly workspacePath?: string
@@ -107,6 +117,18 @@ export interface BatchCommitResponse {
107
117
  export interface SkillListResponse {
108
118
  readonly ok: true
109
119
  readonly skills: readonly SkillListEntry[]
120
+ readonly issues: readonly SkillListIssue[]
121
+ }
122
+
123
+ /** `/skill-importer/update-status` response. */
124
+ export interface UpdateStatusResponse {
125
+ readonly ok: true
126
+ readonly currentVersion: string
127
+ readonly latestVersion?: string
128
+ readonly updateAvailable: boolean
129
+ readonly command?: string
130
+ /** A failed registry check is non-fatal: the installed version is still useful. */
131
+ readonly error?: string
110
132
  }
111
133
 
112
134
  /** Success response of an import. */
@@ -43,7 +43,7 @@ try {
43
43
  mkdirSync(existing, { recursive: true })
44
44
  writeFileSync(join(existing, 'SKILL.md'), skill('alpha', 'old'))
45
45
 
46
- const scan = scanBatch({ sourcePath: source, target: 'user' })
46
+ const scan = scanBatch({ sourcePath: source, target: 'user-dsh' })
47
47
  assert.equal(scan.entries.find(row => row.name === 'alpha')?.conflict, true)
48
48
  assert.equal(scan.entries.find(row => row.name === 'windows-crlf')?.status, 'ready')
49
49
  assert.equal(scan.entries.find(row => row.name === 'invalid')?.status, 'error')
@@ -63,7 +63,7 @@ try {
63
63
  const changedSource = join(sandbox, '.codex', 'skills')
64
64
  mkdirSync(join(changedSource, 'beta'), { recursive: true })
65
65
  writeFileSync(join(changedSource, 'beta', 'SKILL.md'), skill('beta'))
66
- const changedScan = scanBatch({ sourcePath: changedSource, target: 'user' })
66
+ const changedScan = scanBatch({ sourcePath: changedSource, target: 'user-dsh' })
67
67
  writeFileSync(join(changedSource, 'beta', 'SKILL.md'), skill('beta', 'changed after scan'))
68
68
  const changedResults = commitBatch(changedScan, new Set())
69
69
  assert.equal(changedResults[0]?.status, 'error')
@@ -74,15 +74,15 @@ try {
74
74
  const customAgentSource = join(sandbox, 've_hcs_agent', 'skills')
75
75
  mkdirSync(join(customAgentSource, 'gamma'), { recursive: true })
76
76
  writeFileSync(join(customAgentSource, 'gamma', 'SKILL.md'), skill('gamma'))
77
- assert.equal(scanBatch({ sourcePath: customAgentSource, target: 'user' }).entries[0]?.name, 'gamma')
77
+ assert.equal(scanBatch({ sourcePath: customAgentSource, target: 'user-dsh' }).entries[0]?.name, 'gamma')
78
78
 
79
79
  const empty = join(sandbox, '.agents', 'skills')
80
80
  mkdirSync(empty, { recursive: true })
81
- assert.throws(() => scanBatch({ sourcePath: empty, target: 'user' }), /没有找到/)
81
+ assert.throws(() => scanBatch({ sourcePath: empty, target: 'user-dsh' }), /没有找到/)
82
82
 
83
83
  const arbitrary = join(sandbox, 'arbitrary')
84
84
  mkdirSync(arbitrary)
85
- assert.throws(() => scanBatch({ sourcePath: arbitrary, target: 'user' }), /名为 skills/)
85
+ assert.throws(() => scanBatch({ sourcePath: arbitrary, target: 'user-dsh' }), /名为 skills/)
86
86
 
87
87
  assert.equal(originAllowed({ headers: {} }), false)
88
88
  assert.equal(originAllowed({ headers: { origin: 'https://evil.example' } }), false)
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict'
2
+ import { effectiveSkills } from '../src/client/effectiveSkills.ts'
3
+
4
+ const row = (name, source, workspacePath) => ({
5
+ name,
6
+ source,
7
+ workspacePath,
8
+ description: source,
9
+ modelInvocable: true,
10
+ userInvocable: true,
11
+ })
12
+
13
+ const effective = effectiveSkills([
14
+ row('shared', 'user-agents'),
15
+ row('shared', 'project-agents', '/project'),
16
+ row('shared', 'user-dsh'),
17
+ row('shared', 'project-dsh', '/project'),
18
+ row('global-only', 'user-dsh'),
19
+ ])
20
+
21
+ assert.deepEqual(effective.map((skill) => [skill.name, skill.source]), [
22
+ ['global-only', 'user-dsh'],
23
+ ['shared', 'project-dsh'],
24
+ ])
25
+ console.log('effective skills test: OK')
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { listSkills, skillRoot } from '../src/server.ts'
6
+
7
+ const sandbox = mkdtempSync(join(tmpdir(), 'dsh-skill-list-test-'))
8
+ const previousHome = process.env.DSH_HOME
9
+ const previousAgentsHome = process.env.DSH_AGENTS_HOME
10
+ process.env.DSH_HOME = join(sandbox, 'home')
11
+ process.env.DSH_AGENTS_HOME = join(sandbox, 'agents-home')
12
+
13
+ try {
14
+ assert.equal(skillRoot('project-dsh', '/workspace'), join('/workspace', '.dsh', 'skills'))
15
+ assert.equal(skillRoot('project-agents', '/workspace'), join('/workspace', '.agents', 'skills'))
16
+ assert.equal(skillRoot('user-dsh', ''), join(process.env.DSH_HOME, 'skills'))
17
+ assert.equal(skillRoot('user-agents', ''), join(process.env.DSH_AGENTS_HOME, 'skills'))
18
+
19
+ const first = join(sandbox, 'first')
20
+ const second = join(sandbox, 'second')
21
+ const firstRoot = join(first, '.agents', 'skills')
22
+ const secondRoot = join(second, '.agents', 'skills')
23
+ mkdirSync(join(firstRoot, 'shared'), { recursive: true })
24
+ mkdirSync(join(secondRoot, 'shared'), { recursive: true })
25
+ writeFileSync(join(firstRoot, 'shared', 'SKILL.md'), '---\nname: shared\ndescription: first\n---\n')
26
+ writeFileSync(join(secondRoot, 'shared', 'SKILL.md'), '---\nname: shared\ndescription: second\n---\n')
27
+
28
+ const listed = listSkills([first, second])
29
+ assert.equal(listed.issues.length, 0)
30
+ assert.deepEqual(
31
+ listed.skills.map(skill => [skill.name, skill.description, skill.workspacePath]),
32
+ [['shared', 'first', first], ['shared', 'second', second]],
33
+ )
34
+
35
+ chmodSync(secondRoot, 0o000)
36
+ const partial = listSkills([first, second])
37
+ assert.equal(partial.skills.length, 1)
38
+ assert.equal(partial.skills[0]?.workspacePath, first)
39
+ assert.equal(partial.issues.length, 1)
40
+ assert.equal(partial.issues[0]?.path, secondRoot)
41
+ chmodSync(secondRoot, 0o755)
42
+ } finally {
43
+ if (previousHome === undefined) delete process.env.DSH_HOME
44
+ else process.env.DSH_HOME = previousHome
45
+ if (previousAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
46
+ else process.env.DSH_AGENTS_HOME = previousAgentsHome
47
+ rmSync(sandbox, { recursive: true, force: true })
48
+ }
49
+
50
+ console.log('skill list test: OK')
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { compareVersions, currentVersion, updateCommand } from '../src/update.ts'
4
+
5
+ test('compares stable semantic versions', () => {
6
+ assert.equal(compareVersions('0.2.3', '0.2.4'), -1)
7
+ assert.equal(compareVersions('1.0.0', '1.0.0'), 0)
8
+ assert.equal(compareVersions('2.0.0', '1.9.9'), 1)
9
+ })
10
+
11
+ test('orders prereleases before stable releases', () => {
12
+ assert.equal(compareVersions('1.0.0-rc.2', '1.0.0'), -1)
13
+ assert.equal(compareVersions('1.0.0-rc.10', '1.0.0-rc.2'), 1)
14
+ })
15
+
16
+ test('reads package version and produces an exact update command', () => {
17
+ assert.equal(currentVersion(), '0.2.4')
18
+ assert.equal(
19
+ updateCommand('0.2.4'),
20
+ 'npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@0.2.4',
21
+ )
22
+ })