@nanmicoder/dsh-skills-hub 0.0.1

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 (67) hide show
  1. package/AGENTS.md +9 -0
  2. package/CONTRIBUTING.md +11 -0
  3. package/LICENSE +21 -0
  4. package/README.md +120 -0
  5. package/README_EN.md +120 -0
  6. package/README_ZH.md +5 -0
  7. package/assets/readme/hero-en.png +0 -0
  8. package/assets/readme/hero-zh.png +0 -0
  9. package/assets/readme/marketplace.png +0 -0
  10. package/compatibility.json +10 -0
  11. package/cordis.patch.yml +5 -0
  12. package/docs/COMPATIBILITY.md +31 -0
  13. package/docs/CONTRACT.md +125 -0
  14. package/docs/RELEASE.md +24 -0
  15. package/lib/client.js +25115 -0
  16. package/lib/client.js.map +1 -0
  17. package/lib/config.js +51 -0
  18. package/lib/index.js +176 -0
  19. package/lib/market/cache.js +113 -0
  20. package/lib/market/clawhub-provider.js +598 -0
  21. package/lib/market/install-service.js +394 -0
  22. package/lib/market/market-service.js +312 -0
  23. package/lib/market/provider-fetch.js +216 -0
  24. package/lib/market/skillhub-provider.js +277 -0
  25. package/lib/market/types.js +82 -0
  26. package/lib/skills/installed.js +426 -0
  27. package/lib/skills/management.js +36 -0
  28. package/lib/skills/read-text.js +23 -0
  29. package/lib/skills/root.js +112 -0
  30. package/lib/types/client/api.d.ts +106 -0
  31. package/lib/types/client/components/FilterBar.d.ts +20 -0
  32. package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
  33. package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
  34. package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
  35. package/lib/types/client/components/InstalledSkills.d.ts +3 -0
  36. package/lib/types/client/components/MarkdownView.d.ts +4 -0
  37. package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
  38. package/lib/types/client/components/MarketHome.d.ts +12 -0
  39. package/lib/types/client/components/SecurityBadge.d.ts +16 -0
  40. package/lib/types/client/components/SkillAvatar.d.ts +14 -0
  41. package/lib/types/client/components/SkillCard.d.ts +17 -0
  42. package/lib/types/client/components/SkillDetailView.d.ts +21 -0
  43. package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
  44. package/lib/types/client/icons.d.ts +71 -0
  45. package/lib/types/client/index.d.ts +56 -0
  46. package/lib/types/client/locale-context.d.ts +31 -0
  47. package/lib/types/client/locales.d.ts +117 -0
  48. package/lib/types/client/page/MarketPage.d.ts +21 -0
  49. package/lib/types/client/skill-markdown.d.ts +2 -0
  50. package/lib/types/client/state.d.ts +119 -0
  51. package/lib/types/config.d.ts +51 -0
  52. package/lib/types/index.d.ts +42 -0
  53. package/lib/types/market/cache.d.ts +49 -0
  54. package/lib/types/market/clawhub-provider.d.ts +28 -0
  55. package/lib/types/market/install-service.d.ts +75 -0
  56. package/lib/types/market/market-service.d.ts +74 -0
  57. package/lib/types/market/provider-fetch.d.ts +61 -0
  58. package/lib/types/market/skillhub-provider.d.ts +13 -0
  59. package/lib/types/market/types.d.ts +163 -0
  60. package/lib/types/skills/installed.d.ts +117 -0
  61. package/lib/types/skills/management.d.ts +7 -0
  62. package/lib/types/skills/read-text.d.ts +5 -0
  63. package/lib/types/skills/root.d.ts +48 -0
  64. package/lib/types/web-routes.d.ts +74 -0
  65. package/lib/web-routes.js +427 -0
  66. package/package.json +160 -0
  67. package/release-notes/v0.0.1.md +11 -0
