@nanmicoder/dsh-skills-hub 0.0.1 → 0.0.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 (48) hide show
  1. package/CONTRIBUTING.md +18 -0
  2. package/README.md +31 -82
  3. package/README_EN.md +31 -82
  4. package/README_ZH.md +2 -0
  5. package/compatibility.json +1 -1
  6. package/docs/COMPATIBILITY.md +34 -0
  7. package/docs/CONFIGURATION.md +38 -0
  8. package/docs/CONTRACT.md +25 -7
  9. package/docs/RELEASE.md +5 -5
  10. package/lib/client.js +1916 -1025
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +2 -0
  13. package/lib/index.js +16 -6
  14. package/lib/market/cache.js +104 -30
  15. package/lib/market/disk-cache.js +113 -0
  16. package/lib/market/install-service.js +8 -6
  17. package/lib/market/market-service.js +79 -29
  18. package/lib/market/provider-fetch.js +9 -2
  19. package/lib/market/types.js +2 -1
  20. package/lib/skills/management.js +182 -4
  21. package/lib/skills/root.js +1 -1
  22. package/lib/types/client/api.d.ts +33 -5
  23. package/lib/types/client/components/InstallConfirmDialog.d.ts +6 -0
  24. package/lib/types/client/components/InstalledSkillDetail.d.ts +24 -0
  25. package/lib/types/client/components/InstalledSkills.d.ts +18 -1
  26. package/lib/types/client/components/MarkdownView.d.ts +7 -1
  27. package/lib/types/client/components/SkillAvatar.d.ts +1 -1
  28. package/lib/types/client/components/SkillDetailShell.d.ts +63 -0
  29. package/lib/types/client/components/SkillDetailView.d.ts +4 -6
  30. package/lib/types/client/components/SkillFiles.d.ts +32 -0
  31. package/lib/types/client/components/SourceStatusBar.d.ts +4 -0
  32. package/lib/types/client/icons.d.ts +4 -0
  33. package/lib/types/client/locales.d.ts +30 -0
  34. package/lib/types/client/relative-time.d.ts +13 -0
  35. package/lib/types/client/skill-markdown.d.ts +39 -0
  36. package/lib/types/client/state.d.ts +13 -4
  37. package/lib/types/config.d.ts +2 -0
  38. package/lib/types/market/cache.d.ts +62 -14
  39. package/lib/types/market/disk-cache.d.ts +16 -0
  40. package/lib/types/market/market-service.d.ts +15 -3
  41. package/lib/types/market/provider-fetch.d.ts +2 -0
  42. package/lib/types/skills/management.d.ts +63 -2
  43. package/lib/types/skills/root.d.ts +8 -0
  44. package/lib/types/web-routes.d.ts +2 -0
  45. package/lib/web-routes.js +42 -4
  46. package/package.json +1 -1
  47. package/release-notes/v0.0.1.md +1 -1
  48. package/release-notes/v0.0.2.md +12 -0
@@ -29,6 +29,8 @@ export interface SkillsHubConfig {
29
29
  allowUninstall: boolean;
30
30
  /** Default `limit` for `GET /skills` when the request omits one (clamped to 1…100). */
31
31
  pageSize: number;
32
+ /** Lifetime of persisted market snapshots in minutes (1…1440). */
33
+ cacheTtlMinutes: number;
32
34
  }
33
35
  /**
34
36
  * Shipped defaults. Exported so `apply()` can fill in a partially resolved
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Skills Market — in-memory TTL cache with stale-while-error support, plus
2
+ * Skills Market — memory + persistent TTL cache with stale-while-error support, plus
3
3
  * per-source health tracking.
4
4
  *
5
5
  * Only *upstream* data is cached here (list pages, details, file bodies).
@@ -7,11 +7,11 @@
7
7
  * and is deliberately never cached: a skill installed a second ago must show up
8
8
  * as installed on the very next list call.
9
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`.
10
+ * The host configures disk storage at activation. Without a directory the
11
+ * cache stays memory-only, so provider tests never touch a user's Harness home.
12
12
  */
13
13
  import type { MarketSource, SourceHealthStatus, SourceStatusInfo } from './types.ts';
