dsh-skill-hub 0.2.0 → 0.2.2

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.
Files changed (45) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +181 -249
  3. package/README.zh.md +275 -0
  4. package/lib/client.js +395 -257
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +126 -26
  7. package/lib/types/client/SkillHubSettingsCard.d.ts +23 -17
  8. package/lib/types/client/api.d.ts +9 -3
  9. package/lib/types/client/grouping.d.ts +3 -0
  10. package/lib/types/client/index.d.ts +12 -6
  11. package/lib/types/client/locales.d.ts +8 -4
  12. package/lib/types/client/market-catalog.d.ts +1 -1
  13. package/lib/types/client/panel/MarketView.d.ts +5 -5
  14. package/lib/types/client/panel/SkillHubPanel.d.ts +1 -1
  15. package/lib/types/client/panel/SourcesView.d.ts +5 -3
  16. package/lib/types/client/panel/useSkillHub.d.ts +4 -0
  17. package/lib/types/client/settings-card.d.ts +5 -4
  18. package/lib/types/index.d.ts +12 -1
  19. package/lib/types/protocol.d.ts +23 -0
  20. package/lib/types/skillfs.d.ts +1 -1
  21. package/lib/types/update.d.ts +1 -1
  22. package/package.json +28 -27
  23. package/src/client/SkillHubSettingsCard.tsx +25 -14
  24. package/src/client/api.ts +9 -6
  25. package/src/client/grouping.test.ts +12 -0
  26. package/src/client/grouping.ts +10 -1
  27. package/src/client/index.tsx +25 -19
  28. package/src/client/locales.ts +16 -8
  29. package/src/client/market-catalog.ts +1 -1
  30. package/src/client/panel/MarketView.tsx +77 -64
  31. package/src/client/panel/SkillHubPanel.tsx +18 -1
  32. package/src/client/panel/SkillRow.tsx +17 -2
  33. package/src/client/panel/SourcesView.tsx +92 -5
  34. package/src/client/panel/panel.module.css +9 -1
  35. package/src/client/panel/useSkillHub.ts +25 -11
  36. package/src/client/settings-card.tsx +5 -4
  37. package/src/index.ts +78 -31
  38. package/src/protocol.ts +24 -0
  39. package/src/routes.test.ts +55 -0
  40. package/src/routes.ts +90 -10
  41. package/src/skillfs.ts +1 -1
  42. package/src/update.ts +1 -1
  43. package/lib/types/client/api-config-scope.d.ts +0 -48
  44. package/src/client/api-config-scope.test.ts +0 -79
  45. package/src/client/api-config-scope.ts +0 -119
package/src/routes.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * JSON body) exactly once, so a new route cannot forget them.
13
13
  */
14
14
 
15
- import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
15
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
16
16
  import { basename, dirname, join } from 'node:path'
17
17
  import type { IncomingMessage, ServerResponse } from 'node:http'
18
18
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
@@ -293,18 +293,85 @@ async function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>> {
293
293
  return names
294
294
  }
295
295
 
