@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
@@ -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
  }
@@ -68,9 +68,13 @@ export declare class SkillsHubApiError extends Error {
68
68
  */
69
69
  export declare function isAbortError(error: unknown): boolean;
70
70
  /** Search the catalogue (market sources) — the remote list page. */
71
- export declare function fetchMarketList(query: MarketQuery, signal?: AbortSignal): Promise<MarketListResult>;
71
+ export declare function fetchMarketList(query: MarketQuery, signal?: AbortSignal, options?: {
72
+ refresh?: boolean;
73
+ }): Promise<MarketListResult>;
72
74
  /** One skill's full detail plus the health of the source that served it. */
73
- export declare function fetchSkillDetail(id: string, signal?: AbortSignal): Promise<{
75
+ export declare function fetchSkillDetail(id: string, signal?: AbortSignal, options?: {
76
+ refresh?: boolean;
77
+ }): Promise<{
74
78
  skill: NormalizedSkillDetail;
75
79
  sourceStatus: SourceStatusInfo;
76
80
  }>;
@@ -94,11 +98,35 @@ export declare function uninstallSkill(id: string): Promise<{
94
98
  removedPath: string;
95
99
  skill: NormalizedSkill;
96
100
  }>;
97
- /** Preview the exact local copy without consulting a market provider. */
98
- export declare function fetchInstalledDetail(key: string, signal?: AbortSignal): Promise<{
101
+ /**
102
+ * One file of an installed skill, as the file tab lists it.
103
+ *
104
+ * `language` is the Host's mapping from the file extension and `size` is the
105
+ * real size on disk, so a truncated preview still reports the document it came
106
+ * from rather than the bytes that fit through the endpoint.
107
+ */
108
+ export interface InstalledFileEntry {
109
+ path: string;
110
+ size: number;
111
+ language: string;
112
+ }
113
+ /**
114
+ * The local skill page's payload.
115
+ *
116
+ * `markdown` is the whole SKILL.md (frontmatter included) and `frontmatter` is
117
+ * that same header as YAML text: the market detail's structured metadata panel
118
+ * is fed by upstream JSON, and a hand-written skill has no such record.
119
+ */
120
+ export interface InstalledSkillDetail {
99
121
  item: InstalledSkillRecord;
100
122
  markdown: string;
101
- }>;
123
+ frontmatter: string | null;
124
+ files: InstalledFileEntry[];
125
+ }
126
+ /** Preview the exact local copy without consulting a market provider. */
127
+ export declare function fetchInstalledDetail(key: string, signal?: AbortSignal): Promise<InstalledSkillDetail>;
128
+ /** One file of an installed skill; the Host validates the key and contains the path. */
129
+ export declare function fetchInstalledFile(key: string, path: string, signal?: AbortSignal): Promise<MarketFileContent>;
102
130
  /** Remove a specifically selected local entry after the management UI confirmation. */
103
131
  export declare function removeInstalled(key: string): Promise<{
104
132
  item: InstalledSkillRecord;
@@ -7,6 +7,12 @@ export interface InstallConfirmSkill {
7
7
  version?: string;
8
8
  securityStatus: SecurityStatus;
9
9
  authorName: string;
10
+ /**
11
+ * When the catalogue data behind this dialog was read from upstream, if it
12
+ * came from a snapshot. Installing is a trust decision, so the confirmation
13
+ * cannot present a cached security verdict as a fresh one.
14
+ */
15
+ snapshotAt?: number;
10
16
  }
11
17
  /**
12
18
  * Install confirmation.
@@ -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) => 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) => 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;
@@ -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,63 @@
1
+ /**
2
+ * The detail surface shared by the market catalogue and the installed inventory.
3
+ *
4
+ * Both pages answer the same question — "what is inside this skill?" — and the
5
+ * only real difference is where the facts come from: an upstream provider or the
6
+ * filesystem. Everything that is *reading* rather than *fetching* therefore
7
+ * lives here: the back affordance, the identity header, the two tabs, the
8
+ * document column, the facts rail and the focus move that keeps a keyboard user
9
+ * from being left at the top of the document when the list is replaced.
10
+ *
11
+ * The two pages are not identical, so the shell takes slots rather than a data
12
+ * model: `badges`, `notice`, `overview`, `files` and `rail` are composed by the
13
+ * caller, while each caller owns its own loading state and its own choice of tab
14
+ * storage (the market page keeps it in `MarketState`, the local page in its own
15
+ * component state).
16
+ */
17
+ import { type ReactNode } from 'react';
18
+ /** Tab values every detail page offers, in render order. */
19
+ export type SkillDetailTab = 'overview' | 'files';
20
+ /**
21
+ * The shell's stylesheet, re-exported for its callers.
22
+ *
23
+ * The pages built on this shell also render shell-shaped fragments (a report
24
+ * row, a path line, a facts list) and reaching them through an import is more
25
+ * honest than re-declaring near-copies of the same rules in a second module.
26
+ */
27
+ export declare const detailStyles: Record<string, string>;
28
+ /** One label/value row of the facts rail. */
29
+ export declare function Fact(props: {
30
+ label: string;
31
+ children: ReactNode;
32
+ }): JSX.Element;
33
+ export declare function SkillDetailShell(props: {
34
+ /** Identity of the rendered subject; changing it moves focus back to the heading. */
35
+ focusKey: string;
36
+ onBack: () => void;
37
+ backLabel: string;
38
+ avatar: ReactNode;
39
+ /** Small line above the name: source, version, provenance. */
40
+ eyebrow: ReactNode;
41
+ name: string;
42
+ badges?: ReactNode;
43
+ summary?: string;
44
+ /** Full-width band under the header (warnings, security reports). */
45
+ notice?: ReactNode;
46
+ overviewLabel: string;
47
+ filesLabel: string;
48
+ activeTab: SkillDetailTab;
49
+ onTabChange: (tab: SkillDetailTab) => void;
50
+ overview: ReactNode;
51
+ files: ReactNode;
52
+ rail: ReactNode;
53
+ }): JSX.Element;
54
+ /**
55
+ * Keeps the detail layout stable while its request is in flight.
56
+ *
57
+ * It renders the same boxes as the real page rather than a spinner, because the
58
+ * click that opens a detail is the moment the panel changes shape; a centered
59
+ * "loading" line would make that change happen twice.
60
+ */
61
+ export declare function SkillDetailSkeleton({ onBack }: {
62
+ onBack: () => void;
63
+ }): JSX.Element;
@@ -1,11 +1,13 @@
1
1
  import type { NormalizedSkillDetail } from '../../market/types.ts';
2
2
  import type { MarketController, MarketState } from '../state.ts';
3
3
  /**
4
- * Skill detail page.
4
+ * Market skill detail page.
5
5
  *
6
6
  * Like the home page this component owns no data: the tab, the selected file
7
7
  * and the file contents all live in `MarketState`, so switching tabs or
8
- * re-opening a skill does not lose its place.
8
+ * re-opening a skill does not lose its place. The reading surface itself — back
9
+ * affordance, header, tabs, facts rail — is `SkillDetailShell`, which the local
10
+ * installed page uses too.
9
11
  *
10
12
  * The bundled GFM renderer handles catalogue Markdown without relying on a
11
13
  * host renderer delegate. Raw HTML is disabled and URLs use its safe default.
@@ -15,7 +17,3 @@ export declare function SkillDetailView(props: {
15
17
  state: MarketState;
16
18
  controller: MarketController;
17
19
  }): JSX.Element;
18
- /** Keeps the detail layout stable while its network request is in flight. */
19
- export declare function SkillDetailSkeleton({ onBack }: {
20
- onBack: () => void;
21
- }): JSX.Element;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Files tab body, shared by the two detail pages.
3
+ *
4
+ * The market page lists provider file metadata and the local page lists the
5
+ * bytes on disk, but both are the same control: a picker on the left, one
6
+ * document on the right, and a copy button that confirms itself. The only
7
+ * difference the caller keeps is how a selection is fetched.
8
+ *
9
+ * A markdown document opens *rendered*: SKILL.md is prose meant to be read, and
10
+ * showing its source first answers a question nobody asked. Installing still has
11
+ * to be a trust decision, though, so the raw bytes are one toggle away — with
12
+ * line numbers and the copy button pointed at exactly what is on disk. Other
13
+ * files have no rendered form and stay source-only.
14
+ */
15
+ import type { MarketFileContent } from '../../market/types.ts';
16
+ /** The subset of a file row this control renders; both pages' metadata fits it. */
17
+ export interface SkillFileListItem {
18
+ path: string;
19
+ size: number;
20
+ language: string;
21
+ /** Provider flagged the file as beyond the preview/install limit. */
22
+ tooBig?: boolean;
23
+ }
24
+ export declare function SkillFiles(props: {
25
+ files: SkillFileListItem[];
26
+ /** Already-fetched document for the selected path, or `null` while none is loaded. */
27
+ selected: MarketFileContent | null;
28
+ loading: boolean;
29
+ /** Reader-facing failure of the last selection, if any. */
30
+ error?: string | null;
31
+ onSelect: (path: string) => void;
32
+ }): JSX.Element;
@@ -5,6 +5,10 @@ import type { MarketSource, SourceStatusInfo } from '../../market/types.ts';
5
5
  * The upstream error string rides the pill's `title` instead of being printed:
6
6
  * a 720px panel has no room for a stack trace next to two source names, and an
7
7
  * operator who needs it can hover.
8
+ *
9
+ * The snapshot age *is* printed: a healthy source can still be answering from a
10
+ * cache, and a reader deciding whether to install third-party code is entitled
11
+ * to see that the page in front of them was read minutes ago rather than now.
8
12
  */
9
13
  export declare function SourceStatusBar(props: {
10
14
  sources: Record<MarketSource, SourceStatusInfo>;
@@ -69,3 +69,7 @@ export declare function ChevronRightIcon({ size, className }: SkillsHubIconProps
69
69
  export declare function CloseIcon({ size, className }: SkillsHubIconProps): JSX.Element;
70
70
  /** Price tag: leading glyph of a card's tag row. */
71
71
  export declare function TagIcon({ size, className }: SkillsHubIconProps): JSX.Element;
72
+ /** Folder: leading glyph of the installed skill's location line. */
73
+ export declare function FolderIcon({ size, className }: SkillsHubIconProps): JSX.Element;
74
+ /** Arrow leaving a frame: open the same skill in the marketplace. */
75
+ export declare function ExternalLinkIcon({ size, className }: SkillsHubIconProps): JSX.Element;
@@ -29,6 +29,34 @@ declare const zhDictionary: {
29
29
  localUninstallDescription: string;
30
30
  unlinkDescription: string;
31
31
  uninstalling: string;
32
+ uninstallDisabled: string;
33
+ copyPath: string;
34
+ copiedPath: string;
35
+ sourceLabel: string;
36
+ managedByHub: string;
37
+ unmanagedSkill: string;
38
+ symlinkSkill: string;
39
+ installedAtLabel: string;
40
+ skillKind: string;
41
+ bundleSkill: string;
42
+ flatSkill: string;
43
+ filesCount: string;
44
+ emptyDocument: string;
45
+ installedReadFailed: string;
46
+ openInMarket: string;
47
+ fileView: string;
48
+ filePreview: string;
49
+ fileSource: string;
50
+ snapshotAge: string;
51
+ ageJustNow: string;
52
+ ageMinutes: string;
53
+ ageHours: string;
54
+ ageDays: string;
55
+ refreshNow: string;
56
+ refreshSnapshot: string;
57
+ detailSnapshot: string;
58
+ installSnapshot: string;
59
+ marketProvenance: string;
32
60
  panel: string;
33
61
  title: string;
34
62
  subtitle: string;
@@ -37,6 +65,7 @@ declare const zhDictionary: {
37
65
  'source.all': string;
38
66
  'source.clawhub': string;
39
67
  'source.skillhub': string;
68
+ 'source.local': string;
40
69
  'security.all': string;
41
70
  'security.verified': string;
42
71
  'security.benign': string;
@@ -96,6 +125,7 @@ declare const zhDictionary: {
96
125
  uninstallDone: string;
97
126
  noFiles: string;
98
127
  fileTooLarge: string;
128
+ fileTruncated: string;
99
129
  copy: string;
100
130
  copied: string;
101
131
  unoaudited: string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How old a snapshot is, in the panel's own vocabulary.
3
+ *
4
+ * The panel shows cached catalogue data, so "when was this actually fetched" is
5
+ * part of the answer rather than a debug detail: a reader who is about to
6
+ * install third-party code is entitled to know whether the security report in
7
+ * front of them was read a minute ago or yesterday.
8
+ */
9
+ import type { Translate } from './locale-context.tsx';
10
+ /** Coarse buckets: the first match wins, and precision beyond them is noise. */
11
+ export declare function formatAge(t: Translate, from: number, now?: number): string;
12
+ /** Absolute timestamp for the hover text, so the bucket never hides the fact. */
13
+ export declare function formatStamp(from: number): string;
@@ -1,2 +1,41 @@
1
+ /**
2
+ * Frontmatter handling for skill documents.
3
+ *
4
+ * A `SKILL.md` opens with a YAML block, and feeding that block to a markdown
5
+ * renderer is not merely ugly — it is wrong: a lone `---` is a setext underline,
6
+ * so the closing fence promotes the whole metadata block into one giant heading.
7
+ * The block is split off here instead, so the UI can render it as structured
8
+ * metadata (`FrontmatterPanel`) while the rest of the document goes to the
9
+ * renderer. The approach mirrors the desktop project's `skillFrontmatter.ts`.
10
+ *
11
+ * The parser is a deliberately small YAML subset — scalars, inline and block
12
+ * sequences, `|`/`>` block scalars, and one level of nested mapping that is kept
13
+ * as the raw YAML it is — because skill frontmatter is flat by convention and a
14
+ * full YAML engine is a dependency this browser bundle does not need. It never
15
+ * throws: a document without a fence, or with an unterminated one, comes back as
16
+ * body only.
17
+ */
18
+ /** One document separated into its metadata and everything else. */
19
+ export interface SkillFrontmatterSplit {
20
+ /** Parsed metadata, or `null` when the document carries none worth showing. */
21
+ frontmatter: Record<string, unknown> | null;
22
+ /** The document with its metadata block removed. */
23
+ body: string;
24
+ }
25
+ /**
26
+ * Parse a metadata block that has already been extracted — the Host bounds its
27
+ * size before it reaches the browser, so this entry point works on that text.
28
+ *
29
+ * @param yaml - the block's text, without its fences.
30
+ * @returns the parsed keys, or `null` when the block carries nothing to show.
31
+ */
32
+ export declare function parseSkillFrontmatter(yaml: string): Record<string, unknown> | null;
33
+ /**
34
+ * Separate a document's frontmatter from its body.
35
+ *
36
+ * @param markdown - the whole document.
37
+ * @returns the parsed metadata (or `null`) plus the body to render.
38
+ */
39
+ export declare function splitSkillFrontmatter(markdown: string): SkillFrontmatterSplit;
1
40
  /** Frontmatter is metadata, not part of the skill's rendered instructions. */
2
41
  export declare function stripSkillFrontmatter(markdown: string): string;
@@ -13,8 +13,8 @@
13
13
  * already holds.
14
14
  * - **in-flight guards**: one install/uninstall per id, one page per cursor, one
15
15
  * detail/file request per target.
16
- * - **detail/file caching**: reopening a skill is instant and cannot blank the
17
- * pane with a spinner.
16
+ * - **detail snapshots**: reopening can show the previous copy immediately,
17
+ * while the Host rechecks its TTL and current install annotations.
18
18
  * - **local management**: the InstalledSkills component independently lists what is already
19
19
  * on disk (`fetchInstalled`) instead of asking the market, matching the
20
20
  * contract's §5.3 behaviour.
@@ -49,6 +49,8 @@ export interface MarketState {
49
49
  items: NormalizedSkill[];
50
50
  nextCursor: string | null;
51
51
  sources: Record<MarketSource, SourceStatusInfo>;
52
+ /** Each card retains the provenance of its own page when more pages append. */
53
+ itemStatuses: Record<string, SourceStatusInfo>;
52
54
  loading: boolean;
53
55
  loadingMore: boolean;
54
56
  error: string | null;
@@ -59,6 +61,8 @@ export interface MarketState {
59
61
  id: string;
60
62
  };
61
63
  detail: NormalizedSkillDetail | null;
64
+ /** Provenance of `detail`: when it was fetched upstream, and whether it is a snapshot. */
65
+ detailStatus: SourceStatusInfo | null;
62
66
  detailLoading: boolean;
63
67
  detailError: string | null;
64
68
  activeTab: 'overview' | 'files';
@@ -73,13 +77,18 @@ export interface MarketState {
73
77
  export interface MarketController {
74
78
  state: MarketState;
75
79
  readonly injected: MarketPageInjected;
76
- refresh(): Promise<void>;
80
+ /** `force` is the reader's refresh: it must not be answered from the cache. */
81
+ refresh(options?: {
82
+ force?: boolean;
83
+ }): Promise<void>;
77
84
  loadMore(): Promise<void>;
78
85
  setQuery(q: string): void;
79
86
  setSource(source: MarketFilters['source']): void;
80
87
  setSecurity(security: MarketFilters['security']): void;
81
88
  setInstalledFilter(installed: MarketFilters['installed']): void;
82
- openDetail(id: string): Promise<void>;
89
+ openDetail(id: string, options?: {
90
+ refresh?: boolean;
91
+ }): Promise<void>;
83
92
  closeDetail(): void;
84
93
  setTab(tab: 'overview' | 'files'): void;
85
94
  selectFile(path: string): Promise<void>;