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.
@@ -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(item: PluginPluginItem): React.ReactNode {
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
- // Build component counts
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
- if (plugin.agents?.length) components.push(`${plugin.agents.length} agents`);
317
- if (plugin.commands?.length)
318
- components.push(`${plugin.commands.length} commands`);
319
- if (plugin.skills?.length) components.push(`${plugin.skills.length} skills`);
320
- if (plugin.mcpServers?.length)
321
- components.push(`${plugin.mcpServers.length} MCP`);
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
- return pluginDetail(item);
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
  }