296
- /** Build the full catalog response (shared by catalog/toggle/create handlers). */
296
+ /** 已知工作区条目(dsh workspace.json 表)。 */
297
+ interface WorkspaceEntry {
298
+ path: string
299
+ title: string
300
+ }
301
+
302
+ /**
303
+ * 读取 dsh 的已知工作区清单(~/.dsh/storages/workspace.json 的
304
+ * tables.workspaces 表)。面板默认视图据此合并所有工作区的项目技能;
305
+ * 文件缺失/损坏时返回空清单(回退为仅用户级视图)。
306
+ */
307
+ async function workspaceEntries(home: string): Promise<WorkspaceEntry[]> {
308
+ try {
309
+ const raw = await readFile(join(home, 'storages', 'workspace.json'), 'utf8')
310
+ const parsed: unknown = JSON.parse(raw)
311
+ const tables = typeof parsed === 'object' && parsed !== null
312
+ ? (parsed as Record<string, unknown>).tables as Record<string, unknown> | undefined
313
+ : undefined
314
+ const workspaces = tables !== undefined && typeof tables === 'object'
315
+ ? (tables as Record<string, unknown>).workspaces as Record<string, unknown> | undefined
316
+ : undefined
317
+ const entries: WorkspaceEntry[] = []
318
+ if (workspaces !== undefined && typeof workspaces === 'object') {
319
+ for (const record of Object.values(workspaces)) {
320
+ const entry = record as { path?: unknown; title?: unknown } | null
321
+ if (entry !== null && typeof entry === 'object' && typeof entry.path === 'string' && entry.path !== '') {
322
+ entries.push({
323
+ path: entry.path,
324
+ title: typeof entry.title === 'string' && entry.title !== '' ? entry.title : entry.path,
325
+ })
326
+ }
327
+ }
328
+ }
329
+ return entries
330
+ } catch {
331
+ return []
332
+ }
333
+ }
334
+
335
+ /** 项目技能来源(workspace 字段只对它们设置)。 */
336
+ function isProjectSource(source: string): boolean {
337
+ return source === 'project-dsh' || source === 'project-agents'
338
+ }
339
+
340
+ /**
341
+ * Build the full catalog response (shared by catalog/toggle/create handlers).
342
+ * 显式 cwd 只看该工作区;否则合并所有已知工作区(workspace.json)的项目技能
343
+ * + 用户级技能,同名技能先到先得;没有任何工作区时回退为仅用户级视图。
344
+ */
297
345
  async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<CatalogResponse> {
298
- const lookup = cwd !== undefined ? { cwd } : undefined
299
- const snapshot = await deps.skills.snapshot(lookup)
300
346
  const home = homeOf(deps)
347
+ let workspaces: WorkspaceEntry[]
348
+ if (cwd !== undefined && cwd !== '') {
349
+ workspaces = [{ path: cwd, title: cwd }]
350
+ } else {
351
+ workspaces = await workspaceEntries(home)
352
+ if (workspaces.length === 0) workspaces = [{ path: '', title: '' }]
353
+ }
354
+ const byName = new Map<string, { skill: SkillSummary; workspace?: string; workspaceTitle?: string }>()
355
+ let complete = true
356
+ for (const ws of workspaces) {
357
+ const lookup = ws.path !== '' ? { cwd: ws.path } : undefined
358
+ const snapshot = await deps.skills.snapshot(lookup)
359
+ if (!snapshot.complete) complete = false
360
+ for (const skill of snapshot.skills) {
361
+ if (byName.has(skill.name)) continue
362
+ byName.set(skill.name, {
363
+ skill,
364
+ ...(isProjectSource(skill.source) && ws.path !== '' ? { workspace: ws.path, workspaceTitle: ws.title } : {}),
365
+ })
366
+ }
367
+ }
301
368
  // 添加/更新时间 = 用户级技能文件的创建/修改时间(排序与详情展示用)。
302
369
  // snapshot 只给 SkillSummary(无 path),所以按可写根推断路径;非用户级
303
370
  // 来源没有稳定路径,省略字段,客户端排序会把它放到末尾。
304
371
  // 全部技能并发收集(面板每 5 秒轮询一次,逐个串行 stat 会让响应随技能
305
372
  // 数量线性变慢)。
306
373
  const timesByName = new Map<string, { addedAt: number; updatedAt: number }>()
307
- await Promise.all(snapshot.skills.map(async (skill) => {
374
+ await Promise.all([...byName.values()].map(async ({ skill }) => {
308
375
  if (!isWritableSource(skill.source)) return
309
376
  const base = rootPath(skill.source, home)
310
377
  for (const candidate of [join(base, skill.name, 'SKILL.md'), join(base, skill.name), join(base, skill.name + '.md')]) {
@@ -317,7 +384,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
317
384
  }
318
385
  }
319
386
  }))
320
- const skills: CatalogSkill[] = snapshot.skills.map((skill) => {
387
+ const skills: CatalogSkill[] = [...byName.values()].map(({ skill, workspace, workspaceTitle }) => {
321
388
  const row: CatalogSkill = {
322
389
  name: skill.name,
323
390
  description: skill.description,
@@ -328,6 +395,8 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
328
395
  },
329
396
  provider: skill.provider,
330
397
  writable: isWritableSource(skill.source),
398
+ source: skill.source,
399
+ ...(workspace !== undefined ? { workspace, workspaceTitle: workspaceTitle ?? workspace } : {}),
331
400
  }
332
401
  const times = timesByName.get(skill.name)
333
402
  if (times !== undefined) {
@@ -341,7 +410,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
341
410
  ...(await scanDiagnostics('user-dsh', home)),
342
411
  ...(await scanDiagnostics('user-agents', home)),
343
412
  ]
344
- return { ok: true, complete: snapshot.complete, skills, disabled, diagnostics }
413
+ return { ok: true, complete, skills, disabled, diagnostics }
345
414
  }
346
415
 
347
416
  /** Map a loaded definition onto the wire shape. */
@@ -460,7 +529,18 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
460
529
  const name = queryParam(url, 'name')
461
530
  if (name === undefined || name === '') { writeError(res, 400, 'name query parameter is required'); return }
462
531
  const cwd = queryParam(url, 'cwd')
463
- const skill = await deps.skills.get(name, cwd !== undefined ? { cwd } : undefined)
532
+ // 显式 cwd 只看该工作区;否则按已知工作区逐个查找(与目录默认视图
533
+ // 一致),最后回退用户级根,保证默认视图里可见的项目技能能打开详情。
534
+ let skill: SkillDefinition | undefined
535
+ if (cwd !== undefined && cwd !== '') {
536
+ skill = await deps.skills.get(name, { cwd })
537
+ } else {
538
+ for (const ws of await workspaceEntries(homeOf(deps))) {
539
+ skill = await deps.skills.get(name, { cwd: ws.path })
540
+ if (skill !== undefined) break
541
+ }
542
+ if (skill === undefined) skill = await deps.skills.get(name)
543
+ }
464
544
  if (skill === undefined) { writeError(res, 404, 'skill not found: ' + name); return }
465
545
  const detail = toDetail(skill)
466
546
  if (skill.path !== undefined) {
@@ -683,7 +763,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
683
763
  },
684
764
  }),
685
765
  // --------------------------------------------------------------- market
686
- // Codex-style market sources: the user adds repo slugs; each source can
766
+ // Market sources: the user adds repo slugs; each source can
687
767
  // be scanned through /repo and imported through /repo/import.
688
768
  route({
689
769
  path: SKILL_HUB_API.market,
@@ -926,7 +1006,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
926
1006
  },
927
1007
  }),
928
1008
  // -------------------------------------------------------------- update
929
- // 自身更新检查:查询 GitHub latest release(语义同 cc-switch 的检查更新)。
1009
+ // 自身更新检查:查询 GitHub latest release
930
1010
  route({
931
1011
  path: SKILL_HUB_API.update,
932
1012
  methods: ['GET'],
package/src/skillfs.ts CHANGED
@@ -267,7 +267,7 @@ export async function findProjectRoot(cwd: string): Promise<string> {
267
267
 
268
268
  /**
269
269
  * Scan one writable root for files the provider ignores, so the GUI can
270
- * show why a skill never appears (the zcode-style diagnostics lesson: a
270
+ * show why a skill never appears a
271
271
  * missing frontmatter must be visible, not silent). .disabled files belong
272
272
  * to the hub and are skipped.
273
273
  */
package/src/update.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Self-update check for dsh-skill-hub, mirroring cc-switch's behavior:
2
+ * Self-update check for dsh-skill-hub
3
3
  * query GitHub's latest release and compare it to the installed version.
4
4
  *
5
5
  * This stays dependency-free: Node's global fetch is used and the version
@@ -1,48 +0,0 @@
1
- /**
2
- * ApiConfigScope — a FormScope-compatible view over the hub's own config
3
- * route (/api/skill-hub/config). The host's settings service refuses to
4
- * expose third-party namespaces to the web client (dsh-host-apiproxy's
5
- * allowlist), so the settings card talks to this plugin-owned route instead
6
- * of the settings transport.
7
- *
8
- * The scope mirrors the settings-scope contract the vendored CardForm
9
- * consumes: `value` is the effective config, `base` the built-in defaults,
10
- * and `user` the raw saved overrides (a field's presence there marks it
11
- * overridden). Writes go straight through saveConfig and re-seed the
12
- * snapshot from the route's response, so the card's save-verification
13
- * (user layer equals the written value) sees the persisted state.
14
- */
15
- import type { SkillHubApi } from './api.ts';
16
- import type { FormScope } from './settings-form.ts';
17
- /** Model-invocable dot color default. Single source for the TS side; the
18
- * panel's CSS mirrors it via --hub-model (panel.module.css). */
19
- export declare const DEFAULT_DOT_MODEL_COLOR = "#2f81f7";
20
- /** User-invocable dot color default. Single source for the TS side; the
21
- * panel's CSS mirrors it via --hub-user (panel.module.css). */
22
- export declare const DEFAULT_DOT_USER_COLOR = "#3fb950";
23
- /** FormScope-compatible reactive config handle (see module doc). */
24
- export declare class ApiConfigScope implements FormScope {
25
- private readonly api;
26
- private status;
27
- private value;
28
- private saved;
29
- private readonly listeners;
30
- /** @param api - the browser-half API client (shared with the skill panel). */
31
- constructor(api: SkillHubApi);
32
- private load;
33
- subscribe(listener: () => void): () => void;
34
- getSnapshot(): {
35
- status: string;
36
- writable: boolean;
37
- value?: Record<string, unknown>;
38
- base?: unknown;
39
- user?: unknown;
40
- };
41
- /** Write one field through the config route and adopt the persisted state. */
42
- set(field: string, value: unknown): Promise<void>;
43
- /** Clear one field's override so it re-inherits the default. */
44
- unset(field: string): Promise<void>;
45
- /** Persist a patch, then re-seed from the route's fresh response. */
46
- private apply;
47
- private publish;
48
- }
@@ -1,79 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import type { ConfigRequest, ConfigResponse, HubConfig } from '../protocol.ts'
3
- import type { SkillHubApi } from './api.ts'
4
- import { ApiConfigScope } from './api-config-scope.ts'
5
-
6
- function deferredReady(scope: ApiConfigScope): Promise<void> {
7
- return new Promise((resolve) => setTimeout(resolve, 0))
8
- }
9
-
10
- describe('ApiConfigScope', () => {
11
- it('persists the panel display toggles (showUseCount etc.)', async () => {
12
- const saved: Partial<HubConfig> = {}
13
- const writes: ConfigRequest[] = []
14
- const api = {
15
- config: async (): Promise<ConfigResponse> => ({
16
- ok: true,
17
- config: {
18
- enabled: true,
19
- announceToAgent: true,
20
- showUseCount: true,
21
- showUseTime: true,
22
- showGroupSummary: true,
23
- },
24
- saved,
25
- }),
26
- saveConfig: async (patch: ConfigRequest): Promise<ConfigResponse> => {
27
- writes.push(patch)
28
- for (const [key, value] of Object.entries(patch)) {
29
- if (value === null) delete saved[key as keyof HubConfig]
30
- else saved[key as keyof HubConfig] = value as never
31
- }
32
- return {
33
- ok: true,
34
- config: {
35
- enabled: true,
36
- announceToAgent: true,
37
- showUseCount: saved.showUseCount !== false,
38
- showUseTime: saved.showUseTime !== false,
39
- showGroupSummary: saved.showGroupSummary !== false,
40
- },
41
- saved,
42
- }
43
- },
44
- } as unknown as SkillHubApi
45
-
46
- const scope = new ApiConfigScope(api)
47
- await deferredReady(scope)
48
-
49
- await scope.set('showUseCount', false)
50
- expect(writes).toContainEqual({ showUseCount: false })
51
- expect(scope.getSnapshot().value).toMatchObject({ showUseCount: false })
52
- expect(scope.getSnapshot().user).toMatchObject({ showUseCount: false })
53
-
54
- await scope.unset('showUseCount')
55
- expect(writes).toContainEqual({ showUseCount: null })
56
- expect(scope.getSnapshot().user).not.toHaveProperty('showUseCount')
57
- expect(scope.getSnapshot().value).toMatchObject({ showUseCount: true })
58
- })
59
-
60
- it('ignores unknown fields', async () => {
61
- const writes: ConfigRequest[] = []
62
- const api = {
63
- config: async (): Promise<ConfigResponse> => ({
64
- ok: true,
65
- config: { enabled: true, announceToAgent: true },
66
- saved: {},
67
- }),
68
- saveConfig: async (patch: ConfigRequest): Promise<ConfigResponse> => {
69
- writes.push(patch)
70
- return { ok: true, config: { enabled: true, announceToAgent: true }, saved: {} }
71
- },
72
- } as unknown as SkillHubApi
73
-
74
- const scope = new ApiConfigScope(api)
75
- await deferredReady(scope)
76
- await scope.set('not-a-field', true)
77
- expect(writes).toHaveLength(0)
78
- })
79
- })
@@ -1,119 +0,0 @@
1
- /**
2
- * ApiConfigScope — a FormScope-compatible view over the hub's own config
3
- * route (/api/skill-hub/config). The host's settings service refuses to
4
- * expose third-party namespaces to the web client (dsh-host-apiproxy's
5
- * allowlist), so the settings card talks to this plugin-owned route instead
6
- * of the settings transport.
7
- *
8
- * The scope mirrors the settings-scope contract the vendored CardForm
9
- * consumes: `value` is the effective config, `base` the built-in defaults,
10
- * and `user` the raw saved overrides (a field's presence there marks it
11
- * overridden). Writes go straight through saveConfig and re-seed the
12
- * snapshot from the route's response, so the card's save-verification
13
- * (user layer equals the written value) sees the persisted state.
14
- */
15
-
16
- import type { HubConfig } from '../protocol.ts'
17
- import type { SkillHubApi } from './api.ts'
18
- import type { FormScope } from './settings-form.ts'
19
-
20
- /** Model-invocable dot color default. Single source for the TS side; the
21
- * panel's CSS mirrors it via --hub-model (panel.module.css). */
22
- export const DEFAULT_DOT_MODEL_COLOR = '#2f81f7'
23
- /** User-invocable dot color default. Single source for the TS side; the
24
- * panel's CSS mirrors it via --hub-user (panel.module.css). */
25
- export const DEFAULT_DOT_USER_COLOR = '#3fb950'
26
-
27
- /** Built-in defaults every field inherits until the user overrides it. */
28
- const DEFAULTS: HubConfig = {
29
- enabled: true,
30
- announceToAgent: true,
31
- showUseCount: true,
32
- showUseTime: true,
33
- showGroupSummary: true,
34
- }
35
-
36
- /** Boolean config fields (color fields are strings). */
37
- const BOOLEAN_FIELDS = new Set<keyof HubConfig>([
38
- 'enabled', 'announceToAgent', 'showUseCount', 'showUseTime', 'showGroupSummary',
39
- ])
40
-
41
- /** Config fields the card edits (used to guard set/unset field names). */
42
- const FIELDS = new Set<keyof HubConfig>([
43
- 'enabled', 'announceToAgent', 'dotModelColor', 'dotUserColor',
44
- 'showUseCount', 'showUseTime', 'showGroupSummary',
45
- ])
46
-
47
- /** FormScope-compatible reactive config handle (see module doc). */
48
- export class ApiConfigScope implements FormScope {
49
- private status: 'loading' | 'ready' | 'unavailable' = 'loading'
50
- private value: HubConfig = { ...DEFAULTS }
51
- private saved: Partial<HubConfig> = {}
52
- private readonly listeners = new Set<() => void>()
53
-
54
- /** @param api - the browser-half API client (shared with the skill panel). */
55
- constructor(private readonly api: SkillHubApi) {
56
- void this.load()
57
- }
58
-
59
- private async load(): Promise<void> {
60
- try {
61
- const response = await this.api.config()
62
- this.value = response.config
63
- this.saved = { ...response.saved }
64
- this.status = 'ready'
65
- } catch {
66
- // The route is missing or unreachable (e.g. host half not yet updated):
67
- // the card shows its not-exposed state instead of breaking the GUI.
68
- this.status = 'unavailable'
69
- }
70
- this.publish()
71
- }
72
-
73
- subscribe(listener: () => void): () => void {
74
- this.listeners.add(listener)
75
- return () => { this.listeners.delete(listener) }
76
- }
77
-
78
- getSnapshot(): { status: string; writable: boolean; value?: Record<string, unknown>; base?: unknown; user?: unknown } {
79
- return {
80
- status: this.status,
81
- writable: true,
82
- value: { ...this.value } as unknown as Record<string, unknown>,
83
- base: { ...DEFAULTS },
84
- user: { ...this.saved },
85
- }
86
- }
87
-
88
- /** Write one field through the config route and adopt the persisted state. */
89
- async set(field: string, value: unknown): Promise<void> {
90
- if (!FIELDS.has(field as keyof HubConfig)) return
91
- const patch = BOOLEAN_FIELDS.has(field as keyof HubConfig)
92
- ? { [field]: Boolean(value) }
93
- : { [field]: typeof value === 'string' ? value : undefined }
94
- await this.apply(patch as Partial<HubConfig>)
95
- }
96
-
97
- /** Clear one field's override so it re-inherits the default. */
98
- async unset(field: string): Promise<void> {
99
- if (!FIELDS.has(field as keyof HubConfig)) return
100
- await this.apply({ [field]: undefined } as Partial<HubConfig>)
101
- }
102
-
103
- /** Persist a patch, then re-seed from the route's fresh response. */
104
- private async apply(patch: Partial<HubConfig>): Promise<void> {
105
- // null on the wire clears a saved override (see ConfigRequest).
106
- const request: Record<string, boolean | string | null> = {}
107
- for (const [key, value] of Object.entries(patch)) {
108
- request[key] = value === undefined ? null : value
109
- }
110
- const response = await this.api.saveConfig(request as never)
111
- this.value = response.config
112
- this.saved = { ...response.saved }
113
- this.publish()
114
- }
115
-
116
- private publish(): void {
117
- for (const listener of this.listeners) listener()
118
- }
119
- }