claudeup 4.41.0 → 4.42.1

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.
@@ -1,5 +1,7 @@
1
- import type { Marketplace } from "../../types/index.js";
2
1
  import type { PluginInfo } from "../../services/plugin-manager.js";
2
+ import type { PluginComponent } from "../../services/skills-manager.js";
3
+ import type { Marketplace } from "../../types/index.js";
4
+ import type { PluginContentsEntry } from "../state/types.js";
3
5
 
4
6
  // Virtual marketplace name for the community sub-section of claude-plugins-official
5
7
  export const COMMUNITY_VIRTUAL_MARKETPLACE =
@@ -31,9 +33,51 @@ export interface PluginPluginItem {
31
33
  plugin: PluginInfo;
32
34
  /** Fuzzy match highlight indices, if search is active */
33
35
  matches?: number[];
36
+ /** True when this plugin's contents can be opened with ← / →. */
37
+ isExpandable: boolean;
38
+ /** True when its component rows follow below. */
39
+ isExpanded: boolean;
34
40
  }
35
41
 
36
- export type PluginBrowserItem = PluginCategoryItem | PluginPluginItem;
42
+ /**
43
+ * One component inside an expanded plugin — display only.
44
+ *
45
+ * Claude Code installs plugins, not individual pieces, so these rows carry no
46
+ * action. They exist because a plugin row alone says nothing about what
47
+ * installing it brings: a marketplace that publishes its whole repo as one
48
+ * plugin (mattpocock, ecc, ponytail, addy-agent-skills, humanizer and
49
+ * opendesign all do) rendered as a single unexplained line, and a plugin whose
50
+ * only content is an MCP server — mnemex — looked like it shipped nothing at
51
+ * all.
52
+ */
53
+ export interface PluginComponentItem {
54
+ id: string;
55
+ kind: "component";
56
+ label: string;
57
+ /** The plugin this belongs to, for the detail panel. */
58
+ plugin: PluginInfo;
59
+ component: PluginComponent;
60
+ /** Repo the component's file is read from. */
61
+ repo: string;
62
+ /** Cache key for this component's content. */
63
+ detailKey: string;
64
+ }
65
+
66
+ /** Placeholder row shown while a plugin's contents load, or when they cannot. */
67
+ export interface PluginContentsStatusItem {
68
+ id: string;
69
+ kind: "contents-status";
70
+ label: string;
71
+ plugin: PluginInfo;
72
+ status: "loading" | "error" | "empty";
73
+ error?: string;
74
+ }
75
+
76
+ export type PluginBrowserItem =
77
+ | PluginCategoryItem
78
+ | PluginPluginItem
79
+ | PluginComponentItem
80
+ | PluginContentsStatusItem;
37
81
 
38
82
  // ─── Adapter ─────────────────────────────────────────────────────────────────
39
83
 
