@nanmicoder/dsh-skills-hub 0.0.1 → 0.0.3

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/CONTRIBUTING.md +22 -0
  2. package/README.md +32 -82
  3. package/README_EN.md +32 -82
  4. package/README_ZH.md +2 -0
  5. package/assets/readme/marketplace.png +0 -0
  6. package/compatibility.json +1 -1
  7. package/docs/CATALOG.md +39 -0
  8. package/docs/COMPATIBILITY.md +46 -0
  9. package/docs/CONFIGURATION.md +38 -0
  10. package/docs/CONTRACT.md +35 -12
  11. package/docs/RELEASE.md +5 -5
  12. package/lib/catalog/catalog.js +89 -0
  13. package/lib/catalog/skills.json +8673 -0
  14. package/lib/client.js +3349 -1728
  15. package/lib/client.js.map +1 -1
  16. package/lib/config.js +2 -0
  17. package/lib/index.js +16 -6
  18. package/lib/market/cache.js +104 -30
  19. package/lib/market/clawhub-provider.js +139 -22
  20. package/lib/market/disk-cache.js +113 -0
  21. package/lib/market/install-service.js +24 -11
  22. package/lib/market/market-service.js +161 -32
  23. package/lib/market/provider-fetch.js +9 -2
  24. package/lib/market/skillhub-provider.js +6 -2
  25. package/lib/market/types.js +2 -1
  26. package/lib/skills/installed.js +5 -0
  27. package/lib/skills/management.js +182 -4
  28. package/lib/skills/root.js +1 -1
  29. package/lib/types/catalog/catalog.d.ts +73 -0
  30. package/lib/types/client/api.d.ts +45 -8
  31. package/lib/types/client/components/CategoryBar.d.ts +13 -0
  32. package/lib/types/client/components/FilterBar.d.ts +4 -0
  33. package/lib/types/client/components/InstallConfirmDialog.d.ts +20 -11
  34. package/lib/types/client/components/InstalledSkillDetail.d.ts +24 -0
  35. package/lib/types/client/components/InstalledSkills.d.ts +18 -1
  36. package/lib/types/client/components/MarkdownView.d.ts +7 -1
  37. package/lib/types/client/components/MarketSkillDetail.d.ts +34 -0
  38. package/lib/types/client/components/SkillAvatar.d.ts +1 -1
  39. package/lib/types/client/components/SkillDetailShell.d.ts +86 -0
  40. package/lib/types/client/components/SkillFiles.d.ts +32 -0
  41. package/lib/types/client/components/SourceStatusBar.d.ts +4 -0
  42. package/lib/types/client/icons.d.ts +4 -0
  43. package/lib/types/client/locales.d.ts +94 -0
  44. package/lib/types/client/relative-time.d.ts +13 -0
  45. package/lib/types/client/skill-insights.d.ts +30 -0
  46. package/lib/types/client/skill-markdown.d.ts +39 -0
  47. package/lib/types/client/state.d.ts +36 -7
  48. package/lib/types/config.d.ts +2 -0
  49. package/lib/types/market/cache.d.ts +62 -14
  50. package/lib/types/market/clawhub-provider.d.ts +15 -0
  51. package/lib/types/market/disk-cache.d.ts +16 -0
  52. package/lib/types/market/install-service.d.ts +1 -0
  53. package/lib/types/market/market-service.d.ts +40 -6
  54. package/lib/types/market/provider-fetch.d.ts +2 -0
  55. package/lib/types/market/types.d.ts +29 -0
  56. package/lib/types/skills/installed.d.ts +4 -0
  57. package/lib/types/skills/management.d.ts +63 -2
  58. package/lib/types/skills/root.d.ts +8 -0
  59. package/lib/types/web-routes.d.ts +3 -1
  60. package/lib/web-routes.js +85 -8
  61. package/package.json +1 -1
  62. package/release-notes/v0.0.1.md +1 -1
  63. package/release-notes/v0.0.2.md +12 -0
  64. package/release-notes/v0.0.3.md +12 -0
  65. package/lib/types/client/components/InstallStateBadge.d.ts +0 -11
  66. package/lib/types/client/components/SecurityBadge.d.ts +0 -16
  67. package/lib/types/client/components/SkillDetailView.d.ts +0 -21
@@ -1,10 +1,31 @@
1
- /** Local management only addresses entries freshly discovered inside configured user roots. */
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
+ */
2
14
  import * as fs from 'node:fs/promises';
3
15
  import * as path from 'node:path';