package/lib/config.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Skills Hub configuration.
3
+ *
4
+ * The schema below is the plugin's whole configuration surface: DSH validates
5
+ * the profile's plugin row against it and hands `apply()` the resolved value.
6
+ * Every field carries a default, so a row written as `{}` — or an `apply()`
7
+ * called directly from a test — still boots the plugin with the shipped
8
+ * behaviour instead of failing on a missing key.
9
+ *
10
+ * `skillsRoot: null` means "the user skills directory DSH itself scans"
11
+ * (`$DSH_HOME/skills`); a string is an explicit override for tests, for
12
+ * throwaway profiles, or for a deployment that keeps skills outside the
13
+ * harness home. The reference implementation hard-coded its own
14
+ * `~/.claude/skills`, which is exactly the coupling this field removes.
15
+ */
16
+ import z from '@deepseek-ai/schemastery';
17
+ /**
18
+ * Shipped defaults. Exported so `apply()` can fill in a partially resolved
19
+ * config when the plugin is mounted without going through the schema (unit
20
+ * tests, direct composition), without restating the literals in two places.
21
+ */
22
+ export const SKILLS_HUB_DEFAULTS = {
23
+ skillsRoot: null,
24
+ clawhubBaseUrl: 'https://clawhub.ai',
25
+ skillhubBaseUrl: 'https://api.skillhub.cn',
26
+ timeoutMs: 15_000,
27
+ requestRetries: 1,
28
+ allowUninstall: true,
29
+ pageSize: 24,
30
+ };
31
+ /**
32
+ * `null` must stay a legal value for `skillsRoot` (it is the "use the DSH
33
+ * default" marker), so the field is modelled as a union rather than as a
34
+ * nullable string: a plain `z.string().default('')` would make "unset" and
35
+ * "empty string" indistinguishable and would leak an empty path into `path.join`.
36
+ *
37
+ * Schemastery quirk worth knowing: its object resolver drops a key whose
38
+ * resolved value is nullable and which was absent from the input, so
39
+ * `Config({}).skillsRoot` is `undefined`, not `null`. Both mean "use the DSH
40
+ * default" and `apply()` normalizes either to `null` before using it, so a
41
+ * caller that bypasses the schema cannot change the behaviour.
42
+ */
43
+ export const Config = z.object({
44
+ skillsRoot: z.union([z.string(), z.const(null)]).default(null),
45
+ clawhubBaseUrl: z.string().default(SKILLS_HUB_DEFAULTS.clawhubBaseUrl),
46
+ skillhubBaseUrl: z.string().default(SKILLS_HUB_DEFAULTS.skillhubBaseUrl),
47
+ timeoutMs: z.natural().min(1_000).default(SKILLS_HUB_DEFAULTS.timeoutMs),
48
+ requestRetries: z.natural().default(SKILLS_HUB_DEFAULTS.requestRetries),
49
+ allowUninstall: z.boolean().default(SKILLS_HUB_DEFAULTS.allowUninstall),
50
+ pageSize: z.natural().min(1).max(100).default(SKILLS_HUB_DEFAULTS.pageSize),
51
+ });
package/lib/index.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Skills Hub — DeepSeek Harness host plugin.
3
+ *
4
+ * Adds the market half of the Skills Hub panel to a DSH profile:
5
+ *
6
+ * - `/api/skills-hub/*` (browse, search, detail, file preview, installed,
7
+ * install, uninstall) served from the composition's Web server;
8
+ * - installation into the DSH user skills directory, so an installed skill is
9
+ * immediately discoverable by the `skill` tool;
10
+ * - the installed-skill index the market layer asks for via
11
+ * `setInstalledLookup()`.
12
+ *
13
+ * Installation is deliberately not a realm feature: it is plain filesystem work
14
+ * plus HTTP, no agent session is involved, and the tools registry is untouched.
15
+ *
16
+ * @module dsh-skills-hub
17
+ */
18
+ import { Config, SKILLS_HUB_DEFAULTS } from "./config.js";
19
+ import { configureProviderFetch } from "./market/provider-fetch.js";
20
+ import { setInstalledLookup, resetInstalledLookup } from "./market/market-service.js";
21
+ import { installedLookupFrom, scanInstalledSkills } from "./skills/installed.js";
22
+ import { resolveSkillsRoot, resolveSkillsScanRoots } from "./skills/root.js";
23
+ import { registerSkillsHubRoutes, setSkillsHubPageSize, } from "./web-routes.js";
24
+ export const name = 'skills-hub';
25
+ /**
26
+ * Required services.
27
+ *
28
+ * Empty on purpose, and the honest answer for this plugin: nothing must exist for
29
+ * it to *load*. The Web server is looked up lazily (`ctx.get`, both known keys)
30
+ * and bound whenever it appears, so a headless profile keeps the plugin inert
31
+ * instead of pending forever on a service key that composition never provides;
32
+ * the skill registry is optional (it only informs a post-install catalog read);
33
+ * and the market core is pure code with no service dependency at all. Injecting
34
+ * `'webServer'` here would make the plugin unusable in the very profiles where
35
+ * the harness would still boot it, and would break on a composition that spells
36
+ * the service `httpServer`.
37
+ */
38
+ export const inject = [];
39
+ export { Config };
40
+ /** Web-server service keys, newest first (mirrors `WEB_SERVER_KEYS` in dsh-agent-teams). */
41
+ const WEB_SERVER_KEYS = ['webServer', 'httpServer'];
42
+ /** Fill in defaults when `apply()` is called without going through the schema. */
43
+ function resolveConfig(config) {
44
+ const skillsRoot = typeof config?.skillsRoot === 'string' && config.skillsRoot.trim() !== ''
45
+ ? config.skillsRoot
46
+ : null;
47
+ return {
48
+ skillsRoot,
49
+ clawhubBaseUrl: config?.clawhubBaseUrl ?? SKILLS_HUB_DEFAULTS.clawhubBaseUrl,
50
+ skillhubBaseUrl: config?.skillhubBaseUrl ?? SKILLS_HUB_DEFAULTS.skillhubBaseUrl,
51
+ timeoutMs: config?.timeoutMs ?? SKILLS_HUB_DEFAULTS.timeoutMs,
52
+ requestRetries: config?.requestRetries ?? SKILLS_HUB_DEFAULTS.requestRetries,
53
+ allowUninstall: config?.allowUninstall ?? SKILLS_HUB_DEFAULTS.allowUninstall,
54
+ pageSize: config?.pageSize ?? SKILLS_HUB_DEFAULTS.pageSize,
55
+ };
56
+ }
57
+ /**
58
+ * Re-read the skill catalog after a successful filesystem change.
59
+ *
60
+ * There is no public, registry-wide rescan entry point to call: `SkillRegistry`
61
+ * exposes list/snapshot/get/register/registerProvider, the invalidation hooks
62
+ * are either provider-scoped (`SkillProviderControl.invalidate`) or the
63
+ * actor-gated `fs/observed` event — which this plugin must not forge, because
64
+ * the event carries an authoritative tool observation for the whole host. What
65
+ * actually republishes our change is DSH's own watcher: the filesystem skill
66
+ * provider watches every configured root (`watch` defaults to true) and
67
+ * invalidates its catalog on the `addDir`/`unlinkDir` that the atomic publish
68
+ * produces.
69
+ *
70
+ * So this call is a read, not a mutation: it settles the catalog for a consumer
71
+ * asking right after the HTTP response instead of leaving that consumer to race
72
+ * the watcher's stability threshold. A composition without the service (headless
73
+ * profile) simply skips it.
74
+ */
75
+ async function refreshSkillCatalog(ctx) {
76
+ const skills = ctx.get('skills');
77
+ if (skills?.snapshot === undefined)
78
+ return;
79
+ try {
80
+ await skills.snapshot({});
81
+ }
82
+ catch (error) {
83
+ ctx.logger.warn(`skills-hub: skill catalog refresh failed: ${String(error)}`);
84
+ }
85
+ }
86
+ /**
87
+ * Activate the plugin.
88
+ *
89
+ * @param ctx - host plugin context.
90
+ * @param config - resolved {@link SkillsHubConfig} (schema defaults already applied).
91
+ */
92
+ export function apply(ctx, config) {
93
+ const resolved = resolveConfig(config);
94
+ // Provider endpoints, timeout and retry budget are process-wide in the market
95
+ // core (the reference behaves the same way); the user agent keeps W1's default
96
+ // because the frozen Config surface has no field for it.
97
+ configureProviderFetch({
98
+ clawhubBaseUrl: resolved.clawhubBaseUrl,
99
+ skillhubBaseUrl: resolved.skillhubBaseUrl,
100
+ timeoutMs: resolved.timeoutMs,
101
+ retries: resolved.requestRetries,
102
+ });
103
+ setSkillsHubPageSize(resolved.pageSize);
104
+ let disposed = false;
105
+ /** Rebuild the installed index and hand it to the market layer. */
106
+ const refreshInstalledIndex = async () => {
107
+ const roots = await resolveSkillsScanRoots(resolved.skillsRoot);
108
+ const records = await scanInstalledSkills(roots);
109
+ // A refresh that lost the race with unload must not publish over a newer
110
+ // plugin instance that may already own the seam.
111
+ if (disposed)
112
+ return;
113
+ setInstalledLookup(installedLookupFrom(records));
114
+ };
115
+ /**
116
+ * The single refresh entry point: routes call it after install/uninstall, and
117
+ * it also runs once at activation. It never throws — the caller has already
118
+ * succeeded at the operation it is refreshing for.
119
+ */
120
+ const rescan = async () => {
121
+ try {
122
+ await refreshInstalledIndex();
123
+ }
124
+ catch (error) {
125
+ ctx.logger.warn(`skills-hub: installed-skill index refresh failed: ${String(error)}`);
126
+ return;
127
+ }
128
+ await refreshSkillCatalog(ctx);
129
+ };
130
+ ctx.effect(() => () => {
131
+ disposed = true;
132
+ // The market layer is module state that outlives this plugin instance: drop
133
+ // our installed-skill index so a reloaded instance cannot serve stale
134
+ // install state (and `disposed` above stops an in-flight scan from
135
+ // republishing it).
136
+ resetInstalledLookup();
137
+ }, 'skills-hub: activation state');
138
+ // First scan: `apply()` is synchronous, so this cannot be awaited here. Until
139
+ // it settles the market layer keeps its empty default lookup (skills show as
140
+ // installable, and the installer's own conflict check still refuses to clobber
141
+ // an existing directory), and /installed reads the disk directly.
142
+ void rescan();
143
+ // The Web server may bind before or after this plugin (the Loader activates
144
+ // rows concurrently), and older compositions name the service `httpServer`.
145
+ // Bind lazily on the first service event that makes a server reachable.
146
+ let webRegistered = false;
147
+ const registerWebSurface = () => {
148
+ if (webRegistered)
149
+ return;
150
+ const webServer = (ctx.get(WEB_SERVER_KEYS[0]) ?? ctx.get(WEB_SERVER_KEYS[1]));
151
+ if (webServer === undefined)
152
+ return;
153
+ // `requestRejection` belongs to the browser-trust fence of the Connection
154
+ // service. Resolved per request, never captured here: the Connection row can
155
+ // activate after this route is registered, and a snapshot would leave the
156
+ // marketplace readable — and installable — from any web page.
157
+ const gate = () => ctx.get('connection');
158
+ const deps = {
159
+ skillsRoot: () => resolveSkillsRoot(resolved.skillsRoot),
160
+ allowUninstall: () => resolved.allowUninstall,
161
+ rescan,
162
+ };
163
+ // `ctx.effect` runs the factory immediately; `dsh-host-webserver` throws on a
164
+ // duplicate route path, so the flag may only be set once registration has
165
+ // actually succeeded. Setting it first turned a failed registration into a
166
+ // permanent activation failure with no retry on the next service event.
167
+ ctx.effect(() => registerSkillsHubRoutes(webServer, gate, deps), 'skills-hub: HTTP API');
168
+ webRegistered = true;
169
+ };
170
+ registerWebSurface();
171
+ ctx.on('internal/service', (serviceName) => {
172
+ if (serviceName === WEB_SERVER_KEYS[0] || serviceName === WEB_SERVER_KEYS[1])
173
+ registerWebSurface();
174
+ });
175
+ ctx.logger.info(`skills-hub: ready (root=${resolved.skillsRoot ?? '$DSH_HOME/skills'}, uninstall=${resolved.allowUninstall ? 'on' : 'off'})`);
176
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Skills Market — in-memory TTL cache with stale-while-error support, plus
3
+ * per-source health tracking.
4
+ *
5
+ * Only *upstream* data is cached here (list pages, details, file bodies).
6
+ * Install state is derived per request from the injected installed-skill index
7
+ * and is deliberately never cached: a skill installed a second ago must show up
8
+ * as installed on the very next list call.
9
+ *
10
+ * Everything lives in module scope, so the module needs no DSH service and no
11
+ * filesystem — a unit test can drive it with plain `node --test`.
12
+ */
13
+ /** Hard cap so a long-lived host process cannot grow the map without bound. */
14
+ const MAX_ENTRIES = 500;
15
+ /** Per-payload TTLs: cheap-to-refetch list pages expire sooner than file bodies. */
16
+ export const MARKET_TTL = {
17
+ list: 5 * 60_000,
18
+ search: 2 * 60_000,
19
+ detail: 10 * 60_000,
20
+ files: 10 * 60_000,
21
+ fileContent: 30 * 60_000,
22
+ };
23
+ class MarketCache {
24
+ entries = new Map();
25
+ get(key) {
26
+ const entry = this.entries.get(key);
27
+ if (!entry)
28
+ return undefined;
29
+ if (Date.now() > entry.expiresAt)
30
+ return undefined;
31
+ // LRU touch: re-insert so the oldest key is the least recently used one.
32
+ this.entries.delete(key);
33
+ this.entries.set(key, entry);
34
+ return entry.value;
35
+ }
36
+ /** Returns the entry even when expired — used for the stale-while-error fallback. */
37
+ getStale(key) {
38
+ const entry = this.entries.get(key);
39
+ if (!entry)
40
+ return undefined;
41
+ return { value: entry.value, storedAt: entry.storedAt };
42
+ }
43
+ set(key, value, ttlMs) {
44
+ if (this.entries.has(key))
45
+ this.entries.delete(key);
46
+ const now = Date.now();
47
+ this.entries.set(key, { value, expiresAt: now + ttlMs, storedAt: now });
48
+ if (this.entries.size > MAX_ENTRIES) {
49
+ // Map iteration is insertion-ordered, so the first key is the oldest.
50
+ const oldest = this.entries.keys().next().value;
51
+ if (oldest !== undefined)
52
+ this.entries.delete(oldest);
53
+ }
54
+ }
55
+ clear() {
56
+ this.entries.clear();
57
+ }
58
+ }
59
+ export const marketCache = new MarketCache();
60
+ function freshHealth() {
61
+ return {
62
+ clawhub: { status: 'ok' },
63
+ skillhub: { status: 'ok' },
64
+ };
65
+ }
66
+ const sourceHealth = freshHealth();
67
+ /**
68
+ * Record the outcome of one upstream interaction.
69
+ *
70
+ * A `failed` report is downgraded to `degraded` when the source succeeded
71
+ * recently (it answered a moment ago, this request just went wrong) — repeated
72
+ * failures after the last success report `failed`. This mirrors the reference
73
+ * server's `recordSourceFailure`, which is what the UI's source bar was built
74
+ * around; callers that genuinely know better can pass `degraded` explicitly.
75
+ */
76
+ export function markSourceHealth(source, status, error) {
77
+ const prev = sourceHealth[source];
78
+ if (status === 'ok') {
79
+ sourceHealth[source] = { status: 'ok', lastOkAt: Date.now() };
80
+ return;
81
+ }
82
+ if (status === 'cached') {
83
+ sourceHealth[source] = { status: 'cached', lastOkAt: prev.lastOkAt, lastError: error };
84
+ return;
85
+ }
86
+ sourceHealth[source] = {
87
+ status: status === 'degraded' || (prev.status === 'ok' && prev.lastOkAt !== undefined)
88
+ ? 'degraded'
89
+ : 'failed',
90
+ lastOkAt: prev.lastOkAt,
91
+ lastError: error,
92
+ };
93
+ }
94
+ export function getSourceHealth(source) {
95
+ const record = sourceHealth[source];
96
+ return {
97
+ status: record.status,
98
+ fetchedAt: record.lastOkAt,
99
+ error: record.lastError,
100
+ };
101
+ }
102
+ /** Test hook: forget every recorded success/failure. */
103
+ export function resetSourceHealth() {
104
+ const fresh = freshHealth();
105
+ for (const source of Object.keys(fresh)) {
106
+ sourceHealth[source] = fresh[source];
107
+ }
108
+ }
109
+ /** Test hook: drop cached payloads and reset source health. */
110
+ export function resetMarketCache() {
111
+ marketCache.clear();
112
+ resetSourceHealth();
113
+ }