@@ -41,6 +85,94 @@ export interface BuildPluginBrowserItemsArgs {
41
85
  marketplaces: Marketplace[];
42
86
  plugins: PluginInfo[];
43
87
  collapsedMarketplaces: Set<string>;
88
+ expandedPlugins?: Set<string>;
89
+ pluginContents?: Map<string, PluginContentsEntry>;
90
+ }
91
+
92
+ /**
93
+ * A plugin's contents are listed from its repo tree, so a plugin with no repo
94
+ * to read (a local-path marketplace, or a plugin whose marketplace has no git
95
+ * remote) cannot be expanded. Offering the arrow there would open a row that
96
+ * can only ever say "could not load".
97
+ */
98
+ export function pluginContentsRepo(
99
+ plugin: PluginInfo,
100
+ marketplace: Marketplace,
101
+ ): string | undefined {
102
+ return plugin.sourceRepo || marketplace.source.repo || undefined;
103
+ }
104
+
105
+ /**
106
+ * Appends a plugin row, plus its component rows when expanded.
107
+ */
108
+ function pushPluginItems(
109
+ items: PluginBrowserItem[],
110
+ plugin: PluginInfo,
111
+ marketplace: Marketplace,
112
+ expandedPlugins: Set<string>,
113
+ pluginContents: Map<string, PluginContentsEntry>,
114
+ ): void {
115
+ const repo = pluginContentsRepo(plugin, marketplace);
116
+ const isExpandable = !plugin.isOrphaned && !!repo;
117
+ const isExpanded = isExpandable && expandedPlugins.has(plugin.id);
118
+
119
+ items.push({
120
+ id: `pl:${plugin.id}`,
121
+ kind: "plugin",
122
+ label: plugin.name,
123
+ plugin,
124
+ isExpandable,
125
+ isExpanded,
126
+ });
127
+
128
+ if (!isExpanded) return;
129
+
130
+ const entry = pluginContents.get(plugin.id);
131
+ if (!entry || entry.status === "loading") {
132
+ items.push({
133
+ id: `c:${plugin.id}:loading`,
134
+ kind: "contents-status",
135
+ label: "Reading the plugin…",
136
+ plugin,
137
+ status: "loading",
138
+ });
139
+ return;
140
+ }
141
+ if (entry.status === "error") {
142
+ items.push({
143
+ id: `c:${plugin.id}:error`,
144
+ kind: "contents-status",
145
+ label: "Could not read the plugin",
146
+ plugin,
147
+ status: "error",
148
+ error: entry.error,
149
+ });
150
+ return;
151
+ }
152
+ const components = entry.components ?? [];
153
+ if (components.length === 0) {
154
+ items.push({
155
+ id: `c:${plugin.id}:empty`,
156
+ kind: "contents-status",
157
+ label: "Ships no skills, commands, agents or MCP servers",
158
+ plugin,
159
+ status: "empty",
160
+ });
161
+ return;
162
+ }
163
+ for (const component of components) {
164
+ items.push({
165
+ id: `c:${plugin.id}:${component.kind}:${component.repoPath}:${component.name}`,
166
+ kind: "component",
167
+ label: component.name,
168
+ plugin,
169
+ component,
170
+ // `repo` is non-null here: without one the plugin is not expandable and
171
+ // this loop is unreachable.
172
+ repo: repo as string,
173
+ detailKey: `${repo}/${component.repoPath}#${component.name}`,
174
+ });
175
+ }
44
176
  }
45
177
 
46
178
  /**
@@ -90,6 +222,8 @@ export function buildPluginBrowserItems({
90
222
  marketplaces,
91
223
  plugins,
92
224
  collapsedMarketplaces,
225
+ expandedPlugins = new Set(),
226
+ pluginContents = new Map(),
93
227
  }: BuildPluginBrowserItemsArgs): PluginBrowserItem[] {
94
228
  const pluginsByMarketplace = new Map<string, PluginInfo[]>();
95
229
  for (const plugin of plugins) {
@@ -139,12 +273,13 @@ export function buildPluginBrowserItems({
139
273
  });
140
274
  if (isEnabled && anthropicHasPlugins && !anthropicCollapsed) {
141
275
  for (const plugin of anthropicPlugins) {
142
- items.push({
143
- id: `pl:${plugin.id}`,
144
- kind: "plugin",
145
- label: plugin.name,
276
+ pushPluginItems(
277
+ items,
146
278
  plugin,
147
- });
279
+ marketplace,
280
+ expandedPlugins,
281
+ pluginContents,
282
+ );
148
283
  }
149
284
  }
150
285
 
@@ -175,12 +310,13 @@ export function buildPluginBrowserItems({
175
310
  });
176
311
  if (!communityCollapsed) {
177
312
  for (const plugin of communityPlugins) {
178
- items.push({
179
- id: `pl:${plugin.id}`,
180
- kind: "plugin",
181
- label: plugin.name,
313
+ pushPluginItems(
314
+ items,
182
315
  plugin,
183
- });
316
+ communityVirtualMp,
317
+ expandedPlugins,
318
+ pluginContents,
319
+ );
184
320
  }
185
321
  }
186
322
  }
@@ -205,12 +341,13 @@ export function buildPluginBrowserItems({
205
341
  // Plugins under this marketplace (if expanded)
206
342
  if (isEnabled && hasPlugins && !isCollapsed) {
207
343
  for (const plugin of marketplacePlugins) {
208
- items.push({
209
- id: `pl:${plugin.id}`,
210
- kind: "plugin",
211
- label: plugin.name,
344
+ pushPluginItems(
345
+ items,
212
346
  plugin,
213
- });
347
+ marketplace,
348
+ expandedPlugins,
349
+ pluginContents,
350
+ );
214
351
  }
215
352
  }
216
353
  }
@@ -8,12 +8,18 @@ interface KeyValueLineProps {
8
8
 
9
9
  /**
10
10
  * Aligned label-value pair for detail panels.
11
- * Label is padded to 10 chars for consistent alignment.
11
+ *
12
+ * Labels pad to a 10-column gutter. The trailing space is inside the padding,
13
+ * not appended after it, so a label at or past 10 columns still separates from
14
+ * its value instead of running into it: every caller used to pass a short fixed
15
+ * label, and the first arbitrary one — a skill's `disable-model-invocation`
16
+ * frontmatter key — rendered as `disable-model-invocationtrue`. Labels of 9
17
+ * columns or fewer are unaffected.
12
18
  */