4
- import { readBoundedText } from "./read-text.js";
5
16
  import { randomUUID } from 'node:crypto';
6
17
  import { MarketInstallError } from "../market/install-service.js";
18
+ import { detectMarketLanguage } from "../market/types.js";
7
19
  import { scanInstalledSkills } from "./installed.js";
20
+ import { readBoundedText } from "./read-text.js";
21
+ /** Largest SKILL.md the local detail endpoint returns, matching the market preview policy. */
22
+ const MAX_DOCUMENT_BYTES = 2 * 1024 * 1024;
23
+ /** Largest single document the browser may pull into the file viewer. */
24
+ const MAX_PREVIEW_BYTES = 300 * 1024;
25
+ /** Frontmatter kept for the metadata panel; a pathological header cannot fill it. */
26
+ const MAX_FRONTMATTER_BYTES = 16 * 1024;
27
+ /** Entries listed for one skill before the walk stops. */
28
+ const MAX_LISTED_FILES = 500;
8
29
  async function resolveEntry(roots, key) {
9
30
  if (!/^[a-f0-9]{64}$/.test(key))
10
31
  throw new MarketInstallError(400, 'BAD_REQUEST', 'Invalid installed skill key');
@@ -13,15 +34,172 @@ async function resolveEntry(roots, key) {
13
34
  throw new MarketInstallError(404, 'MARKET_NOT_INSTALLED', 'Installed skill no longer exists');
14
35
  return entry;
15
36
  }
37
+ /**
38
+ * Raw frontmatter block, kept as YAML text.
39
+ *
40
+ * Parsing it into structure would mean shipping a YAML reader for a panel that
41
+ * only needs to *show* the header, and the market detail's structured panel is
42
+ * fed by upstream JSON this plugin does not have for a hand-written skill. The
43
+ * verbatim block is also what the file actually contains, so nothing is lost.
44
+ */
45
+ export function extractFrontmatter(markdown) {
46
+ const text = markdown.startsWith('\uFEFF') ? markdown.slice(1) : markdown;
47
+ const lines = text.split(/\r?\n/);
48
+ if (lines[0]?.trim() !== '---')
49
+ return null;
50
+ for (let index = 1; index < lines.length; index++) {
51
+ const marker = (lines[index] ?? '').trim();
52
+ if (marker !== '---' && marker !== '...')
53
+ continue;
54
+ const block = lines.slice(1, index).join('\n').trim();
55
+ return block === '' ? null : block.slice(0, MAX_FRONTMATTER_BYTES);
56
+ }
57
+ // Unterminated header: the document is ordinary Markdown, as the browser's
58
+ // strip helper already assumes.
59
+ return null;
60
+ }
61
+ /**
62
+ * Inventory one entry's own files.
63
+ *
64
+ * The walk never leaves the entry: directories are descended, symlinked entries
65
+ * are listed as nothing at all, and the provenance sidecar (a dot-file, like
66
+ * every other dot-entry) is skipped because it is plugin bookkeeping rather than
67
+ * installed payload. A flat skill is a single file and reports itself.
68
+ */
69
+ export async function listEntryFiles(item) {
70
+ const stats = await fs.stat(item.dirPath);
71
+ if (!stats.isDirectory()) {
72
+ return [{ path: path.basename(item.dirPath), size: stats.size, language: detectMarketLanguage(item.dirPath) }];
73
+ }
74
+ const files = [];
75
+ const pending = [''];
76
+ while (pending.length > 0 && files.length < MAX_LISTED_FILES) {
77
+ const relativeDir = pending.pop();
78
+ if (relativeDir === undefined)
79
+ break;
80
+ const absoluteDir = relativeDir === '' ? item.dirPath : path.join(item.dirPath, relativeDir);
81
+ let entries;
82
+ try {
83
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
84
+ }
85
+ catch {
86
+ // Raced with a concurrent removal — list what is still there.
87
+ continue;
88
+ }
89
+ entries.sort((left, right) => left.name.localeCompare(right.name));
90
+ for (const entry of entries) {
91
+ if (files.length >= MAX_LISTED_FILES)
92
+ break;
93
+ if (entry.name.startsWith('.'))
94
+ continue;
95
+ const relative = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
96
+ if (entry.isDirectory()) {
97
+ pending.push(relative);
98
+ continue;
99
+ }
100
+ // `isFile()` excludes symlinks: a linked file is not part of the payload.
101
+ if (!entry.isFile())
102
+ continue;
103
+ try {
104
+ const fileStats = await fs.lstat(path.join(absoluteDir, entry.name));
105
+ if (!fileStats.isFile())
106
+ continue;
107
+ files.push({ path: relative, size: fileStats.size, language: detectMarketLanguage(entry.name) });
108
+ }
109
+ catch {
110
+ // Raced with a concurrent removal.
111
+ }
112
+ }
113
+ }
114
+ files.sort((left, right) => left.path.localeCompare(right.path));
115
+ return files;
116
+ }
117
+ /**
118
+ * Validate one browser-supplied relative path.
119
+ *
120
+ * Absolute paths, `..` segments, empty segments and NUL are rejected outright;
121
+ * the caller then proves containment against the entry's real path, so a
122
+ * traversal cannot be smuggled in through either spelling or a symlink.
123
+ */
124
+ function requireRelativePath(value) {
125
+ const normalized = value.replace(/\\/g, '/').replace(/^\.\//, '');
126
+ if (normalized === '' || normalized.includes('\0') || path.isAbsolute(normalized)) {
127
+ throw new MarketInstallError(400, 'BAD_REQUEST', 'Invalid skill file path');
128
+ }
129
+ const segments = normalized.split('/');
130
+ if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
131
+ throw new MarketInstallError(400, 'BAD_REQUEST', 'Invalid skill file path');
132
+ }
133
+ return segments.join('/');
134
+ }
135
+ /** Whether `candidate` is `root` itself or sits underneath it. */
136
+ function contains(root, candidate) {
137
+ return candidate === root || candidate.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
138
+ }
139
+ /** Read the SKILL.md document plus the entry inventory the local detail page renders. */
16
140
  export async function readInstalledSkill(roots, key) {
17
141
  const item = await resolveEntry(roots, key);
18
142
  const stats = await fs.stat(item.dirPath);
19
143
  const file = stats.isDirectory() ? path.join(item.dirPath, 'SKILL.md') : item.dirPath;
20
- const content = await readBoundedText(file, 2 * 1024 * 1024);
144
+ const content = await readBoundedText(file, MAX_DOCUMENT_BYTES);
21
145
  if (content.truncated) {
22
146
  throw new MarketInstallError(413, 'FILE_TOO_LARGE', 'Skill preview exceeds 2 MB');
23
147
  }
24
- return { item, markdown: content.text };
148
+ return {
149
+ item,
150
+ markdown: content.text,
151
+ frontmatter: extractFrontmatter(content.text),
152
+ files: await listEntryFiles(item),
153
+ };
154
+ }
155
+ /**
156
+ * Read one file of one entry.
157
+ *
158
+ * A flat skill exposes exactly itself — never its siblings in the same root —
159
+ * and a directory bundle exposes only what resolves back inside its own real
160
+ * path, so a symlink planted in the tree cannot turn this endpoint into an
161
+ * arbitrary-file reader.
162
+ */
163
+ export async function readInstalledFile(roots, key, requested) {
164
+ const item = await resolveEntry(roots, key);
165
+ const relative = requireRelativePath(requested);
166
+ const stats = await fs.stat(item.dirPath);
167
+ let documentPath;
168
+ if (stats.isDirectory()) {
169
+ const base = await fs.realpath(item.dirPath);
170
+ const candidate = path.resolve(base, relative);
171
+ if (!contains(base, candidate))
172
+ throw new MarketInstallError(400, 'BAD_REQUEST', 'Invalid skill file path');
173
+ let resolved;
174
+ try {
175
+ resolved = await fs.realpath(candidate);
176
+ }
177
+ catch {
178
+ throw new MarketInstallError(404, 'MARKET_NOT_INSTALLED', 'Skill file no longer exists');
179
+ }
180
+ if (!contains(base, resolved)) {
181
+ throw new MarketInstallError(400, 'BAD_REQUEST', 'Skill file escapes its directory');
182
+ }
183
+ documentPath = resolved;
184
+ }
185
+ else {
186
+ if (relative !== path.basename(item.dirPath)) {
187
+ throw new MarketInstallError(400, 'BAD_REQUEST', 'Invalid skill file path');
188
+ }
189
+ documentPath = await fs.realpath(item.dirPath);
190
+ }
191
+ const documentStats = await fs.stat(documentPath);
192
+ if (!documentStats.isFile())
193
+ throw new MarketInstallError(400, 'BAD_REQUEST', 'Skill file is not a document');
194
+ const content = await readBoundedText(documentPath, MAX_PREVIEW_BYTES);
195
+ return {
196
+ path: relative,
197
+ content: content.text,
198
+ language: detectMarketLanguage(relative),
199
+ size: documentStats.size,
200
+ // Truncation is not an error: the browser labels the partial document.
201
+ truncated: content.truncated,
202
+ };
25
203
  }
26
204
  /** Rename the selected entry out of discovery before deletion. Symlink targets are never removed. */
27
205
  export async function removeInstalledSkill(roots, key, allowUninstall) {
@@ -49,7 +49,7 @@ function expandHomePath(value) {
49
49
  * blank `$DSH_HOME` is treated as unset so a stray empty variable can never
50
50
  * resolve the harness home to the current working directory.
51
51
  */
52
- function resolveDshHome(configured) {
52
+ export function resolveDshHome(configured) {
53
53
  if (typeof configured === 'string' && configured.trim() !== '') {
54
54
  return path.resolve(expandHomePath(configured.trim()));
55
55
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Curated catalogue — the marketplace's home list.
3
+ *
4
+ * The upstream registries hold ~80k ClawHub and ~110k SkillHub skills; nobody
5
+ * chooses from that. The home list is instead a few hundred skills reviewed in
6
+ * this repository (`catalog/curation.json`) and snapshotted by
7
+ * `scripts/catalog-refresh.mjs` into `skills.json`, which ships with the plugin.
8
+ *
9
+ * Only the *list* comes from the snapshot. Opening a card, reading a file and
10
+ * installing still go to the owning registry (through the usual cache), so a
11
+ * reader never installs the snapshot's version of anything.
12
+ */
13
+ import { type MarketCategory, type MarketSource, type NormalizedSkill, type SecurityStatus } from '../market/types.ts';
14
+ export type CatalogEntry = {
15
+ source: MarketSource;
16
+ slug: string;
17
+ /** Registry owner; disambiguates ClawHub slugs shared by several authors. */
18
+ owner: string;
19
+ name: string;
20
+ /** Reader-facing summary (zh-CN). */
21
+ summary: string;
22
+ /** Original upstream summary, kept for English readers and search. */
23
+ summaryEn?: string;
24
+ /** Key into `categories`. */
25
+ category: string;
26
+ tags: string[];
27
+ /** Editor's pick: listed first and marked on the card. */
28
+ featured?: boolean;
29
+ stats: {
30
+ downloads: number;
31
+ installs?: number;
32
+ stars?: number;
33
+ };
34
+ version?: string;
35
+ updatedAt?: number;
36
+ iconUrl?: string;
37
+ security: SecurityStatus;
38
+ /** Why the security verdict is what it is, when it is not clean. */
39
+ securityNote?: string;
40
+ requiresApiKey?: boolean;
41
+ license?: string;
42
+ };
43
+ export type CatalogFile = {
44
+ version: 1;
45
+ /** Epoch millis of the upstream reads behind this snapshot. */
46
+ generatedAt: number;
47
+ categories: Array<{
48
+ key: string;
49
+ name: string;
50
+ nameEn: string;
51
+ }>;
52
+ skills: CatalogEntry[];
53
+ };
54
+ export declare function getCatalog(): CatalogFile;
55
+ export declare function catalogEntryFor(source: MarketSource, slug: string): CatalogEntry | undefined;
56
+ /** Category bar entries, in editorial order, with how many skills each holds. */
57
+ export declare function catalogCategories(): Array<MarketCategory & {
58
+ count: number;
59
+ }>;
60
+ /** ClawHub owners the catalogue pins, for the provider's slug disambiguation. */
61
+ export declare function catalogClawhubOwners(): Array<readonly [string, string]>;
62
+ export declare function catalogEntryToSkill(entry: CatalogEntry): NormalizedSkill;
63
+ /**
64
+ * Entries matching every whitespace-separated term, in catalogue order.
65
+ *
66
+ * A name/slug hit ranks above a summary or tag hit: typing a skill's name
67
+ * should put that skill first, not the one that mentions it most.
68
+ */
69
+ export declare function filterCatalog(params: {
70
+ q?: string;
71
+ category?: string;
72
+ source?: 'all' | MarketSource;
73
+ }): CatalogEntry[];
@@ -9,7 +9,7 @@
9
9
  * never a value from the Host's `src/market/*`, which would drag Node code into
10
10
  * the browser bundle (contract §2).
11
11
  */
12
- import type { MarketFileContent, MarketListResult, MarketSource, NormalizedSkill, NormalizedSkillDetail, SecurityStatus, SourceStatusInfo } from '../market/types.ts';
12
+ import type { MarketCategoriesResult, MarketFileContent, MarketListResult, MarketSource, NormalizedSkill, NormalizedSkillDetail, SecurityStatus, SourceStatusInfo } from '../market/types.ts';
13
13
  /**
14
14
  * Structural mirror of the Host's `InstalledSkillRecord`
15
15
  * (`src/skills/installed.ts`, contract §5.2).
@@ -29,6 +29,8 @@ export interface InstalledSkillRecord {
29
29
  id: string;
30
30
  source: MarketSource | 'local';
31
31
  slug: string;
32
+ /** Registry author recorded at install time, when known. */
33
+ owner?: string;
32
34
  name: string;
33
35
  dirName: string;
34
36
  dirPath: string;
@@ -43,6 +45,10 @@ export interface InstalledSkillRecord {
43
45
  /** List query accepted by `GET /api/skills-hub/skills`. */
44
46
  export interface MarketQuery {
45
47
  q?: string;
48
+ /** `catalog` (default): the curated list. `market`: live upstream search. */
49
+ scope?: 'catalog' | 'market';
50
+ /** Catalogue category key; `'all'` (or absent) means no category filter. */
51
+ category?: string;
46
52
  source: 'all' | MarketSource;
47
53
  security: 'all' | SecurityStatus;
48
54
  installed: 'all' | 'installed' | 'installable';
@@ -68,14 +74,21 @@ export declare class SkillsHubApiError extends Error {
68
74
  */
69
75
  export declare function isAbortError(error: unknown): boolean;
70
76
  /** Search the catalogue (market sources) — the remote list page. */
71
- export declare function fetchMarketList(query: MarketQuery, signal?: AbortSignal): Promise<MarketListResult>;
77
+ export declare function fetchMarketList(query: MarketQuery, signal?: AbortSignal, options?: {
78
+ refresh?: boolean;
79
+ }): Promise<MarketListResult>;
80
+ /** The category bar's entries (curated catalogue), with the snapshot's provenance. */
81
+ export declare function fetchMarketCategories(signal?: AbortSignal): Promise<MarketCategoriesResult>;
72
82
  /** One skill's full detail plus the health of the source that served it. */
73
- export declare function fetchSkillDetail(id: string, signal?: AbortSignal): Promise<{
83
+ export declare function fetchSkillDetail(id: string, signal?: AbortSignal, options?: {
84
+ refresh?: boolean;
85
+ owner?: string;
86
+ }): Promise<{
74
87
  skill: NormalizedSkillDetail;
75
88
  sourceStatus: SourceStatusInfo;
76
89
  }>;
77
90
  /** One file of a skill, for the Files tab. */
78
- export declare function fetchSkillFile(id: string, path: string, signal?: AbortSignal): Promise<MarketFileContent>;
91
+ export declare function fetchSkillFile(id: string, path: string, signal?: AbortSignal, owner?: string): Promise<MarketFileContent>;
79
92
  /** Health of every market source, refreshed independently of the list. */
80
93
  export declare function fetchSourceStatus(signal?: AbortSignal): Promise<Record<MarketSource, SourceStatusInfo>>;
81
94
  /**
@@ -85,7 +98,7 @@ export declare function fetchSourceStatus(signal?: AbortSignal): Promise<Record<
85
98
  */
86
99
  export declare function fetchInstalled(signal?: AbortSignal): Promise<InstalledSkillRecord[]>;
87
100
  /** Install a market skill into the local skills directory. */
88
- export declare function installSkill(id: string): Promise<{
101
+ export declare function installSkill(id: string, owner?: string): Promise<{
89
102
  installedPath: string;
90
103
  skill: NormalizedSkill;
91
104
  }>;
@@ -94,11 +107,35 @@ export declare function uninstallSkill(id: string): Promise<{
94
107
  removedPath: string;
95
108
  skill: NormalizedSkill;
96
109
  }>;
97
- /** Preview the exact local copy without consulting a market provider. */
98
- export declare function fetchInstalledDetail(key: string, signal?: AbortSignal): Promise<{
110
+ /**
111
+ * One file of an installed skill, as the file tab lists it.
112
+ *
113
+ * `language` is the Host's mapping from the file extension and `size` is the
114
+ * real size on disk, so a truncated preview still reports the document it came
115
+ * from rather than the bytes that fit through the endpoint.
116
+ */
117
+ export interface InstalledFileEntry {
118
+ path: string;
119
+ size: number;
120
+ language: string;
121
+ }
122
+ /**
123
+ * The local skill page's payload.
124
+ *
125
+ * `markdown` is the whole SKILL.md (frontmatter included) and `frontmatter` is
126
+ * that same header as YAML text: the market detail's structured metadata panel
127
+ * is fed by upstream JSON, and a hand-written skill has no such record.
128
+ */
129
+ export interface InstalledSkillDetail {
99
130
  item: InstalledSkillRecord;
100
131
  markdown: string;
101
- }>;
132
+ frontmatter: string | null;
133
+ files: InstalledFileEntry[];
134
+ }
135
+ /** Preview the exact local copy without consulting a market provider. */
136
+ export declare function fetchInstalledDetail(key: string, signal?: AbortSignal): Promise<InstalledSkillDetail>;
137
+ /** One file of an installed skill; the Host validates the key and contains the path. */
138
+ export declare function fetchInstalledFile(key: string, path: string, signal?: AbortSignal): Promise<MarketFileContent>;
102
139
  /** Remove a specifically selected local entry after the management UI confirmation. */
103
140
  export declare function removeInstalled(key: string): Promise<{
104
141
  item: InstalledSkillRecord;
@@ -0,0 +1,13 @@
1
+ import type { MarketCategory } from '../../market/types.ts';
2
+ /**
3
+ * Category chips above the curated catalogue.
4
+ *
5
+ * The categories are the catalogue's own, so every chip has skills behind it
6
+ * and its count is exact. Rendered only once they loaded.
7
+ */
8
+ export declare function CategoryBar(props: {
9
+ categories: MarketCategory[];
10
+ active: string;
11
+ disabled: boolean;
12
+ onSelect: (category: string) => void;
13
+ }): JSX.Element | null;
@@ -8,6 +8,10 @@ import type { MarketFilters } from '../state.ts';
8
8
  * platform keyboard behaviour, a real label association, and renders inside a
9
9
  * 720px panel without a portal.
10
10
  *
11
+ * The search field is a draft: typing only edits local state, and the query is
12
+ * submitted on Enter (or when the field is emptied/cleared). Enter during IME
13
+ * composition confirms the candidate and must not submit.
14
+ *
11
15
  * The result count is announced through a visually hidden live region instead
12
16
  * of being printed twice: the visible count lives in the home header, and a
13
17
  * screen reader still hears it change when a filter narrows the list.
@@ -1,25 +1,34 @@
1
- import type { MarketSource, SecurityStatus } from '../../market/types.ts';
1
+ import type { MarketSource, NormalizedSkillDetail, SecurityStatus } from '../../market/types.ts';
2
2
  /** The subset of a skill the confirmation needs; the caller owns the source object. */
3
3
  export interface InstallConfirmSkill {
4
4
  id: string;
5
5
  name: string;
6
6
  source: MarketSource;
7
7
  version?: string;
8
+ iconUrl?: string;
8
9
  securityStatus: SecurityStatus;
9
10
  authorName: string;
11
+ /**
12
+ * The loaded detail, when the dialog was opened from the detail page: only
13
+ * then are the skill's files known, and with them what it will be able to do.
14
+ */
15
+ detail?: NormalizedSkillDetail;
16
+ /**
17
+ * When the catalogue data behind this dialog was read from upstream, if it
18
+ * came from a snapshot. Installing is a trust decision, so the confirmation
19
+ * cannot present a cached security verdict as a fresh one.
20
+ */
21
+ snapshotAt?: number;
10
22
  }
11
23
  /**
12
- * Install confirmation.
24
+ * Install confirmation (design: install dialog of the detail page).
13
25
  *
14
- * Mounted only while a confirmation is pending — the frozen props carry no
15
- * `skill | null` and no `open`, so the caller's conditional render *is* the
16
- * open state (the optional `open` prop exists only for a caller that prefers
17
- * to keep the element mounted).
18
- *
19
- * Escape and mask-click closing come from `Modal` itself; while `busy` both
20
- * are neutralised and the buttons are disabled, because an install already in
21
- * flight cannot be recalled and a dialog that vanishes mid-request reads as a
22
- * cancel that never happened.
26
+ * Mounted only while a confirmation is pending. A skill that is not scanned
27
+ * clean (flagged or unknown) needs an explicit acknowledgement before the
28
+ * confirm button arms: installing is a trust decision about a third party, and
29
+ * the dialog says so instead of making it a reflex click. Escape and mask-click
30
+ * come from `Modal`; while `busy` both are neutralised, because an install in
31
+ * flight cannot be recalled.
23
32
  */
24
33
  export declare function InstallConfirmDialog(props: {
25
34
  skill: InstallConfirmSkill;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Installed skill detail page.
3
+ *
4
+ * The list's "view" affordance used to open a 380px confirmation-style modal,
5
+ * which is the wrong container for a document: SKILL.md is a page of prose with
6
+ * tables and code, and the marketplace already answers the same question with a
7
+ * full-panel page. This page is that same reading surface (`SkillDetailShell`),
8
+ * fed by the filesystem instead of a provider.
9
+ *
10
+ * What it deliberately does *not* do is pretend a local skill is a market
11
+ * entry. There is no author, download count or security report to show, so
12
+ * those rows are absent rather than zero. Where the two identities do meet —
13
+ * a skill installed from a market, proven by its `.skills-hub.json` sidecar —
14
+ * the page links to the market detail instead of duplicating it, which keeps
15
+ * upstream data upstream and this page readable with the network down.
16
+ */
17
+ import { type InstalledSkillRecord } from '../api.ts';
18
+ export declare function InstalledSkillDetail(props: {
19
+ item: InstalledSkillRecord;
20
+ onBack: () => void;
21
+ onUninstall: (item: InstalledSkillRecord) => void;
22
+ /** Open the same skill's market page; only offered for market provenance. */
23
+ onOpenMarket: (id: string, owner?: string) => void;
24
+ }): JSX.Element;
@@ -1,3 +1,20 @@
1
- export declare function InstalledSkills({ onChanged }: {
1
+ /**
2
+ * Installed skills: the local inventory and its reading page.
3
+ *
4
+ * Two views, one component. The detail page replaces the list *inside* this
5
+ * component rather than in `MarketPage`, so the list is never unmounted: the
6
+ * search query, the loaded rows and the scroll position are all still there when
7
+ * the reader comes back. That is also why the selected row is stored as a key
8
+ * and resolved against the current rows — a refresh that drops the entry closes
9
+ * the page instead of leaving it pointing at something that no longer exists.
10
+ *
11
+ * Removal stays a confirmation modal on purpose: it is a short yes/no about a
12
+ * destructive action, which is exactly what the primitive's dialog is for. The
13
+ * skill's *content* is not, which is why it moved to a page.
14
+ */
15
+ export declare function InstalledSkills(props: {
16
+ /** A removal changed the local inventory; the market half refreshes too. */
2
17
  onChanged: () => void;
18
+ /** Open the market page of a skill that came from a market. */
19
+ onOpenMarket: (id: string, owner?: string) => void;
3
20
  }): JSX.Element;
@@ -1,4 +1,10 @@
1
- /** Self-contained GFM rendering for untrusted marketplace and local skill docs. */
1
+ /**
2
+ * Self-contained GFM rendering for untrusted marketplace and local skill docs.
3
+ *
4
+ * Raw HTML is skipped and external links are opened with `noopener noreferrer`;
5
+ * the typography lives in the detail shell so a rendered document looks the same
6
+ * in the overview tab and in the file viewer.
7
+ */
2
8
  export declare function MarkdownView({ content }: {
3
9
  content: string;
4
10
  }): JSX.Element;
@@ -0,0 +1,34 @@
1
+ import type { NormalizedSkillDetail } from '../../market/types.ts';
2
+ import type { MarketController, MarketState } from '../state.ts';
3
+ import { type Translate } from '../locale-context.tsx';
4
+ import { type Capability } from '../skill-insights.ts';
5
+ /** Compact counts: 482069 → 482.1k. */
6
+ export declare function formatCount(value: number): string;
7
+ /** Upstream timestamps are epoch millis; render as an ISO date (stable across locales). */
8
+ export declare function formatDate(timestamp: number | undefined): string;
9
+ /** Only absolute http(s) links from upstream may become an `href`. */
10
+ export declare function safeUrl(url: string | undefined): string | undefined;
11
+ /** One capability, phrased with its evidence. */
12
+ export declare function capabilityText(t: Translate, capability: Capability): {
13
+ title: string;
14
+ detail: string;
15
+ };
16
+ /** Capabilities and triggers, derived once per detail. */
17
+ export declare function useSkillInsights(detail: NormalizedSkillDetail): {
18
+ capabilities: Capability[];
19
+ triggers: string[];
20
+ };
21
+ /**
22
+ * Market skill detail page.
23
+ *
24
+ * Owns no data: tab, selected file and file contents live in `MarketState`.
25
+ * Upstream facts (stats, version, scans, files) come from the live detail; the
26
+ * catalogue adds category and the Chinese summary. "What this skill does" and
27
+ * "when it triggers" are read off its own SKILL.md and file list by explicit
28
+ * rules (`skill-insights.ts`) and are hidden when no rule fires.
29
+ */
30
+ export declare function MarketSkillDetail(props: {
31
+ detail: NormalizedSkillDetail;
32
+ state: MarketState;
33
+ controller: MarketController;
34
+ }): JSX.Element;
@@ -8,7 +8,7 @@ import type { MarketSource } from '../../market/types.ts';
8
8
  */
9
9
  export declare function SkillAvatar(props: {
10
10
  name: string;
11
- source: MarketSource;
11
+ source: MarketSource | 'local';
12
12
  iconUrl?: string;
13
13
  size: number;
14
14
  }): JSX.Element;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The detail surface shared by the market catalogue and the installed inventory
3
+ * (design: "技能市场重构 – 详情页").
4
+ *
5
+ * Both pages answer the same question — "what is inside this skill?" — and the
6
+ * only real difference is where the facts come from: an upstream provider or the
7
+ * filesystem. The shell therefore owns everything that is *layout*: breadcrumb,
8
+ * identity header, stats strip, underlined tabs, the grey canvas, and the
9
+ * document/side-rail columns. Callers compose the header pieces and the active
10
+ * tab's content, and keep their own tab state (the market page in
11
+ * `MarketState`, the local page in component state).
12
+ */
13
+ import { type ReactNode } from 'react';
14
+ import type { SecurityStatus } from '../../market/types.ts';
15
+ /** The shell's stylesheet, for callers composing shell-shaped fragments. */
16
+ export declare const detailStyles: Record<string, string>;
17
+ export interface DetailTab<K extends string> {
18
+ key: K;
19
+ label: string;
20
+ /** Small trailing marker: a count, or a warning dot. */
21
+ badge?: ReactNode;
22
+ }
23
+ export declare function SkillDetailShell<K extends string>(props: {
24
+ /** Identity of the rendered subject; changing it moves focus to the heading. */
25
+ focusKey: string;
26
+ onBack: () => void;
27
+ /** Breadcrumb root: the list this page was opened from. */
28
+ backLabel: string;
29
+ avatar: ReactNode;
30
+ name: string;
31
+ version?: string;
32
+ /** One line of "by author · source · licence · updated" facts. */
33
+ meta?: ReactNode;
34
+ summary?: string;
35
+ chips?: ReactNode;
36
+ actions?: ReactNode;
37
+ stats?: Array<{
38
+ label: string;
39
+ value: string;
40
+ }>;
41
+ tabs: Array<DetailTab<K>>;
42
+ activeTab: K;
43
+ onTabChange: (tab: K) => void;
44
+ /** Notes above the tab content (snapshot age, location, warnings). */
45
+ notice?: ReactNode;
46
+ children: ReactNode;
47
+ }): JSX.Element;
48
+ /** Document column plus side rail; the rail moves below on a narrow panel. */
49
+ export declare function DetailColumns(props: {
50
+ main: ReactNode;
51
+ side: ReactNode;
52
+ }): JSX.Element;
53
+ export declare function SideCard(props: {
54
+ title: string;
55
+ extra?: ReactNode;
56
+ children: ReactNode;
57
+ }): JSX.Element;
58
+ export declare function InfoRow(props: {
59
+ label: string;
60
+ value: ReactNode;
61
+ mono?: boolean;
62
+ }): JSX.Element;
63
+ /** Count in a tab label ("文件 15"). */
64
+ export declare function TabCount(props: {
65
+ value: number;
66
+ }): JSX.Element;
67
+ type ChipTone = 'plain' | 'ok' | 'warn' | 'blue';
68
+ export declare function Chip(props: {
69
+ tone?: ChipTone;
70
+ title?: string;
71
+ children: ReactNode;
72
+ }): JSX.Element;
73
+ /** Upstream scan verdict, shared by the cards, the detail header and the install dialog. */
74
+ export declare function SecurityChip(props: {
75
+ status: SecurityStatus;
76
+ short?: boolean;
77
+ }): JSX.Element;
78
+ /**
79
+ * Keeps the detail layout stable while its request is in flight: the same
80
+ * boxes as the real page rather than a spinner, so the page changes shape once.
81
+ */
82
+ export declare function SkillDetailSkeleton({ onBack, backLabel }: {
83
+ onBack: () => void;
84
+ backLabel?: string;
85
+ }): JSX.Element;
86
+ export {};