14
- /** Per-payload TTLs: cheap-to-refetch list pages expire sooner than file bodies. */
14
+ /** Default snapshot lifetime; activation can override it with cacheTtlMinutes. */
15
15
  export declare const MARKET_TTL: {
16
16
  readonly list: number;
17
17
  readonly search: number;
@@ -19,18 +19,67 @@ export declare const MARKET_TTL: {
19
19
  readonly files: number;
20
20
  readonly fileContent: number;
21
21
  };
22
- declare class MarketCache {
22
+ export interface MarketCacheOptions {
23
+ directory?: string;
24
+ ttlMs?: number;
25
+ onError?: (error: unknown) => void;
26
+ }
27
+ /** One fresh hit, together with the moment its payload was actually fetched. */
28
+ export interface MarketCacheRecord<T> {
29
+ value: T;
30
+ /** Epoch millis of the upstream read this payload came from. */
31
+ storedAt: number;
32
+ }
33
+ /**
34
+ * Counters describing what the cache actually did.
35
+ *
36
+ * "How much traffic does the cache save" used to be an argument. Every claim
37
+ * about hits, misses and needless upstream reads can now be checked against
38
+ * these numbers on a real machine instead of reasoned about.
39
+ */
40
+ export interface MarketCacheStats {
41
+ /** Answers served from a fresh memory or disk entry. */
42
+ hits: number;
43
+ /** Lookups that had to go upstream (including forced refreshes). */
44
+ misses: number;
45
+ /** Answers served from an expired entry because the source failed. */
46
+ staleServed: number;
47
+ /** Upstream page/detail requests actually issued. */
48
+ upstreamRequests: number;
49
+ /** Refreshes a reader explicitly asked for. */
50
+ forcedRefreshes: number;
51
+ }
52
+ /** Record one cache event. Kept tiny so it can sit on the request path. */
53
+ export declare function noteMarketStat(entry: keyof MarketCacheStats): void;
54
+ /** Snapshot of the counters; the caller cannot mutate the live object. */
55
+ export declare function getMarketStats(): MarketCacheStats;
56
+ export declare class MarketCache {
57
+ private options;
23
58
  private entries;
24
- get<T>(key: string): T | undefined;
59
+ private disk?;
60
+ private warned;
61
+ private ttlMs;
62
+ constructor(options?: MarketCacheOptions);
63
+ private warn;
64
+ private remember;
65
+ private read;
66
+ /**
67
+ * Read a fresh entry.
68
+ *
69
+ * The record carries `storedAt` on purpose: a hit is a *snapshot*, and the
70
+ * caller must be able to say how old it is. Returning only the payload is how
71
+ * the panel ended up reporting "fetched just now" for data it had not touched
72
+ * in ten minutes.
73
+ */
74
+ getRecord<T>(key: string): Promise<MarketCacheRecord<T> | undefined>;
25
75
  /** Returns the entry even when expired — used for the stale-while-error fallback. */
26
- getStale<T>(key: string): {
27
- value: T;
28
- storedAt: number;
29
- } | undefined;
30
- set(key: string, value: unknown, ttlMs: number): void;
76
+ getStale<T>(key: string): Promise<MarketCacheRecord<T> | undefined>;
77
+ set(key: string, value: unknown, ttlMs?: number): Promise<number>;
31
78
  clear(): void;
32
79
  }
33
- export declare const marketCache: MarketCache;
80
+ export declare let marketCache: MarketCache;
81
+ /** Replace memory state when activating a profile; disk entries are read lazily. */
82
+ export declare function configureMarketCache(options: MarketCacheOptions): void;
34
83
  /**
35
84
  * Record the outcome of one upstream interaction.
36
85
  *
@@ -44,6 +93,5 @@ export declare function markSourceHealth(source: MarketSource, status: SourceHea
44
93
  export declare function getSourceHealth(source: MarketSource): SourceStatusInfo;
45
94
  /** Test hook: forget every recorded success/failure. */
46
95
  export declare function resetSourceHealth(): void;
47
- /** Test hook: drop cached payloads and reset source health. */
96
+ /** Test hook: drop memory payloads, reset source health and the counters. */
48
97
  export declare function resetMarketCache(): void;
49
- export {};
@@ -0,0 +1,16 @@
1
+ export interface CacheEntry {
2
+ value: unknown;
3
+ expiresAt: number;
4
+ storedAt: number;
5
+ }
6
+ export declare const MAX_CACHE_ENTRIES = 500;
7
+ export declare const MAX_STALE_AGE_MS: number;
8
+ export declare class DiskMarketCache {
9
+ private directory;
10
+ private writes;
11
+ constructor(directory: string);
12
+ private filename;
13
+ read(key: string): Promise<CacheEntry | undefined>;
14
+ write(key: string, entry: CacheEntry): Promise<void>;
15
+ private prune;
16
+ }
@@ -5,7 +5,7 @@
5
5
  * cross-source dedupe, per-source health/degradation reporting, TTL caching
6
6
  * (stale-while-error) and locally-computed install state.
7
7
  *
8
- * The module is deliberately free of DSH services and of the filesystem: the
8
+ * The module is deliberately free of DSH services: the
9
9
  * "what is installed locally" question is answered by an injected
10
10
  * `InstalledLookup` (see `setInstalledLookup`), so this file can be unit-tested
11
11
  * by pointing the providers at a stub server.
@@ -60,15 +60,27 @@ export interface ListMarketSkillsParams {
60
60
  installed: 'all' | 'installed' | 'installable';
61
61
  cursor?: string;
62
62
  limit: number;
63
+ /** Reader-requested refresh: ignore fresh entries (they are still rewritten). */
64
+ refresh?: boolean;
63
65
  }
64
66
  export declare function listMarketSkills(params: ListMarketSkillsParams): Promise<MarketListResult>;
65
- export declare function getMarketSkillDetail(source: MarketSource, slug: string): Promise<{
67
+ export declare function getMarketSkillDetail(source: MarketSource, slug: string, options?: {
68
+ force?: boolean;
69
+ allowStale?: boolean;
70
+ }): Promise<{
66
71
  skill: NormalizedSkillDetail;
67
72
  sourceStatus: SourceStatusInfo;
68
73
  }>;
69
74
  export declare function isValidMarketFilePath(filePath: string): boolean;
70
75
  export declare function getMarketFileContent(source: MarketSource, slug: string, filePath: string): Promise<MarketFileContent>;
71
76
  export declare function getMarketStatus(): Record<MarketSource, SourceStatusInfo>;
72
- /** Look up a single skill (used by install) — the detail path, bypassing list. */
77
+ /**
78
+ * Look up a single skill for an install (the detail path, bypassing the list).
79
+ *
80
+ * Always forced: a cached manifest is a snapshot of the *catalogue*, and
81
+ * installing is the one action that has to be authorized by what upstream says
82
+ * right now — the file bytes are fetched fresh and hash-verified against the
83
+ * list, so the list must come from the same generation as those bytes.
84
+ */
73
85
  export declare function resolveMarketSkill(source: MarketSource, slug: string): Promise<NormalizedSkillDetail>;
74
86
  export {};
@@ -18,6 +18,8 @@ export interface ProviderEndpointConfig {
18
18
  retries: number;
19
19
  userAgent: string;
20
20
  }
21
+ /** Keep cache keys and all chained upstream reads on one configuration snapshot. */
22
+ export declare function withProviderConfiguration<T>(operation: () => Promise<T>): Promise<T>;
21
23
  /**
22
24
  * Point the providers at different hosts / limits. Called by the plugin with the
23
25
  * resolved Config; tests call it to aim the providers at a local stub server.
@@ -1,7 +1,68 @@
1
+ /**
2
+ * Local skill management: one discovered entry at a time.
3
+ *
4
+ * Every function here starts from a key, never from a client-supplied path: the
5
+ * roots are re-scanned on each call, and only an entry that scan just produced
6
+ * can be read or removed. A path that arrives from the browser is therefore
7
+ * always interpreted *inside* an entry this process has already vouched for.
8
+ *
9
+ * The detail read is what the panel's local skill page renders: the SKILL.md
10
+ * document, its raw frontmatter block and a bounded inventory of the skill's own
11
+ * files. Nothing here consults a market provider — an installed skill stays
12
+ * readable with the network down.
13
+ */
1
14
  import { type InstalledSkillRecord } from './installed.ts';
2
- export declare function readInstalledSkill(roots: string[], key: string): Promise<{
15
+ /** One file of an installed skill, relative to the entry. */
16
+ export interface InstalledFileEntry {
17
+ path: string;
18
+ size: number;
19
+ language: string;
20
+ }
21
+ /** `GET /installed/detail` body. */
22
+ export interface InstalledSkillDetail {
3
23
  item: InstalledSkillRecord;
24
+ /** Full SKILL.md text, frontmatter included: the browser strips it. */
4
25
  markdown: string;
5
- }>;
26
+ /** Verbatim YAML header without its fences, or `null` when the file has none. */
27
+ frontmatter: string | null;
28
+ files: InstalledFileEntry[];
29
+ }
30
+ /** `GET /installed/file` body; the shape mirrors the market file preview. */
31
+ export interface InstalledFileContent {
32
+ path: string;
33
+ content: string;
34
+ language: string;
35
+ size: number;
36
+ truncated: boolean;
37
+ }
38
+ /**
39
+ * Raw frontmatter block, kept as YAML text.
40
+ *
41
+ * Parsing it into structure would mean shipping a YAML reader for a panel that
42
+ * only needs to *show* the header, and the market detail's structured panel is
43
+ * fed by upstream JSON this plugin does not have for a hand-written skill. The
44
+ * verbatim block is also what the file actually contains, so nothing is lost.
45
+ */
46
+ export declare function extractFrontmatter(markdown: string): string | null;
47
+ /**
48
+ * Inventory one entry's own files.
49
+ *
50
+ * The walk never leaves the entry: directories are descended, symlinked entries
51
+ * are listed as nothing at all, and the provenance sidecar (a dot-file, like
52
+ * every other dot-entry) is skipped because it is plugin bookkeeping rather than
53
+ * installed payload. A flat skill is a single file and reports itself.
54
+ */
55
+ export declare function listEntryFiles(item: InstalledSkillRecord): Promise<InstalledFileEntry[]>;
56
+ /** Read the SKILL.md document plus the entry inventory the local detail page renders. */
57
+ export declare function readInstalledSkill(roots: string[], key: string): Promise<InstalledSkillDetail>;
58
+ /**
59
+ * Read one file of one entry.
60
+ *
61
+ * A flat skill exposes exactly itself — never its siblings in the same root —
62
+ * and a directory bundle exposes only what resolves back inside its own real
63
+ * path, so a symlink planted in the tree cannot turn this endpoint into an
64
+ * arbitrary-file reader.
65
+ */
66
+ export declare function readInstalledFile(roots: string[], key: string, requested: string): Promise<InstalledFileContent>;
6
67
  /** Rename the selected entry out of discovery before deletion. Symlink targets are never removed. */
7
68
  export declare function removeInstalledSkill(roots: string[], key: string, allowUninstall: boolean): Promise<InstalledSkillRecord>;
@@ -20,6 +20,14 @@
20
20
  * explicit path → `DSH_HOME` (blank treated as unset) → `~/.dsh`, plus the
21
21
  * `~`, `~/`, `~\` tilde expansion of `expandHomePath`.
22
22
  */
23
+ /**
24
+ * Resolve the single-root harness home.
25
+ *
26
+ * Precedence, highest first: an explicit path, `$DSH_HOME`, then `~/.dsh`. A
27
+ * blank `$DSH_HOME` is treated as unset so a stray empty variable can never
28
+ * resolve the harness home to the current working directory.
29
+ */
30
+ export declare function resolveDshHome(configured?: string | null): string;
23
31
  /**
24
32
  * Absolute skills directory skills are installed into, created on demand.
25
33
  *
@@ -30,6 +30,8 @@ export interface SkillsHubRoutesDeps {
30
30
  skillsRoot: () => Promise<string>;
31
31
  allowUninstall: () => boolean;
32
32
  rescan: () => Promise<void>;
33
+ /** Initial local inventory scan, required before annotating persisted data. */
34
+ ready?: () => Promise<void>;
33
35
  }
34
36
  /**
35
37
  * Browser-trust gate, structurally equal to the frozen contract's predicate.
package/lib/web-routes.js CHANGED
@@ -17,7 +17,8 @@
17
17
  */
18
18
  import { installMarketSkill, MarketInstallError, uninstallMarketSkill, } from "./market/install-service.js";
19
19
  import { getMarketFileContent, getMarketSkillDetail, getMarketStatus, isValidMarketFilePath, listMarketSkills, } from "./market/market-service.js";
20
- import { readInstalledSkill, removeInstalledSkill } from "./skills/management.js";
20
+ import { getMarketStats } from "./market/cache.js";
21
+ import { readInstalledFile, readInstalledSkill, removeInstalledSkill } from "./skills/management.js";
21
22
  import { scanInstalledSkills } from "./skills/installed.js";
22
23
  import { resolveSkillsScanRoots } from "./skills/root.js";
23
24
  import { MARKET_ERROR_CODES, MARKET_SOURCES, MarketUpstreamError, parseSkillId, } from "./market/types.js";
@@ -265,9 +266,30 @@ function parseBodyId(body) {
265
266
  throw new RouteError(400, 'BAD_REQUEST', `Invalid skill id: ${id === '' ? '(missing)' : id}`);
266
267
  return parsed;
267
268
  }
269
+ /**
270
+ * `refresh` query parameter.
271
+ *
272
+ * A reader who presses refresh means "do not answer me from your cache". Before
273
+ * this existed the request looked exactly like the automatic one, so the panel
274
+ * re-rendered the same cached page and presented it as fresh. Only the explicit
275
+ * spellings are accepted; anything else is a bad request rather than a silently
276
+ * ignored intent.
277
+ */
278
+ function parseRefresh(url) {
279
+ const raw = url.searchParams.get('refresh');
280
+ if (raw === null || raw === '' || raw === '0' || raw === 'false')
281
+ return false;
282
+ if (raw === '1' || raw === 'true')
283
+ return true;
284
+ throw new RouteError(400, 'BAD_REQUEST', `Invalid refresh flag: ${raw}`);
285
+ }
268
286
  async function handleStatus(res) {
269
287
  sendJson(res, 200, { sources: getMarketStatus() });
270
288
  }
289
+ /** Cache and upstream counters: the evidence behind every traffic claim. */
290
+ async function handleStats(res) {
291
+ sendJson(res, 200, { stats: getMarketStats() });
292
+ }
271
293
  async function handleList(url, res) {
272
294
  const params = {
273
295
  q: optionalQuery(url, 'q', MAX_QUERY_LENGTH),
@@ -276,12 +298,13 @@ async function handleList(url, res) {
276
298
  installed: enumQuery(url, 'installed', INSTALLED_FILTERS, 'all'),
277
299
  cursor: optionalQuery(url, 'cursor', MAX_CURSOR_LENGTH),
278
300
  limit: parseLimit(url),
301
+ refresh: parseRefresh(url),
279
302
  };
280
303
  const result = await listMarketSkills(params);
281
304
  sendJson(res, 200, result);
282
305
  }
283
- async function handleDetail(source, slug, res) {
284
- sendJson(res, 200, await getMarketSkillDetail(source, slug));
306
+ async function handleDetail(source, slug, url, res) {
307
+ sendJson(res, 200, await getMarketSkillDetail(source, slug, { force: parseRefresh(url) }));
285
308
  }
286
309
  async function handleFile(source, slug, url, res) {
287
310
  const filePath = url.searchParams.get('path') ?? '';
@@ -335,6 +358,7 @@ async function handle(req, res, gate, deps) {
335
358
  return;
336
359
  }
337
360
  }
361
+ await deps.ready?.();
338
362
  const url = new URL(req.url ?? '/', 'http://localhost');
339
363
  const segments = parseSegments(url.pathname);
340
364
  const method = req.method ?? 'GET';
@@ -344,12 +368,26 @@ async function handle(req, res, gate, deps) {
344
368
  await handleStatus(res);
345
369
  return;
346
370
  }
371
+ if (head === 'stats' && segments.length === 1) {
372
+ requireMethod(method, 'GET');
373
+ await handleStats(res);
374
+ return;
375
+ }
347
376
  if (head === 'installed' && segments.length === 2 && second === 'detail') {
348
377
  requireMethod(method, 'GET');
349
378
  const roots = await resolveSkillsScanRoots(await deps.skillsRoot());
350
379
  sendJson(res, 200, await readInstalledSkill(roots, url.searchParams.get('key') ?? ''));
351
380
  return;
352
381
  }
382
+ if (head === 'installed' && segments.length === 2 && second === 'file') {
383
+ requireMethod(method, 'GET');
384
+ const roots = await resolveSkillsScanRoots(await deps.skillsRoot());
385
+ // Both parameters are validated inside: a key must resolve to a freshly
386
+ // discovered entry, and the path must stay inside that entry.
387
+ const file = await readInstalledFile(roots, url.searchParams.get('key') ?? '', url.searchParams.get('path') ?? '');
388
+ sendJson(res, 200, { file });
389
+ return;
390
+ }
353
391
  if (head === 'installed' && segments.length === 2 && second === 'uninstall') {
354
392
  requireMethod(method, 'POST');
355
393
  const body = await readJsonBody(req);
@@ -387,7 +425,7 @@ async function handle(req, res, gate, deps) {
387
425
  const slug = parseSlug(third);
388
426
  requireMethod(method, 'GET');
389
427
  if (segments.length === 3) {
390
- await handleDetail(source, slug, res);
428
+ await handleDetail(source, slug, url, res);
391
429
  }
392
430
  else {
393
431
  await handleFile(source, slug, url, res);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanmicoder/dsh-skills-hub",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Skills Hub for DeepSeek Harness: browse, preview, install and remove third-party skills from ClawHub and SkillHub inside the DSH Web GUI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -8,4 +8,4 @@ Initial release of `@nanmicoder/dsh-skills-hub` for DeepSeek Harness's 0.2.0 des
8
8
  - Atomic installation, existing-directory conflict protection and restricted filesystem removal.
9
9
  - Scoped npm package, bilingual documentation, pinned compatibility metadata and reproducible build/package/release checks.
10
10
 
11
- Install: `dsh plugin --profile desktop add @nanmicoder/dsh-skills-hub@0.0.1`, then fully restart the desktop app. Installing the plugin does not migrate or delete existing skills.
11
+ Install in **DeepSeek Harness desktop → Plugins → Add plugin**. Paste `https://github.com/NanmiCoder/dsh-skills-hub` (recommended) or `@nanmicoder/dsh-skills-hub`, click **Install**, then **Enable now**. Open **Skills Hub** from the sidebar. Installing the plugin does not migrate or delete existing skills.
@@ -0,0 +1,12 @@
1
+ # Skills Hub 0.0.2
2
+
3
+ `@nanmicoder/dsh-skills-hub` for DeepSeek Harness **0.2.0-rc.2**.
4
+
5
+ - Persist browsed catalogue pages, searches, skill details and file previews across desktop restarts. The default cache lifetime is 60 minutes, configurable through `cacheTtlMinutes`.
6
+ - Preserve snapshot timestamps and each page's provenance. Reopening a detail checks the Host's cache lifetime and current local installation state.
7
+ - Keep installation manifests fresh: actual installs bypass cached details and refuse stale fallback when upstream fails.
8
+ - Bound and validate disk snapshots, recover abandoned temporary writes, and keep provider configurations isolated during reloads.
9
+ - Show installed skills in a full detail page, with rendered Markdown previews, structured frontmatter and a source view for file contents.
10
+ - Prefetch the next catalogue page near the end of the current view, and show snapshot age with an explicit refresh action.
11
+
12
+ Install in **DeepSeek Harness desktop → Plugins → Add plugin** using `@nanmicoder/dsh-skills-hub@0.0.2`, then enable the plugin. Existing skills remain in place. Advanced cache settings are documented in [CONFIGURATION.md](../docs/CONFIGURATION.md).