13
19
  export function KeyValueLine({ label, value }: KeyValueLineProps) {
14
20
  return (
15
21
  <text fg={theme.colors.text}>
16
- <span fg={theme.colors.muted}>{label.padEnd(10)}</span>
22
+ <span fg={theme.colors.muted}>{`${label} `.padEnd(10)}</span>
17
23
  {value}
18
24
  </text>
19
25
  );
@@ -0,0 +1,302 @@
1
+ import type React from "react";
2
+ import { theme } from "../theme.js";
3
+
4
+ /**
5
+ * Markdown → OpenTUI elements, for the detail pane.
6
+ *
7
+ * Why not a library. `glow` is a Go binary: it cannot ship inside
8
+ * `bun build --compile`, and shelling out to something the user may not have
9
+ * installed turns a display detail into a runtime dependency. The JS options
10
+ * (`marked-terminal` and friends) emit ANSI escape sequences, which is the
11
+ * wrong output format — OpenTUI composes `<text>`/`<span>` elements with theme
12
+ * colours, and feeding it raw escapes bypasses the theme and breaks on a
13
+ * re-render. This module is ~200 lines and emits elements directly.
14
+ *
15
+ * Deliberately partial. It covers what SKILL.md files actually contain:
16
+ * headings, fenced code, tables, rules, lists, blockquotes, and inline
17
+ * bold/code. Anything unrecognised falls through as plain text, which is what
18
+ * the pane showed for everything before.
19
+ */
20
+
21
+ export interface MarkdownRenderResult {
22
+ nodes: React.ReactNode[];
23
+ /** Lines beyond `maxLines` that were not rendered. */
24
+ withheld: number;
25
+ }
26
+
27
+ interface RenderOptions {
28
+ /** Columns available to the pane, used to size table columns. */
29
+ width: number;
30
+ /** Render-cost guard; the pane itself scrolls. */
31
+ maxLines: number;
32
+ }
33
+
34
+ // ─── Inline spans ────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Split a line into `code`, **bold** and plain runs.
38
+ *
39
+ * One pass over both markers rather than nested passes: a nested pass would
40
+ * re-scan text already claimed by a code span and turn `` `a**b` `` into a bold
41
+ * run inside code.
42
+ */
43
+ function inlineSpans(text: string, keyPrefix: string): React.ReactNode[] {
44
+ const nodes: React.ReactNode[] = [];
45
+ const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)/g;
46
+ let last = 0;
47
+ let match: RegExpExecArray | null = pattern.exec(text);
48
+ let i = 0;
49
+
50
+ while (match) {
51
+ if (match.index > last) {
52
+ nodes.push(
53
+ <span key={`${keyPrefix}-t${i}`}>{text.slice(last, match.index)}</span>,
54
+ );
55
+ }
56
+ if (match[1]) {
57
+ nodes.push(
58
+ <span key={`${keyPrefix}-c${i}`} fg={theme.colors.link}>
59
+ {match[1].slice(1, -1)}
60
+ </span>,
61
+ );
62
+ } else {
63
+ nodes.push(
64
+ <strong key={`${keyPrefix}-b${i}`}>{match[2].slice(2, -2)}</strong>,
65
+ );
66
+ }
67
+ last = match.index + match[0].length;
68
+ i += 1;
69
+ match = pattern.exec(text);
70
+ }
71
+
72
+ if (last < text.length) {
73
+ nodes.push(<span key={`${keyPrefix}-t${i}`}>{text.slice(last)}</span>);
74
+ }
75
+ return nodes;
76
+ }
77
+
78
+ // ─── Tables ──────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Strip inline markers from a table cell.
82
+ *
83
+ * Cells are padded to a column width, so they cannot carry `<span>` children
84
+ * the way body text does without the padding and the spans disagreeing about
85
+ * length. Removing the markers is the honest trade: a cell reading
86
+ * `` `context`, `callers` `` printed its backticks verbatim, which is the raw
87
+ * source leaking into a rendered view.
88
+ */
89
+ function plainCell(cell: string): string {
90
+ return cell
91
+ .trim()
92
+ .replace(/`([^`]+)`/g, "$1")
93
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
94
+ .replace(/<br\s*\/?>/gi, " ");
95
+ }
96
+
97
+ function splitRow(line: string): string[] {
98
+ return line
99
+ .replace(/^\s*\|/, "")
100
+ .replace(/\|\s*$/, "")
101
+ .split("|")
102
+ .map(plainCell);
103
+ }
104
+
105
+ const isSeparatorRow = (line: string): boolean =>
106
+ /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-");
107
+
108
+ /**
109
+ * Render a table as aligned columns with a rule under the header.
110
+ *
111
+ * Pipes are dropped rather than drawn. In a pane this narrow the vertical bars
112
+ * cost a column each and, once a cell wraps, line up with nothing — which is
113
+ * exactly how the raw source read before.
114
+ */
115
+ function renderTable(
116
+ rows: string[][],
117
+ width: number,
118
+ key: string,
119
+ ): React.ReactNode[] {
120
+ const columns = Math.max(...rows.map((r) => r.length));
121
+ const natural: number[] = [];
122
+ for (let c = 0; c < columns; c++) {
123
+ natural.push(Math.max(1, ...rows.map((r) => (r[c] ?? "").length)));
124
+ }
125
+
126
+ // Shrink the widest column until the row fits. Shrinking the widest keeps a
127
+ // narrow key column (Mode, Level) intact and takes the space out of the prose
128
+ // column, which is the one that can afford it.
129
+ const gap = 2;
130
+ const budget = Math.max(20, width - 1);
131
+ const widths = [...natural];
132
+ const total = () => widths.reduce((a, b) => a + b, 0) + gap * (columns - 1);
133
+ while (total() > budget) {
134
+ const widest = widths.indexOf(Math.max(...widths));
135
+ if (widths[widest] <= 6) break;
136
+ widths[widest] -= 1;
137
+ }
138
+
139
+ const fit = (cell: string, w: number) =>
140
+ cell.length <= w ? cell.padEnd(w) : `${cell.slice(0, Math.max(1, w - 1))}…`;
141
+
142
+ const nodes: React.ReactNode[] = [];
143
+ rows.forEach((row, r) => {
144
+ const line = widths
145
+ .map((w, c) => fit(row[c] ?? "", w))
146
+ .join(" ".repeat(gap))
147
+ .trimEnd();
148
+
149
+ nodes.push(
150
+ // biome-ignore lint/suspicious/noArrayIndexKey: table rows never reorder
151
+ <text key={`${key}-r${r}`} fg={theme.colors.muted}>
152
+ {" "}
153
+ {r === 0 ? <strong>{line}</strong> : line}
154
+ </text>,
155
+ );
156
+
157
+ if (r === 0) {
158
+ nodes.push(
159
+ <text key={`${key}-rule`} fg={theme.colors.dim}>
160
+ {" "}
161
+ {widths.map((w) => "─".repeat(w)).join(" ".repeat(gap))}
162
+ </text>,
163
+ );
164
+ }
165
+ });
166
+ return nodes;
167
+ }
168
+
169
+ // ─── Document ────────────────────────────────────────────────────────────────
170
+
171
+ const HEADING_COLORS = [
172
+ theme.colors.accent,
173
+ theme.colors.accent,
174
+ theme.colors.info,
175
+ theme.colors.info,
176
+ theme.colors.muted,
177
+ theme.colors.muted,
178
+ ];
179
+
180
+ export function renderMarkdown(
181
+ source: string,
182
+ { width, maxLines }: RenderOptions,
183
+ ): MarkdownRenderResult {
184
+ const lines = source.split("\n");
185
+ const nodes: React.ReactNode[] = [];
186
+ let i = 0;
187
+ let emitted = 0;
188
+
189
+ const push = (node: React.ReactNode) => {
190
+ nodes.push(node);
191
+ emitted += 1;
192
+ };
193
+
194
+ while (i < lines.length && emitted < maxLines) {
195
+ const line = lines[i];
196
+
197
+ // Fenced code. The fence markers are dropped and the block is tinted, so
198
+ // the reader sees a code block rather than three backticks they have to
199
+ // mentally strip.
200
+ const fence = line.match(/^\s*```\s*(\S*)/);
201
+ if (fence) {
202
+ const lang = fence[1];
203
+ i += 1;
204
+ if (lang) {
205
+ push(
206
+ <text key={`f${i}`} fg={theme.colors.dim}>
207
+ {" "}
208
+ {lang}
209
+ </text>,
210
+ );
211
+ }
212
+ while (i < lines.length && !/^\s*```/.test(lines[i])) {
213
+ const code = lines[i];
214
+ push(
215
+ <text key={`fc${i}`} fg={theme.colors.link}>
216
+ {` ${code}` || " "}
217
+ </text>,
218
+ );
219
+ i += 1;
220
+ if (emitted >= maxLines) break;
221
+ }
222
+ i += 1; // closing fence
223
+ continue;
224
+ }
225
+
226
+ // Table: a pipe row whose successor is a separator row.
227
+ if (
228
+ line.trim().startsWith("|") &&
229
+ i + 1 < lines.length &&
230
+ isSeparatorRow(lines[i + 1])
231
+ ) {
232
+ const rows: string[][] = [splitRow(line)];
233
+ i += 2;
234
+ while (i < lines.length && lines[i].trim().startsWith("|")) {
235
+ rows.push(splitRow(lines[i]));
236
+ i += 1;
237
+ }
238
+ for (const node of renderTable(rows, width, `tb${i}`)) push(node);
239
+ continue;
240
+ }
241
+
242
+ // Horizontal rule. Drawn, rather than left as three hyphens that read as
243
+ // a stray line of text.
244
+ if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
245
+ push(
246
+ <text key={`hr${i}`} fg={theme.colors.dim}>
247
+ {"─".repeat(Math.max(4, Math.min(width - 1, 60)))}
248
+ </text>,
249
+ );
250
+ i += 1;
251
+ continue;
252
+ }
253
+
254
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
255
+ if (heading) {
256
+ const level = heading[1].length;
257
+ push(
258
+ <text key={`h${i}`} fg={HEADING_COLORS[level - 1]}>
259
+ <strong>{heading[2]}</strong>
260
+ </text>,
261
+ );
262
+ i += 1;
263
+ continue;
264
+ }
265
+
266
+ const quote = line.match(/^\s*>\s?(.*)$/);
267
+ if (quote) {
268
+ push(
269
+ <text key={`q${i}`} fg={theme.colors.muted}>
270
+ <span fg={theme.colors.dim}>{" │ "}</span>
271
+ {inlineSpans(quote[1], `q${i}`)}
272
+ </text>,
273
+ );
274
+ i += 1;
275
+ continue;
276
+ }
277
+
278
+ const list = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
279
+ if (list) {
280
+ const indent = " ".repeat(Math.min(list[1].length, 8));
281
+ const bullet = /\d/.test(list[2]) ? list[2] : "•";
282
+ push(
283
+ <text key={`li${i}`} fg={theme.colors.muted}>
284
+ {` ${indent}`}
285
+ <span fg={theme.colors.dim}>{bullet} </span>
286
+ {inlineSpans(list[3], `li${i}`)}
287
+ </text>,
288
+ );
289
+ i += 1;
290
+ continue;
291
+ }
292
+
293
+ push(
294
+ <text key={`p${i}`} fg={theme.colors.muted}>
295
+ {line ? inlineSpans(line, `p${i}`) : " "}
296
+ </text>,
297
+ );
298
+ i += 1;
299
+ }
300
+
301
+ return { nodes, withheld: Math.max(0, lines.length - i) };
302
+ }