claudeup 4.41.0 → 4.42.0
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.
- package/package.json +4 -4
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/markdown-renderer.test.ts +143 -0
- package/src/__tests__/marketplaces.test.ts +4 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/data/marketplaces.ts +22 -0
- package/src/services/local-marketplace.ts +110 -19
- package/src/services/marketplace-catalog.ts +34 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +18 -0
- package/src/services/skills-manager.ts +531 -14
- package/src/ui/adapters/pluginsAdapter.ts +154 -17
- package/src/ui/components/primitives/KeyValueLine.tsx +8 -2
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/pluginRenderers.tsx +415 -9
- package/src/ui/screens/PluginsScreen.tsx +181 -53
- package/src/ui/state/reducer.ts +56 -0
- package/src/ui/state/types.ts +42 -0
- package/src/ui/theme-mode.ts +67 -0
|
@@ -5,12 +5,15 @@ import {
|
|
|
5
5
|
isInstalledInScope,
|
|
6
6
|
} from "../../services/plugin-manager.js";
|
|
7
7
|
import type { ScopeStatus } from "../../services/plugin-manager.js";
|
|
8
|
+
import type { PluginComponentKind } from "../../services/skills-manager.js";
|
|
8
9
|
import { isKnownVersion } from "../../services/version-snapshot.js";
|
|
9
10
|
import type { PluginRelease } from "../../types/index.js";
|
|
10
11
|
import { highlightMatches } from "../../utils/fuzzy-search.js";
|
|
11
12
|
import type {
|
|
12
13
|
PluginBrowserItem,
|
|
13
14
|
PluginCategoryItem,
|
|
15
|
+
PluginComponentItem,
|
|
16
|
+
PluginContentsStatusItem,
|
|
14
17
|
PluginPluginItem,
|
|
15
18
|
} from "../adapters/pluginsAdapter.js";
|
|
16
19
|
import { CategoryHeader } from "../components/CategoryHeader.js";
|
|
@@ -22,7 +25,13 @@ import {
|
|
|
22
25
|
ScopeSquares,
|
|
23
26
|
SelectableRow,
|
|
24
27
|
} from "../components/primitives/index.js";
|
|
28
|
+
import type {
|
|
29
|
+
PluginContentsEntry,
|
|
30
|
+
PluginSkillDetailEntry,
|
|
31
|
+
} from "../state/types.js";
|
|
32
|
+
import { componentBadge as badgeColors } from "../theme-mode.js";
|
|
25
33
|
import { type UiColor, theme } from "../theme.js";
|
|
34
|
+
import { renderMarkdown } from "./markdownRenderer.js";
|
|
26
35
|
|
|
27
36
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
28
37
|
|
|
@@ -190,6 +199,13 @@ function pluginRow(
|
|
|
190
199
|
}
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
// Same glyphs as a marketplace row, in a cell that is always two columns
|
|
203
|
+
// wide. Both halves matter: a different arrow at each level reads as a
|
|
204
|
+
// different kind of control, and drawing the cell only on the selected row
|
|
205
|
+
// shifted every plugin name two columns sideways as the cursor passed over
|
|
206
|
+
// it.
|
|
207
|
+
const arrow = item.isExpandable ? (item.isExpanded ? "▼ " : "▶ ") : " ";
|
|
208
|
+
|
|
193
209
|
if (isSelected) {
|
|
194
210
|
return (
|
|
195
211
|
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
@@ -200,6 +216,7 @@ function pluginRow(
|
|
|
200
216
|
local={hasLocal}
|
|
201
217
|
selected={true}
|
|
202
218
|
/>{" "}
|
|
219
|
+
{arrow}
|
|
203
220
|
{plugin.name}
|
|
204
221
|
{versionStr}{" "}
|
|
205
222
|
</text>
|
|
@@ -221,6 +238,9 @@ function pluginRow(
|
|
|
221
238
|
return (
|
|
222
239
|
<text fg={theme.colors.text}>
|
|
223
240
|
<span fg={theme.colors.danger}> ■■■ </span>
|
|
241
|
+
{/* An orphan is never expandable, but it keeps the arrow cell so its
|
|
242
|
+
name lands in the same column as every other plugin's. */}
|
|
243
|
+
<span>{" "}</span>
|
|
224
244
|
<span fg={theme.colors.muted}>{displayName}</span>
|
|
225
245
|
{ver ? <span fg={theme.colors.warning}>{ver}</span> : null}
|
|
226
246
|
<span fg={theme.colors.danger}> deprecated</span>
|
|
@@ -233,6 +253,7 @@ function pluginRow(
|
|
|
233
253
|
<span> </span>
|
|
234
254
|
<ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
|
|
235
255
|
<span> </span>
|
|
256
|
+
<span fg={theme.colors.dim}>{arrow}</span>
|
|
236
257
|
<span
|
|
237
258
|
fg={
|
|
238
259
|
hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted
|
|
@@ -265,7 +286,10 @@ function pluginRow(
|
|
|
265
286
|
);
|
|
266
287
|
}
|
|
267
288
|
|
|
268
|
-
function pluginDetail(
|
|
289
|
+
function pluginDetail(
|
|
290
|
+
item: PluginPluginItem,
|
|
291
|
+
fetched?: PluginContentsEntry,
|
|
292
|
+
): React.ReactNode {
|
|
269
293
|
const { plugin } = item;
|
|
270
294
|
// "Installed" has to mean the same thing here as in the list row. Reading the
|
|
271
295
|
// `enabledPlugins` flag alone made this panel print "● Installed" for the
|
|
@@ -311,14 +335,31 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
311
335
|
);
|
|
312
336
|
}
|
|
313
337
|
|
|
314
|
-
//
|
|
338
|
+
// Component counts. A listing fetched from the repo tree wins over the
|
|
339
|
+
// manifest scan on every count it can supply: the scan only sees a
|
|
340
|
+
// marketplace that is cloned locally, a `featured` marketplace the user has
|
|
341
|
+
// not added has no clone at all, and the scan misses an MCP server declared
|
|
342
|
+
// in `.mcp.json` rather than an `mcp-servers/` directory — which is how 5 of
|
|
343
|
+
// this marketplace's 7 MCP plugins declare theirs, mnemex included.
|
|
344
|
+
const listed =
|
|
345
|
+
fetched?.status === "success" ? (fetched.components ?? null) : null;
|
|
346
|
+
const countOf = (kind: PluginComponentKind, fallback?: string[]) =>
|
|
347
|
+
listed
|
|
348
|
+
? listed.filter((c) => c.kind === kind).length
|
|
349
|
+
: (fallback?.length ?? 0);
|
|
350
|
+
|
|
315
351
|
const components: string[] = [];
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
352
|
+
const agentCount = countOf("agent", plugin.agents);
|
|
353
|
+
const commandCount = countOf("command", plugin.commands);
|
|
354
|
+
const skillCount = countOf("skill", plugin.skills);
|
|
355
|
+
const mcpCount = countOf("mcp", plugin.mcpServers);
|
|
356
|
+
/** "1 agent", "2 agents" — a count of one is common enough to read wrong. */
|
|
357
|
+
const plural = (n: number, noun: string) =>
|
|
358
|
+
`${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
359
|
+
if (agentCount) components.push(plural(agentCount, "agent"));
|
|
360
|
+
if (commandCount) components.push(plural(commandCount, "command"));
|
|
361
|
+
if (skillCount) components.push(plural(skillCount, "skill"));
|
|
362
|
+
if (mcpCount) components.push(plural(mcpCount, "MCP server"));
|
|
322
363
|
if (plugin.lspServers && Object.keys(plugin.lspServers).length) {
|
|
323
364
|
components.push(`${Object.keys(plugin.lspServers).length} LSP`);
|
|
324
365
|
}
|
|
@@ -434,6 +475,13 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
434
475
|
}
|
|
435
476
|
/>
|
|
436
477
|
) : null}
|
|
478
|
+
{item.isExpandable ? (
|
|
479
|
+
<box>
|
|
480
|
+
<text fg={theme.colors.muted}>
|
|
481
|
+
← → to {item.isExpanded ? "hide" : "list"} what it contains
|
|
482
|
+
</text>
|
|
483
|
+
</box>
|
|
484
|
+
) : null}
|
|
437
485
|
|
|
438
486
|
{/* Scope Status */}
|
|
439
487
|
<DetailSection>
|
|
@@ -599,6 +647,348 @@ function ReleaseHistory({
|
|
|
599
647
|
);
|
|
600
648
|
}
|
|
601
649
|
|
|
650
|
+
// ─── Skill renderers ─────────────────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Left padding for a component row.
|
|
654
|
+
*
|
|
655
|
+
* A plugin name starts at column 7: one leading space, three scope segments,
|
|
656
|
+
* one space, then the two-column arrow cell. A component's badge occupies
|
|
657
|
+
* columns 5-7 and its name starts at 9.
|
|
658
|
+
*
|
|
659
|
+
* Deliberately no tree glyph. An earlier version drew a `└` on every row, which
|
|
660
|
+
* introduced a second visual grammar halfway down the list — scope segments
|
|
661
|
+
* above, box-drawing below — and read as a broken tree, since a real one would
|
|
662
|
+
* branch with `├` on every row but the last. The badge anchors the row instead,
|
|
663
|
+
* and says what kind of thing it is at the same time.
|
|
664
|
+
*/
|
|
665
|
+
const COMPONENT_INDENT = " ".repeat(5);
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Width of the badge cell plus the gap after it, so status rows line their text
|
|
669
|
+
* up with the component names.
|
|
670
|
+
*
|
|
671
|
+
* The gap is outside the chip, not inside it. Widening the chip to four cells
|
|
672
|
+
* would have put the extra space on its coloured fill, which makes the chip
|
|
673
|
+
* bigger rather than separating it from the name.
|
|
674
|
+
*/
|
|
675
|
+
const BADGE_WIDTH = 3;
|
|
676
|
+
const BADGE_GAP = 1;
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Type icons.
|
|
680
|
+
*
|
|
681
|
+
* Commands and agents take the glyph you address them with — `/name`, `@name` —
|
|
682
|
+
* so the badge and the thing you type match.
|
|
683
|
+
*
|
|
684
|
+
* The skill glyph follows the only convention the ecosystem has. There is no
|
|
685
|
+
* agreed terminal symbol for a skill: Anthropic ships none (its Skills write-up
|
|
686
|
+
* uses a folder metaphor in prose only), and the large community directories
|
|
687
|
+
* distinguish skills from plugins by section heading rather than by symbol. The
|
|
688
|
+
* two products that do mark them converged on the same idea — Cursor's docs say
|
|
689
|
+
* a skill badge "defaults to a lightning icon", and the community Skills
|
|
690
|
+
* sidebar for VS Code uses a sparkle.
|
|
691
|
+
*
|
|
692
|
+
* `✱` is the sparkle idea at single width. The canonical glyphs cannot be used:
|
|
693
|
+
* `⚡` (U+26A1) and `✨` (U+2728) are both East-Asian-Width Wide, i.e. two cells,
|
|
694
|
+
* which would shift every name on the row. An *asterisk* rather than a
|
|
695
|
+
* four-pointed star also keeps it clear of `◆` and `✦`, which read as a generic
|
|
696
|
+
* ornament next to `/` and `@`.
|
|
697
|
+
*
|
|
698
|
+
* `⬢` for an MCP server is ours; nobody has a convention there either.
|
|
699
|
+
*
|
|
700
|
+
* Every glyph here is single-width — check with `unicodedata.east_asian_width`
|
|
701
|
+
* before swapping one, and reject anything reporting W or F.
|
|
702
|
+
*/
|
|
703
|
+
export const COMPONENT_ICON: Record<PluginComponentKind, string> = {
|
|
704
|
+
skill: "✱",
|
|
705
|
+
command: "/",
|
|
706
|
+
agent: "@",
|
|
707
|
+
mcp: "⬢",
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const COMPONENT_COLOR: Record<PluginComponentKind, UiColor> = {
|
|
711
|
+
skill: theme.colors.accent,
|
|
712
|
+
command: theme.colors.info,
|
|
713
|
+
agent: theme.colors.link,
|
|
714
|
+
mcp: theme.colors.warning,
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const COMPONENT_NOUN: Record<PluginComponentKind, string> = {
|
|
718
|
+
skill: "Skill",
|
|
719
|
+
command: "Command",
|
|
720
|
+
agent: "Subagent",
|
|
721
|
+
mcp: "MCP server",
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The type badge: one glyph in a padded, washed cell.
|
|
726
|
+
*
|
|
727
|
+
* Three states, and each exists for a reason.
|
|
728
|
+
*
|
|
729
|
+
* On a *selected* row the whole line is painted in the selection colour, so the
|
|
730
|
+
* badge drops its own fill — a chip of a third colour inside an inverted row is
|
|
731
|
+
* the noisiest thing on the screen, and the selection already says where you
|
|
732
|
+
* are.
|
|
733
|
+
*
|
|
734
|
+
* When the terminal never answered the theme query there is no fill either.
|
|
735
|
+
* `componentBadge()` returns null there by design; painting a light wash on an
|
|
736
|
+
* unknown page risks a bright band across every row of a long list.
|
|
737
|
+
*
|
|
738
|
+
* Otherwise: a wash of the kind's hue, quiet enough to sit on hundreds of rows.
|
|
739
|
+
*/
|
|
740
|
+
function componentBadge(
|
|
741
|
+
kind: PluginComponentKind,
|
|
742
|
+
isSelected: boolean,
|
|
743
|
+
): React.ReactNode {
|
|
744
|
+
const icon = COMPONENT_ICON[kind];
|
|
745
|
+
if (isSelected) return ` ${icon} `;
|
|
746
|
+
|
|
747
|
+
const chip = badgeColors(kind);
|
|
748
|
+
if (!chip) {
|
|
749
|
+
return <span fg={COMPONENT_COLOR[kind]}>{` ${icon} `}</span>;
|
|
750
|
+
}
|
|
751
|
+
return <span bg={chip.bg} fg={chip.fg}>{` ${icon} `}</span>;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function componentRow(
|
|
755
|
+
item: PluginComponentItem,
|
|
756
|
+
isSelected: boolean,
|
|
757
|
+
): React.ReactNode {
|
|
758
|
+
const { component } = item;
|
|
759
|
+
const label = component.group
|
|
760
|
+
? `${component.group}/${component.name}`
|
|
761
|
+
: component.name;
|
|
762
|
+
|
|
763
|
+
if (isSelected) {
|
|
764
|
+
return (
|
|
765
|
+
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
766
|
+
{COMPONENT_INDENT}
|
|
767
|
+
{componentBadge(component.kind, true)}
|
|
768
|
+
{`${" ".repeat(BADGE_GAP)}${label} `}
|
|
769
|
+
</text>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
return (
|
|
773
|
+
<text fg={theme.colors.muted}>
|
|
774
|
+
{COMPONENT_INDENT}
|
|
775
|
+
{componentBadge(component.kind, false)}
|
|
776
|
+
{`${" ".repeat(BADGE_GAP)}${label}`}
|
|
777
|
+
</text>
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function contentsStatusRow(
|
|
782
|
+
item: PluginContentsStatusItem,
|
|
783
|
+
isSelected: boolean,
|
|
784
|
+
): React.ReactNode {
|
|
785
|
+
const pad = COMPONENT_INDENT + " ".repeat(BADGE_WIDTH + BADGE_GAP);
|
|
786
|
+
if (isSelected) {
|
|
787
|
+
return (
|
|
788
|
+
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
789
|
+
{`${pad}${item.label} `}
|
|
790
|
+
</text>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return (
|
|
794
|
+
<text
|
|
795
|
+
fg={item.status === "error" ? theme.colors.warning : theme.colors.dim}
|
|
796
|
+
>
|
|
797
|
+
{`${pad}${item.label}`}
|
|
798
|
+
</text>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* How much of a component's file the panel prints.
|
|
804
|
+
*
|
|
805
|
+
* The detail pane is a scroll box (PgUp/PgDn, Ctrl+U/Ctrl+D), so this is a
|
|
806
|
+
* render-cost guard, not a reading limit — high enough that essentially every
|
|
807
|
+
* real file prints whole. When it does bite, the panel says how many lines it
|
|
808
|
+
* withheld: a body that simply stops reads as a truncated *file*, which is a
|
|
809
|
+
* different and more alarming claim than a truncated view.
|
|
810
|
+
*/
|
|
811
|
+
const BODY_LINES = 400;
|
|
812
|
+
|
|
813
|
+
/** Frontmatter keys already shown in the header, so the table skips them. */
|
|
814
|
+
const HEADER_KEYS = new Set(["name", "description"]);
|
|
815
|
+
|
|
816
|
+
function componentDetail(
|
|
817
|
+
item: PluginComponentItem,
|
|
818
|
+
entry?: PluginSkillDetailEntry,
|
|
819
|
+
width = 80,
|
|
820
|
+
): React.ReactNode {
|
|
821
|
+
const { component, plugin } = item;
|
|
822
|
+
const detail = entry?.status === "success" ? entry.detail : undefined;
|
|
823
|
+
const isMcp = component.kind === "mcp";
|
|
824
|
+
|
|
825
|
+
const described = detail?.frontmatter.find((f) => f.key === "description");
|
|
826
|
+
const titled = detail?.frontmatter.find((f) => f.key === "name");
|
|
827
|
+
const rest = (detail?.frontmatter ?? []).filter(
|
|
828
|
+
(f) => !HEADER_KEYS.has(f.key) && f.value,
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
const rendered = detail
|
|
832
|
+
? renderMarkdown(detail.body, { width, maxLines: BODY_LINES })
|
|
833
|
+
: null;
|
|
834
|
+
|
|
835
|
+
return (
|
|
836
|
+
<box flexDirection="column">
|
|
837
|
+
<box justifyContent="center">
|
|
838
|
+
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
839
|
+
<strong>
|
|
840
|
+
{" "}
|
|
841
|
+
{COMPONENT_ICON[component.kind]} {titled?.value ||
|
|
842
|
+
component.name}{" "}
|
|
843
|
+
</strong>
|
|
844
|
+
</text>
|
|
845
|
+
</box>
|
|
846
|
+
|
|
847
|
+
{described ? (
|
|
848
|
+
<box marginTop={1}>
|
|
849
|
+
<text fg={theme.colors.text}>{described.value}</text>
|
|
850
|
+
</box>
|
|
851
|
+
) : null}
|
|
852
|
+
|
|
853
|
+
<box marginTop={1} flexDirection="column">
|
|
854
|
+
<KeyValueLine
|
|
855
|
+
label="Kind"
|
|
856
|
+
value={
|
|
857
|
+
<span>
|
|
858
|
+
{componentBadge(component.kind, false)}
|
|
859
|
+
<span fg={COMPONENT_COLOR[component.kind]}>
|
|
860
|
+
{` ${COMPONENT_NOUN[component.kind]}`}
|
|
861
|
+
</span>
|
|
862
|
+
</span>
|
|
863
|
+
}
|
|
864
|
+
/>
|
|
865
|
+
<KeyValueLine
|
|
866
|
+
label="Plugin"
|
|
867
|
+
value={<span fg={theme.colors.accent}>{plugin.name}</span>}
|
|
868
|
+
/>
|
|
869
|
+
{component.group ? (
|
|
870
|
+
<KeyValueLine
|
|
871
|
+
label="Group"
|
|
872
|
+
value={<span fg={theme.colors.accent}>{component.group}</span>}
|
|
873
|
+
/>
|
|
874
|
+
) : null}
|
|
875
|
+
{component.mcpCommand ? (
|
|
876
|
+
<KeyValueLine
|
|
877
|
+
label="Runs"
|
|
878
|
+
value={<span fg={theme.colors.link}>{component.mcpCommand}</span>}
|
|
879
|
+
/>
|
|
880
|
+
) : null}
|
|
881
|
+
<KeyValueLine
|
|
882
|
+
label="Path"
|
|
883
|
+
value={<span fg={theme.colors.link}>{component.repoPath}</span>}
|
|
884
|
+
/>
|
|
885
|
+
{rest.map((field) => (
|
|
886
|
+
<KeyValueLine
|
|
887
|
+
key={field.key}
|
|
888
|
+
label={field.key}
|
|
889
|
+
value={<span fg={theme.colors.warning}>{field.value}</span>}
|
|
890
|
+
/>
|
|
891
|
+
))}
|
|
892
|
+
</box>
|
|
893
|
+
|
|
894
|
+
{/* An MCP server is declared in JSON, not a markdown document, so there
|
|
895
|
+
is no body to fetch — the Runs line above is the whole of it. */}
|
|
896
|
+
{!isMcp && (entry?.status === "loading" || !entry) ? (
|
|
897
|
+
<box marginTop={1}>
|
|
898
|
+
<text fg={theme.colors.muted}>Reading {component.repoPath}…</text>
|
|
899
|
+
</box>
|
|
900
|
+
) : null}
|
|
901
|
+
|
|
902
|
+
{entry?.status === "error" ? (
|
|
903
|
+
<box marginTop={1} flexDirection="column">
|
|
904
|
+
<text fg={theme.colors.warning}>
|
|
905
|
+
Could not read it: {entry.error}
|
|
906
|
+
</text>
|
|
907
|
+
<text fg={theme.colors.muted}>
|
|
908
|
+
Set GITHUB_TOKEN to raise the 60-per-hour unauthenticated limit.
|
|
909
|
+
</text>
|
|
910
|
+
</box>
|
|
911
|
+
) : null}
|
|
912
|
+
|
|
913
|
+
{rendered && rendered.nodes.length > 0 ? (
|
|
914
|
+
<DetailSection>
|
|
915
|
+
<text fg={theme.colors.text}>{"─".repeat(24)}</text>
|
|
916
|
+
<box marginTop={1} flexDirection="column">
|
|
917
|
+
{rendered.nodes}
|
|
918
|
+
</box>
|
|
919
|
+
{rendered.withheld > 0 ? (
|
|
920
|
+
<box marginTop={1}>
|
|
921
|
+
<text fg={theme.colors.dim}>
|
|
922
|
+
… {rendered.withheld} more lines in the file
|
|
923
|
+
</text>
|
|
924
|
+
</box>
|
|
925
|
+
) : null}
|
|
926
|
+
</DetailSection>
|
|
927
|
+
) : null}
|
|
928
|
+
|
|
929
|
+
{detail && detail.body.length === 0 ? (
|
|
930
|
+
<box marginTop={1}>
|
|
931
|
+
<text fg={theme.colors.muted}>Frontmatter only — no body.</text>
|
|
932
|
+
</box>
|
|
933
|
+
) : null}
|
|
934
|
+
|
|
935
|
+
<box marginTop={1}>
|
|
936
|
+
<text fg={theme.colors.dim}>
|
|
937
|
+
Installing {plugin.name} installs this. Plugin components are not
|
|
938
|
+
installed one at a time.
|
|
939
|
+
</text>
|
|
940
|
+
</box>
|
|
941
|
+
</box>
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function contentsStatusDetail(item: PluginContentsStatusItem): React.ReactNode {
|
|
946
|
+
if (item.status === "error") {
|
|
947
|
+
return (
|
|
948
|
+
<box flexDirection="column">
|
|
949
|
+
<text fg={theme.colors.warning}>
|
|
950
|
+
<strong>Could not read {item.plugin.name}</strong>
|
|
951
|
+
</text>
|
|
952
|
+
<box marginTop={1}>
|
|
953
|
+
<text fg={theme.colors.muted}>{item.error || "Unknown error."}</text>
|
|
954
|
+
</box>
|
|
955
|
+
<box marginTop={1}>
|
|
956
|
+
<text fg={theme.colors.muted}>
|
|
957
|
+
A marketplace you have added is read from its local clone and needs
|
|
958
|
+
no network. This one is not added, so its contents come from the
|
|
959
|
+
GitHub API — where an unauthenticated client gets 60 requests an
|
|
960
|
+
hour across every repo on screen combined.
|
|
961
|
+
</text>
|
|
962
|
+
</box>
|
|
963
|
+
<box marginTop={1}>
|
|
964
|
+
<text fg={theme.colors.muted}>
|
|
965
|
+
Add the marketplace to read it from disk instead, or set
|
|
966
|
+
GITHUB_TOKEN to raise the limit. Collapse and expand to retry — the
|
|
967
|
+
failure is not cached.
|
|
968
|
+
</text>
|
|
969
|
+
</box>
|
|
970
|
+
</box>
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
if (item.status === "empty") {
|
|
974
|
+
return (
|
|
975
|
+
<box flexDirection="column">
|
|
976
|
+
<text fg={theme.colors.text}>
|
|
977
|
+
<strong>{item.plugin.name} ships no components</strong>
|
|
978
|
+
</text>
|
|
979
|
+
<box marginTop={1}>
|
|
980
|
+
<text fg={theme.colors.muted}>
|
|
981
|
+
No skills, commands, subagents or MCP servers were found in its
|
|
982
|
+
directory. It may ship hooks, an output style, or nothing but
|
|
983
|
+
documentation.
|
|
984
|
+
</text>
|
|
985
|
+
</box>
|
|
986
|
+
</box>
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
return <text fg={theme.colors.muted}>Reading the repo tree…</text>;
|
|
990
|
+
}
|
|
991
|
+
|
|
602
992
|
// ─── Public dispatch functions ────────────────────────────────────────────────
|
|
603
993
|
|
|
604
994
|
/**
|
|
@@ -612,6 +1002,12 @@ export function renderPluginRow(
|
|
|
612
1002
|
if (item.kind === "category") {
|
|
613
1003
|
return categoryRow(item, isSelected);
|
|
614
1004
|
}
|
|
1005
|
+
if (item.kind === "component") {
|
|
1006
|
+
return componentRow(item, isSelected);
|
|
1007
|
+
}
|
|
1008
|
+
if (item.kind === "contents-status") {
|
|
1009
|
+
return contentsStatusRow(item, isSelected);
|
|
1010
|
+
}
|
|
615
1011
|
return pluginRow(item, isSelected);
|
|
616
1012
|
}
|
|
617
1013
|
|
|
@@ -621,10 +1017,20 @@ export function renderPluginRow(
|
|
|
621
1017
|
export function renderPluginDetail(
|
|
622
1018
|
item: PluginBrowserItem | undefined,
|
|
623
1019
|
collapsedMarketplaces: Set<string>,
|
|
1020
|
+
pluginContents?: Map<string, PluginContentsEntry>,
|
|
1021
|
+
skillDetails?: Map<string, PluginSkillDetailEntry>,
|
|
1022
|
+
/** Columns the detail pane has, so a markdown table can size its columns. */
|
|
1023
|
+
width?: number,
|
|
624
1024
|
): React.ReactNode {
|
|
625
1025
|
if (!item) return <text fg={theme.colors.muted}>Select an item</text>;
|
|
626
1026
|
if (item.kind === "category") {
|
|
627
1027
|
return categoryDetail(item, collapsedMarketplaces);
|
|
628
1028
|
}
|
|
629
|
-
|
|
1029
|
+
if (item.kind === "component") {
|
|
1030
|
+
return componentDetail(item, skillDetails?.get(item.detailKey), width);
|
|
1031
|
+
}
|
|
1032
|
+
if (item.kind === "contents-status") {
|
|
1033
|
+
return contentsStatusDetail(item);
|
|
1034
|
+
}
|
|
1035
|
+
return pluginDetail(item, pluginContents?.get(item.plugin.id));
|
|
630
1036
|
}
|