claudeup 3.11.0 → 3.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -164,9 +164,11 @@ export async function fetchPopularSkills(limit = 30) {
164
164
  export async function fetchAvailableSkills(_repos, projectPath) {
165
165
  const userInstalled = await getInstalledSkillNames("user");
166
166
  const projectInstalled = await getInstalledSkillNames("project", projectPath);
167
+ const slugify = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
167
168
  const markInstalled = (skill) => {
168
- const isUserInstalled = userInstalled.has(skill.name);
169
- const isProjInstalled = projectInstalled.has(skill.name);
169
+ const slug = slugify(skill.name);
170
+ const isUserInstalled = userInstalled.has(slug) || userInstalled.has(skill.name);
171
+ const isProjInstalled = projectInstalled.has(slug) || projectInstalled.has(skill.name);
170
172
  const installed = isUserInstalled || isProjInstalled;
171
173
  const installedScope = isProjInstalled
172
174
  ? "project"
@@ -256,16 +258,25 @@ export async function installSkill(skill, scope, projectPath) {
256
258
  if (!content) {
257
259
  throw new Error(`Failed to fetch skill: SKILL.md not found in ${repo}/${repoPath}`);
258
260
  }
261
+ // Use slug for directory name — display names can have slashes/spaces
262
+ const dirName = skill.name
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9]+/g, "-")
265
+ .replace(/^-|-$/g, "");
259
266
  const installDir = scope === "user"
260
- ? path.join(getUserSkillsDir(), skill.name)
261
- : path.join(getProjectSkillsDir(projectPath), skill.name);
267
+ ? path.join(getUserSkillsDir(), dirName)
268
+ : path.join(getProjectSkillsDir(projectPath), dirName);
262
269
  await fs.ensureDir(installDir);
263
270
  await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
264
271
  }
265
272
  export async function uninstallSkill(skillName, scope, projectPath) {
273
+ const dirName = skillName
274
+ .toLowerCase()
275
+ .replace(/[^a-z0-9]+/g, "-")
276
+ .replace(/^-|-$/g, "");
266
277
  const installDir = scope === "user"
267
- ? path.join(getUserSkillsDir(), skillName)
268
- : path.join(getProjectSkillsDir(projectPath), skillName);
278
+ ? path.join(getUserSkillsDir(), dirName)
279
+ : path.join(getProjectSkillsDir(projectPath), dirName);
269
280
  const skillMdPath = path.join(installDir, "SKILL.md");
270
281
  if (await fs.pathExists(skillMdPath)) {
271
282
  await fs.remove(skillMdPath);
@@ -228,9 +228,13 @@ export async function fetchAvailableSkills(
228
228
  const userInstalled = await getInstalledSkillNames("user");
229
229
  const projectInstalled = await getInstalledSkillNames("project", projectPath);
230
230
 
231
+ const slugify = (name: string) =>
232
+ name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
233
+
231
234
  const markInstalled = (skill: SkillInfo): SkillInfo => {
232
- const isUserInstalled = userInstalled.has(skill.name);
233
- const isProjInstalled = projectInstalled.has(skill.name);
235
+ const slug = slugify(skill.name);
236
+ const isUserInstalled = userInstalled.has(slug) || userInstalled.has(skill.name);
237
+ const isProjInstalled = projectInstalled.has(slug) || projectInstalled.has(skill.name);
234
238
  const installed = isUserInstalled || isProjInstalled;
235
239
  const installedScope: "user" | "project" | null = isProjInstalled
236
240
  ? "project"
@@ -334,10 +338,16 @@ export async function installSkill(
334
338
  );
335
339
  }
336
340
 
341
+ // Use slug for directory name — display names can have slashes/spaces
342
+ const dirName = skill.name
343
+ .toLowerCase()
344
+ .replace(/[^a-z0-9]+/g, "-")
345
+ .replace(/^-|-$/g, "");
346
+
337
347
  const installDir =
338
348
  scope === "user"
339
- ? path.join(getUserSkillsDir(), skill.name)
340
- : path.join(getProjectSkillsDir(projectPath), skill.name);
349
+ ? path.join(getUserSkillsDir(), dirName)
350
+ : path.join(getProjectSkillsDir(projectPath), dirName);
341
351
 
342
352
  await fs.ensureDir(installDir);
343
353
  await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
@@ -348,10 +358,15 @@ export async function uninstallSkill(
348
358
  scope: "user" | "project",
349
359
  projectPath?: string,
350
360
  ): Promise<void> {
361
+ const dirName = skillName
362
+ .toLowerCase()
363
+ .replace(/[^a-z0-9]+/g, "-")
364
+ .replace(/^-|-$/g, "");
365
+
351
366
  const installDir =
352
367
  scope === "user"
353
- ? path.join(getUserSkillsDir(), skillName)
354
- : path.join(getProjectSkillsDir(projectPath), skillName);
368
+ ? path.join(getUserSkillsDir(), dirName)
369
+ : path.join(getProjectSkillsDir(projectPath), dirName);
355
370
 
356
371
  const skillMdPath = path.join(installDir, "SKILL.md");
357
372
 
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ /**
3
+ * Scope indicator for list items.
4
+ * Shows colored letter badges when installed, empty space when not.
5
+ *
6
+ * Installed in user+project: u p
7
+ * Installed in project only: p
8
+ * Not installed: (3 spaces)
9
+ *
10
+ * Colors match the detail panel: cyan=user, green=project, yellow=local
11
+ */
12
+ export function scopeIndicatorText(user, project, local) {
13
+ const segments = [
14
+ user
15
+ ? { char: "u", bg: "cyan", fg: "black" }
16
+ : { char: " ", bg: "", fg: "" },
17
+ project
18
+ ? { char: "p", bg: "green", fg: "black" }
19
+ : { char: " ", bg: "", fg: "" },
20
+ local
21
+ ? { char: "l", bg: "yellow", fg: "black" }
22
+ : { char: " ", bg: "", fg: "" },
23
+ ];
24
+ const text = segments.map((s) => s.char).join("");
25
+ return { text, segments };
26
+ }
27
+ export function ScopeIndicator({ user, project, local }) {
28
+ const { segments } = scopeIndicatorText(user, project, local);
29
+ return (_jsx("text", { children: segments.map((s, i) => s.bg ? (_jsx("span", { bg: s.bg, fg: s.fg, children: s.char }, i)) : (_jsx("span", { children: " " }, i))) }));
30
+ }
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Scope indicator for list items.
5
+ * Shows colored letter badges when installed, empty space when not.
6
+ *
7
+ * Installed in user+project: u p
8
+ * Installed in project only: p
9
+ * Not installed: (3 spaces)
10
+ *
11
+ * Colors match the detail panel: cyan=user, green=project, yellow=local
12
+ */
13
+
14
+ export function scopeIndicatorText(
15
+ user?: boolean,
16
+ project?: boolean,
17
+ local?: boolean,
18
+ ): { text: string; segments: Array<{ char: string; bg: string; fg: string }> } {
19
+ const segments: Array<{ char: string; bg: string; fg: string }> = [
20
+ user
21
+ ? { char: "u", bg: "cyan", fg: "black" }
22
+ : { char: " ", bg: "", fg: "" },
23
+ project
24
+ ? { char: "p", bg: "green", fg: "black" }
25
+ : { char: " ", bg: "", fg: "" },
26
+ local
27
+ ? { char: "l", bg: "yellow", fg: "black" }
28
+ : { char: " ", bg: "", fg: "" },
29
+ ];
30
+
31
+ const text = segments.map((s) => s.char).join("");
32
+ return { text, segments };
33
+ }
34
+
35
+ interface ScopeIndicatorProps {
36
+ user?: boolean;
37
+ project?: boolean;
38
+ local?: boolean;
39
+ }
40
+
41
+ export function ScopeIndicator({ user, project, local }: ScopeIndicatorProps) {
42
+ const { segments } = scopeIndicatorText(user, project, local);
43
+
44
+ return (
45
+ <text>
46
+ {segments.map((s, i) =>
47
+ s.bg ? (
48
+ <span key={i} bg={s.bg} fg={s.fg}>
49
+ {s.char}
50
+ </span>
51
+ ) : (
52
+ <span key={i}> </span>
53
+ ),
54
+ )}
55
+ </text>
56
+ );
57
+ }
@@ -16,6 +16,10 @@ import { saveInstalledPluginVersion } from "../../services/plugin-manager.js";
16
16
  import { installPlugin as cliInstallPlugin, uninstallPlugin as cliUninstallPlugin, updatePlugin as cliUpdatePlugin, } from "../../services/claude-cli.js";
17
17
  import { getPluginEnvRequirements, getPluginSourcePath, } from "../../services/plugin-mcp-config.js";
18
18
  import { getPluginSetupFromSource, checkMissingDeps, installPluginDeps, } from "../../services/plugin-setup.js";
19
+ // Virtual marketplace name for the community sub-section of claude-plugins-official
20
+ const COMMUNITY_VIRTUAL_MARKETPLACE = "claude-plugins-official:community";
21
+ // The marketplace that gets split into Anthropic Official + Community sections
22
+ const SPLIT_MARKETPLACE = "claude-plugins-official";
19
23
  export function PluginsScreen() {
20
24
  const { state, dispatch } = useApp();
21
25
  const { plugins: pluginsState } = state;
@@ -77,6 +81,64 @@ export function PluginsScreen() {
77
81
  const isCollapsed = collapsed.has(marketplace.name);
78
82
  const isEnabled = marketplacePlugins.length > 0 || marketplace.official;
79
83
  const hasPlugins = marketplacePlugins.length > 0;
84
+ // Special handling: split claude-plugins-official into two sub-sections
85
+ if (marketplace.name === SPLIT_MARKETPLACE && hasPlugins) {
86
+ const anthropicPlugins = marketplacePlugins.filter((p) => p.author?.name?.toLowerCase() === "anthropic");
87
+ const communityPlugins = marketplacePlugins.filter((p) => p.author?.name?.toLowerCase() !== "anthropic");
88
+ // Sub-section 1: Anthropic Official (plugins by Anthropic)
89
+ const anthropicCollapsed = collapsed.has(marketplace.name);
90
+ const anthropicHasPlugins = anthropicPlugins.length > 0;
91
+ items.push({
92
+ id: `mp:${marketplace.name}`,
93
+ type: "category",
94
+ label: marketplace.displayName,
95
+ marketplace,
96
+ marketplaceEnabled: isEnabled,
97
+ pluginCount: anthropicPlugins.length,
98
+ isExpanded: !anthropicCollapsed && anthropicHasPlugins,
99
+ });
100
+ if (isEnabled && anthropicHasPlugins && !anthropicCollapsed) {
101
+ for (const plugin of anthropicPlugins) {
102
+ items.push({
103
+ id: `pl:${plugin.id}`,
104
+ type: "plugin",
105
+ label: plugin.name,
106
+ plugin,
107
+ });
108
+ }
109
+ }
110
+ // Sub-section 2: Community (third-party plugins in same marketplace)
111
+ if (communityPlugins.length > 0) {
112
+ const communityVirtualMp = {
113
+ name: COMMUNITY_VIRTUAL_MARKETPLACE,
114
+ displayName: "Anthropic Official — 3rd Party",
115
+ source: marketplace.source,
116
+ description: "Third-party plugins in the Anthropic Official marketplace",
117
+ };
118
+ const communityCollapsed = collapsed.has(COMMUNITY_VIRTUAL_MARKETPLACE);
119
+ items.push({
120
+ id: `mp:${COMMUNITY_VIRTUAL_MARKETPLACE}`,
121
+ type: "category",
122
+ label: "Anthropic Official — 3rd Party",
123
+ marketplace: communityVirtualMp,
124
+ marketplaceEnabled: true,
125
+ pluginCount: communityPlugins.length,
126
+ isExpanded: !communityCollapsed,
127
+ isCommunitySection: true,
128
+ });
129
+ if (!communityCollapsed) {
130
+ for (const plugin of communityPlugins) {
131
+ items.push({
132
+ id: `pl:${plugin.id}`,
133
+ type: "plugin",
134
+ label: plugin.name,
135
+ plugin,
136
+ });
137
+ }
138
+ }
139
+ }
140
+ continue;
141
+ }
80
142
  // Category header (marketplace)
81
143
  items.push({
82
144
  id: `mp:${marketplace.name}`,
@@ -113,32 +175,44 @@ export function PluginsScreen() {
113
175
  // Only search plugins, not categories
114
176
  const pluginItems = allItems.filter((item) => item.type === "plugin");
115
177
  const fuzzyResults = fuzzyFilter(pluginItems, query, (item) => item.label);
116
- // Include parent categories for matched plugins
117
- const matchedMarketplaces = new Set();
178
+ // Build a set of matched plugin item ids for O(1) lookup
179
+ const matchedPluginIds = new Set();
118
180
  for (const result of fuzzyResults) {
119
- if (result.item.plugin) {
120
- matchedMarketplaces.add(result.item.plugin.marketplace);
181
+ matchedPluginIds.add(result.item.id);
182
+ }
183
+ // Walk allItems sequentially: track the current category section.
184
+ // For each category, include it only if any plugin under it matched.
185
+ // We build a map from category item id -> whether any plugin below matched.
186
+ const categoryHasMatch = new Map();
187
+ let currentCategoryId = null;
188
+ for (const item of allItems) {
189
+ if (item.type === "category") {
190
+ currentCategoryId = item.id;
191
+ if (!categoryHasMatch.has(item.id)) {
192
+ categoryHasMatch.set(item.id, false);
193
+ }
194
+ }
195
+ else if (item.type === "plugin" && currentCategoryId) {
196
+ if (matchedPluginIds.has(item.id)) {
197
+ categoryHasMatch.set(currentCategoryId, true);
198
+ }
121
199
  }
122
200
  }
123
201
  const result = [];
124
- let currentMarketplace = null;
202
+ let currentCatIncluded = false;
203
+ currentCategoryId = null;
125
204
  for (const item of allItems) {
126
- if (item.type === "category" && item.marketplace) {
127
- if (matchedMarketplaces.has(item.marketplace.name)) {
205
+ if (item.type === "category") {
206
+ currentCategoryId = item.id;
207
+ currentCatIncluded = categoryHasMatch.get(item.id) === true;
208
+ if (currentCatIncluded) {
128
209
  result.push(item);
129
- currentMarketplace = item.marketplace.name;
130
- }
131
- else {
132
- currentMarketplace = null;
133
210
  }
134
211
  }
135
- else if (item.type === "plugin" && item.plugin) {
136
- if (currentMarketplace === item.plugin.marketplace) {
137
- // Check if this plugin matched
212
+ else if (item.type === "plugin" && currentCatIncluded) {
213
+ if (matchedPluginIds.has(item.id)) {
138
214
  const matched = fuzzyResults.find((r) => r.item.id === item.id);
139
- if (matched) {
140
- result.push({ ...item, _matches: matched.matches });
141
- }
215
+ result.push({ ...item, _matches: matched?.matches });
142
216
  }
143
217
  }
144
218
  }
@@ -634,9 +708,10 @@ export function PluginsScreen() {
634
708
  installedVersion &&
635
709
  latestVersion !== "0.0.0" &&
636
710
  installedVersion !== latestVersion;
637
- // Determine action: if installed in this scope → uninstall
638
- // If installed anywhere else but not this scope uninstall from detected scope
639
- // Otherwiseinstall
711
+ // Determine action for THIS scope:
712
+ // - installed in this scope + has update update
713
+ // - installed in this scope uninstall from this scope
714
+ // - not installed in this scope → install to this scope
640
715
  let action;
641
716
  if (isInstalledInScope && hasUpdateInScope) {
642
717
  action = "update";
@@ -644,12 +719,8 @@ export function PluginsScreen() {
644
719
  else if (isInstalledInScope) {
645
720
  action = "uninstall";
646
721
  }
647
- else if (!isInstalledAnywhere) {
648
- action = "install";
649
- }
650
722
  else {
651
- // Installed in a different scope — uninstall from the scope it's actually in
652
- action = "uninstall";
723
+ action = "install";
653
724
  }
654
725
  const actionLabel = action === "update"
655
726
  ? `Updating ${scopeLabel}`
@@ -777,7 +848,11 @@ export function PluginsScreen() {
777
848
  let statusText = "";
778
849
  let statusColor = "green";
779
850
  if (item.marketplaceEnabled) {
780
- if (mp.name === "claude-plugins-official") {
851
+ if (item.isCommunitySection) {
852
+ statusText = "3rd Party";
853
+ statusColor = "gray";
854
+ }
855
+ else if (mp.name === "claude-plugins-official") {
781
856
  statusText = "★ Official";
782
857
  statusColor = "yellow";
783
858
  }
@@ -805,17 +880,12 @@ export function PluginsScreen() {
805
880
  }
806
881
  if (item.type === "plugin" && item.plugin) {
807
882
  const plugin = item.plugin;
808
- let statusIcon = "○";
809
- let statusColor = "gray";
810
883
  const isAnyScope = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
811
- if (plugin.isOrphaned) {
812
- statusIcon = "x";
813
- statusColor = "red";
814
- }
815
- else if (isAnyScope) {
816
- statusIcon = "●";
817
- statusColor = "green";
818
- }
884
+ // Build scope parts for colored rendering
885
+ const hasUser = plugin.userScope?.enabled;
886
+ const hasProject = plugin.projectScope?.enabled;
887
+ const hasLocal = plugin.localScope?.enabled;
888
+ const hasAnyScope = hasUser || hasProject || hasLocal;
819
889
  // Build version string
820
890
  let versionStr = "";
821
891
  if (plugin.isOrphaned) {
@@ -831,19 +901,18 @@ export function PluginsScreen() {
831
901
  const matches = item._matches;
832
902
  const segments = matches ? highlightMatches(plugin.name, matches) : null;
833
903
  if (isSelected) {
834
- const displayText = ` ${statusIcon} ${plugin.name}${versionStr} `;
835
- return (_jsx("text", { bg: "magenta", fg: "white", children: displayText }));
904
+ const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}${hasLocal ? "l" : "."}]`;
905
+ return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", scopeStr, " ", plugin.name, versionStr, " "] }));
836
906
  }
837
- // For non-selected, render with colors
838
907
  const displayName = segments
839
908
  ? segments.map((seg) => seg.text).join("")
840
909
  : plugin.name;
841
910
  if (plugin.isOrphaned) {
842
911
  const ver = plugin.installedVersion && plugin.installedVersion !== "0.0.0"
843
912
  ? ` v${plugin.installedVersion}` : "";
844
- return (_jsxs("text", { children: [_jsxs("span", { fg: "red", children: [" ", statusIcon, " "] }), _jsx("span", { fg: "gray", children: displayName }), ver && _jsx("span", { fg: "yellow", children: ver }), _jsx("span", { fg: "red", children: " deprecated" })] }));
913
+ return (_jsxs("text", { children: [_jsx("span", { fg: "red", children: " [x..] " }), _jsx("span", { fg: "gray", children: displayName }), ver && _jsx("span", { fg: "yellow", children: ver }), _jsx("span", { fg: "red", children: " deprecated" })] }));
845
914
  }
846
- return (_jsxs("text", { children: [_jsxs("span", { fg: statusColor, children: [" ", statusIcon, " "] }), _jsx("span", { children: displayName }), _jsx("span", { fg: plugin.hasUpdate ? "yellow" : "gray", children: versionStr })] }));
915
+ return (_jsxs("text", { children: [_jsx("span", { fg: "#555555", children: " [" }), _jsx("span", { fg: hasUser ? "cyan" : "#555555", children: hasUser ? "u" : "." }), _jsx("span", { fg: hasProject ? "green" : "#555555", children: hasProject ? "p" : "." }), _jsx("span", { fg: hasLocal ? "yellow" : "#555555", children: hasLocal ? "l" : "." }), _jsx("span", { fg: "#555555", children: "]" }), _jsx("span", { children: " " }), _jsx("span", { fg: hasAnyScope ? "white" : "gray", children: displayName }), _jsx("span", { fg: plugin.hasUpdate ? "yellow" : "gray", children: versionStr })] }));
847
916
  }
848
917
  return _jsx("text", { fg: "gray", children: item.label });
849
918
  };
@@ -857,6 +926,8 @@ export function PluginsScreen() {
857
926
  const isEnabled = selectedItem.marketplaceEnabled;
858
927
  // Get appropriate badge for marketplace type
859
928
  const getBadge = () => {
929
+ if (selectedItem.isCommunitySection)
930
+ return " 3rd Party";
860
931
  if (mp.name === "claude-plugins-official")
861
932
  return " ★";
862
933
  if (mp.name === "claude-code-plugins")
@@ -6,6 +6,7 @@ import { ScreenLayout } from "../components/layout/index.js";
6
6
  import { CategoryHeader } from "../components/CategoryHeader.js";
7
7
  import { ScrollableList } from "../components/ScrollableList.js";
8
8
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
9
+ import { scopeIndicatorText } from "../components/ScopeIndicator.js";
9
10
  import { fuzzyFilter, highlightMatches } from "../../utils/fuzzy-search.js";
10
11
  import { getAllMarketplaces } from "../../data/marketplaces.js";
11
12
  import {
@@ -41,6 +42,11 @@ import {
41
42
  } from "../../services/plugin-setup.js";
42
43
  import type { Marketplace } from "../../types/index.js";
43
44
 
45
+ // Virtual marketplace name for the community sub-section of claude-plugins-official
46
+ const COMMUNITY_VIRTUAL_MARKETPLACE = "claude-plugins-official:community";
47
+ // The marketplace that gets split into Anthropic Official + Community sections
48
+ const SPLIT_MARKETPLACE = "claude-plugins-official";
49
+
44
50
  interface ListItem {
45
51
  id: string;
46
52
  type: "category" | "plugin";
@@ -50,6 +56,8 @@ interface ListItem {
50
56
  plugin?: PluginInfo;
51
57
  pluginCount?: number;
52
58
  isExpanded?: boolean;
59
+ /** True for the virtual Community sub-section derived from claude-plugins-official */
60
+ isCommunitySection?: boolean;
53
61
  }
54
62
 
55
63
  export function PluginsScreen() {
@@ -128,6 +136,72 @@ export function PluginsScreen() {
128
136
  const isEnabled = marketplacePlugins.length > 0 || marketplace.official;
129
137
  const hasPlugins = marketplacePlugins.length > 0;
130
138
 
139
+ // Special handling: split claude-plugins-official into two sub-sections
140
+ if (marketplace.name === SPLIT_MARKETPLACE && hasPlugins) {
141
+ const anthropicPlugins = marketplacePlugins.filter(
142
+ (p) => p.author?.name?.toLowerCase() === "anthropic",
143
+ );
144
+ const communityPlugins = marketplacePlugins.filter(
145
+ (p) => p.author?.name?.toLowerCase() !== "anthropic",
146
+ );
147
+
148
+ // Sub-section 1: Anthropic Official (plugins by Anthropic)
149
+ const anthropicCollapsed = collapsed.has(marketplace.name);
150
+ const anthropicHasPlugins = anthropicPlugins.length > 0;
151
+ items.push({
152
+ id: `mp:${marketplace.name}`,
153
+ type: "category",
154
+ label: marketplace.displayName,
155
+ marketplace,
156
+ marketplaceEnabled: isEnabled,
157
+ pluginCount: anthropicPlugins.length,
158
+ isExpanded: !anthropicCollapsed && anthropicHasPlugins,
159
+ });
160
+ if (isEnabled && anthropicHasPlugins && !anthropicCollapsed) {
161
+ for (const plugin of anthropicPlugins) {
162
+ items.push({
163
+ id: `pl:${plugin.id}`,
164
+ type: "plugin",
165
+ label: plugin.name,
166
+ plugin,
167
+ });
168
+ }
169
+ }
170
+
171
+ // Sub-section 2: Community (third-party plugins in same marketplace)
172
+ if (communityPlugins.length > 0) {
173
+ const communityVirtualMp: Marketplace = {
174
+ name: COMMUNITY_VIRTUAL_MARKETPLACE,
175
+ displayName: "Anthropic Official — 3rd Party",
176
+ source: marketplace.source,
177
+ description: "Third-party plugins in the Anthropic Official marketplace",
178
+ };
179
+ const communityCollapsed = collapsed.has(COMMUNITY_VIRTUAL_MARKETPLACE);
180
+ items.push({
181
+ id: `mp:${COMMUNITY_VIRTUAL_MARKETPLACE}`,
182
+ type: "category",
183
+ label: "Anthropic Official — 3rd Party",
184
+ marketplace: communityVirtualMp,
185
+ marketplaceEnabled: true,
186
+ pluginCount: communityPlugins.length,
187
+ isExpanded: !communityCollapsed,
188
+ isCommunitySection: true,
189
+ });
190
+ if (!communityCollapsed) {
191
+ for (const plugin of communityPlugins) {
192
+ items.push({
193
+ id: `pl:${plugin.id}`,
194
+ type: "plugin",
195
+ label: plugin.name,
196
+ plugin,
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ continue;
203
+ }
204
+
131
205
  // Category header (marketplace)
132
206
  items.push({
133
207
  id: `mp:${marketplace.name}`,
@@ -168,34 +242,47 @@ export function PluginsScreen() {
168
242
  const pluginItems = allItems.filter((item) => item.type === "plugin");
169
243
  const fuzzyResults = fuzzyFilter(pluginItems, query, (item) => item.label);
170
244
 
171
- // Include parent categories for matched plugins
172
- const matchedMarketplaces = new Set<string>();
245
+ // Build a set of matched plugin item ids for O(1) lookup
246
+ const matchedPluginIds = new Set<string>();
173
247
  for (const result of fuzzyResults) {
174
- if (result.item.plugin) {
175
- matchedMarketplaces.add(result.item.plugin.marketplace);
248
+ matchedPluginIds.add(result.item.id);
249
+ }
250
+
251
+ // Walk allItems sequentially: track the current category section.
252
+ // For each category, include it only if any plugin under it matched.
253
+ // We build a map from category item id -> whether any plugin below matched.
254
+ const categoryHasMatch = new Map<string, boolean>();
255
+ let currentCategoryId: string | null = null;
256
+ for (const item of allItems) {
257
+ if (item.type === "category") {
258
+ currentCategoryId = item.id;
259
+ if (!categoryHasMatch.has(item.id)) {
260
+ categoryHasMatch.set(item.id, false);
261
+ }
262
+ } else if (item.type === "plugin" && currentCategoryId) {
263
+ if (matchedPluginIds.has(item.id)) {
264
+ categoryHasMatch.set(currentCategoryId, true);
265
+ }
176
266
  }
177
267
  }
178
268
 
179
269
  const result: ListItem[] = [];
180
- let currentMarketplace: string | null = null;
270
+ let currentCatIncluded = false;
271
+ currentCategoryId = null;
181
272
 
182
273
  for (const item of allItems) {
183
- if (item.type === "category" && item.marketplace) {
184
- if (matchedMarketplaces.has(item.marketplace.name)) {
274
+ if (item.type === "category") {
275
+ currentCategoryId = item.id;
276
+ currentCatIncluded = categoryHasMatch.get(item.id) === true;
277
+ if (currentCatIncluded) {
185
278
  result.push(item);
186
- currentMarketplace = item.marketplace.name;
187
- } else {
188
- currentMarketplace = null;
189
279
  }
190
- } else if (item.type === "plugin" && item.plugin) {
191
- if (currentMarketplace === item.plugin.marketplace) {
192
- // Check if this plugin matched
280
+ } else if (item.type === "plugin" && currentCatIncluded) {
281
+ if (matchedPluginIds.has(item.id)) {
193
282
  const matched = fuzzyResults.find((r) => r.item.id === item.id);
194
- if (matched) {
195
- result.push({ ...item, _matches: matched.matches } as ListItem & {
196
- _matches?: number[];
197
- });
198
- }
283
+ result.push({ ...item, _matches: matched?.matches } as ListItem & {
284
+ _matches?: number[];
285
+ });
199
286
  }
200
287
  }
201
288
  }
@@ -811,19 +898,17 @@ export function PluginsScreen() {
811
898
  latestVersion !== "0.0.0" &&
812
899
  installedVersion !== latestVersion;
813
900
 
814
- // Determine action: if installed in this scope → uninstall
815
- // If installed anywhere else but not this scope uninstall from detected scope
816
- // Otherwiseinstall
901
+ // Determine action for THIS scope:
902
+ // - installed in this scope + has update update
903
+ // - installed in this scope uninstall from this scope
904
+ // - not installed in this scope → install to this scope
817
905
  let action: "update" | "install" | "uninstall";
818
906
  if (isInstalledInScope && hasUpdateInScope) {
819
907
  action = "update";
820
908
  } else if (isInstalledInScope) {
821
909
  action = "uninstall";
822
- } else if (!isInstalledAnywhere) {
823
- action = "install";
824
910
  } else {
825
- // Installed in a different scope — uninstall from the scope it's actually in
826
- action = "uninstall";
911
+ action = "install";
827
912
  }
828
913
 
829
914
  const actionLabel =
@@ -1005,7 +1090,10 @@ export function PluginsScreen() {
1005
1090
  let statusText = "";
1006
1091
  let statusColor = "green";
1007
1092
  if (item.marketplaceEnabled) {
1008
- if (mp.name === "claude-plugins-official") {
1093
+ if (item.isCommunitySection) {
1094
+ statusText = "3rd Party";
1095
+ statusColor = "gray";
1096
+ } else if (mp.name === "claude-plugins-official") {
1009
1097
  statusText = "★ Official";
1010
1098
  statusColor = "yellow";
1011
1099
  } else if (mp.name === "claude-code-plugins") {
@@ -1050,17 +1138,13 @@ export function PluginsScreen() {
1050
1138
 
1051
1139
  if (item.type === "plugin" && item.plugin) {
1052
1140
  const plugin = item.plugin;
1053
- let statusIcon = "○";
1054
- let statusColor = "gray";
1055
-
1056
1141
  const isAnyScope = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
1057
- if (plugin.isOrphaned) {
1058
- statusIcon = "x";
1059
- statusColor = "red";
1060
- } else if (isAnyScope) {
1061
- statusIcon = "●";
1062
- statusColor = "green";
1063
- }
1142
+
1143
+ // Build scope parts for colored rendering
1144
+ const hasUser = plugin.userScope?.enabled;
1145
+ const hasProject = plugin.projectScope?.enabled;
1146
+ const hasLocal = plugin.localScope?.enabled;
1147
+ const hasAnyScope = hasUser || hasProject || hasLocal;
1064
1148
 
1065
1149
  // Build version string
1066
1150
  let versionStr = "";
@@ -1078,15 +1162,14 @@ export function PluginsScreen() {
1078
1162
  const segments = matches ? highlightMatches(plugin.name, matches) : null;
1079
1163
 
1080
1164
  if (isSelected) {
1081
- const displayText = ` ${statusIcon} ${plugin.name}${versionStr} `;
1165
+ const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}${hasLocal ? "l" : "."}]`;
1082
1166
  return (
1083
1167
  <text bg="magenta" fg="white">
1084
- {displayText}
1168
+ {" "}{scopeStr} {plugin.name}{versionStr}{" "}
1085
1169
  </text>
1086
1170
  );
1087
1171
  }
1088
1172
 
1089
- // For non-selected, render with colors
1090
1173
  const displayName = segments
1091
1174
  ? segments.map((seg) => seg.text).join("")
1092
1175
  : plugin.name;
@@ -1096,7 +1179,7 @@ export function PluginsScreen() {
1096
1179
  ? ` v${plugin.installedVersion}` : "";
1097
1180
  return (
1098
1181
  <text>
1099
- <span fg="red">{" "}{statusIcon} </span>
1182
+ <span fg="red"> [x..] </span>
1100
1183
  <span fg="gray">{displayName}</span>
1101
1184
  {ver && <span fg="yellow">{ver}</span>}
1102
1185
  <span fg="red"> deprecated</span>
@@ -1106,11 +1189,13 @@ export function PluginsScreen() {
1106
1189
 
1107
1190
  return (
1108
1191
  <text>
1109
- <span fg={statusColor}>
1110
- {" "}
1111
- {statusIcon}{" "}
1112
- </span>
1113
- <span>{displayName}</span>
1192
+ <span fg="#555555"> [</span>
1193
+ <span fg={hasUser ? "cyan" : "#555555"}>{hasUser ? "u" : "."}</span>
1194
+ <span fg={hasProject ? "green" : "#555555"}>{hasProject ? "p" : "."}</span>
1195
+ <span fg={hasLocal ? "yellow" : "#555555"}>{hasLocal ? "l" : "."}</span>
1196
+ <span fg="#555555">]</span>
1197
+ <span> </span>
1198
+ <span fg={hasAnyScope ? "white" : "gray"}>{displayName}</span>
1114
1199
  <span fg={plugin.hasUpdate ? "yellow" : "gray"}>{versionStr}</span>
1115
1200
  </text>
1116
1201
  );
@@ -1131,6 +1216,7 @@ export function PluginsScreen() {
1131
1216
 
1132
1217
  // Get appropriate badge for marketplace type
1133
1218
  const getBadge = () => {
1219
+ if (selectedItem.isCommunitySection) return " 3rd Party";
1134
1220
  if (mp.name === "claude-plugins-official") return " ★";
1135
1221
  if (mp.name === "claude-code-plugins") return " ⚠";
1136
1222
  if (mp.official) return " ★";
@@ -229,21 +229,15 @@ export function SkillsScreen() {
229
229
  try {
230
230
  await installSkill(selectedSkill, scope, state.projectPath);
231
231
  modal.hideModal();
232
- dispatch({
233
- type: "SKILLS_UPDATE_ITEM",
234
- name: selectedSkill.name,
235
- updates: {
236
- installed: true,
237
- installedScope: scope,
238
- },
239
- });
232
+ // Refetch to pick up install status for all skills including recommended
233
+ await fetchData();
240
234
  await modal.message("Installed", `${selectedSkill.name} installed to ${scope === "user" ? "~/.claude/skills/" : ".claude/skills/"}`, "success");
241
235
  }
242
236
  catch (error) {
243
237
  modal.hideModal();
244
238
  await modal.message("Error", `Failed to install: ${error}`, "error");
245
239
  }
246
- }, [selectedSkill, state.projectPath, dispatch, modal]);
240
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
247
241
  // Uninstall handler
248
242
  const handleUninstall = useCallback(async () => {
249
243
  if (!selectedSkill || !selectedSkill.installed)
@@ -258,21 +252,15 @@ export function SkillsScreen() {
258
252
  try {
259
253
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
260
254
  modal.hideModal();
261
- dispatch({
262
- type: "SKILLS_UPDATE_ITEM",
263
- name: selectedSkill.name,
264
- updates: {
265
- installed: false,
266
- installedScope: null,
267
- },
268
- });
255
+ // Refetch to pick up uninstall status
256
+ await fetchData();
269
257
  await modal.message("Uninstalled", `${selectedSkill.name} removed.`, "success");
270
258
  }
271
259
  catch (error) {
272
260
  modal.hideModal();
273
261
  await modal.message("Error", `Failed to uninstall: ${error}`, "error");
274
262
  }
275
- }, [selectedSkill, state.projectPath, dispatch, modal]);
263
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
276
264
  // Keyboard handling — same pattern as PluginsScreen
277
265
  useKeyboard((event) => {
278
266
  if (state.modal)
@@ -374,14 +362,15 @@ export function SkillsScreen() {
374
362
  }
375
363
  if (item.type === "skill" && item.skill) {
376
364
  const skill = item.skill;
377
- const indicator = skill.installed ? "●" : "○";
378
- const indicatorColor = skill.installed ? "cyan" : "gray";
379
- const scopeTag = skill.installedScope === "user" ? "u" : skill.installedScope === "project" ? "p" : "";
380
365
  const starsStr = formatStars(skill.stars);
366
+ const hasUser = skill.installedScope === "user";
367
+ const hasProject = skill.installedScope === "project";
368
+ const nameColor = skill.installed ? "white" : "gray";
381
369
  if (isSelected) {
382
- return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", indicator, " ", skill.name, skill.hasUpdate ? "" : "", scopeTag ? ` [${scopeTag}]` : "", starsStr ? ` ${starsStr}` : "", " "] }));
370
+ const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}]`;
371
+ return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", scopeStr, " ", skill.name, skill.hasUpdate ? " ⬆" : "", starsStr ? ` ${starsStr}` : "", " "] }));
383
372
  }
384
- return (_jsxs("text", { children: [_jsxs("span", { fg: indicatorColor, children: [" ", indicator, " "] }), _jsx("span", { fg: "white", children: skill.name }), skill.hasUpdate && _jsx("span", { fg: "yellow", children: " \u2B06" }), scopeTag && (_jsxs("span", { fg: scopeTag === "u" ? "cyan" : "green", children: [" [", scopeTag, "]"] })), starsStr && (_jsxs("span", { fg: "yellow", children: [" ", starsStr] }))] }));
373
+ return (_jsxs("text", { children: [_jsx("span", { fg: "#555555", children: " [" }), _jsx("span", { fg: hasUser ? "cyan" : "#555555", children: hasUser ? "u" : "." }), _jsx("span", { fg: hasProject ? "green" : "#555555", children: hasProject ? "p" : "." }), _jsx("span", { fg: "#555555", children: "] " }), _jsx("span", { fg: nameColor, children: skill.name }), skill.hasUpdate && _jsx("span", { fg: "yellow", children: " \u2B06" }), starsStr && (_jsxs("span", { fg: "yellow", children: [" ", starsStr] }))] }));
385
374
  }
386
375
  return _jsx("text", { fg: "gray", children: item.label });
387
376
  };
@@ -5,6 +5,7 @@ import { useKeyboard } from "../hooks/useKeyboard.js";
5
5
  import { ScreenLayout } from "../components/layout/index.js";
6
6
  import { ScrollableList } from "../components/ScrollableList.js";
7
7
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
8
+ import { scopeIndicatorText } from "../components/ScopeIndicator.js";
8
9
  import {
9
10
  fetchAvailableSkills,
10
11
  fetchSkillFrontmatter,
@@ -271,14 +272,8 @@ export function SkillsScreen() {
271
272
  try {
272
273
  await installSkill(selectedSkill, scope, state.projectPath);
273
274
  modal.hideModal();
274
- dispatch({
275
- type: "SKILLS_UPDATE_ITEM",
276
- name: selectedSkill.name,
277
- updates: {
278
- installed: true,
279
- installedScope: scope,
280
- },
281
- });
275
+ // Refetch to pick up install status for all skills including recommended
276
+ await fetchData();
282
277
  await modal.message(
283
278
  "Installed",
284
279
  `${selectedSkill.name} installed to ${scope === "user" ? "~/.claude/skills/" : ".claude/skills/"}`,
@@ -288,7 +283,7 @@ export function SkillsScreen() {
288
283
  modal.hideModal();
289
284
  await modal.message("Error", `Failed to install: ${error}`, "error");
290
285
  }
291
- }, [selectedSkill, state.projectPath, dispatch, modal]);
286
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
292
287
 
293
288
  // Uninstall handler
294
289
  const handleUninstall = useCallback(async () => {
@@ -307,20 +302,14 @@ export function SkillsScreen() {
307
302
  try {
308
303
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
309
304
  modal.hideModal();
310
- dispatch({
311
- type: "SKILLS_UPDATE_ITEM",
312
- name: selectedSkill.name,
313
- updates: {
314
- installed: false,
315
- installedScope: null,
316
- },
317
- });
305
+ // Refetch to pick up uninstall status
306
+ await fetchData();
318
307
  await modal.message("Uninstalled", `${selectedSkill.name} removed.`, "success");
319
308
  } catch (error) {
320
309
  modal.hideModal();
321
310
  await modal.message("Error", `Failed to uninstall: ${error}`, "error");
322
311
  }
323
- }, [selectedSkill, state.projectPath, dispatch, modal]);
312
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
324
313
 
325
314
  // Keyboard handling — same pattern as PluginsScreen
326
315
  useKeyboard((event) => {
@@ -437,27 +426,28 @@ export function SkillsScreen() {
437
426
 
438
427
  if (item.type === "skill" && item.skill) {
439
428
  const skill = item.skill;
440
- const indicator = skill.installed ? "●" : "○";
441
- const indicatorColor = skill.installed ? "cyan" : "gray";
442
- const scopeTag = skill.installedScope === "user" ? "u" : skill.installedScope === "project" ? "p" : "";
443
429
  const starsStr = formatStars(skill.stars);
430
+ const hasUser = skill.installedScope === "user";
431
+ const hasProject = skill.installedScope === "project";
432
+ const nameColor = skill.installed ? "white" : "gray";
444
433
 
445
434
  if (isSelected) {
435
+ const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}]`;
446
436
  return (
447
437
  <text bg="magenta" fg="white">
448
- {" "}{indicator} {skill.name}{skill.hasUpdate ? " ⬆" : ""}{scopeTag ? ` [${scopeTag}]` : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
438
+ {" "}{scopeStr} {skill.name}{skill.hasUpdate ? " ⬆" : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
449
439
  </text>
450
440
  );
451
441
  }
452
442
 
453
443
  return (
454
444
  <text>
455
- <span fg={indicatorColor}> {indicator} </span>
456
- <span fg="white">{skill.name}</span>
445
+ <span fg="#555555"> [</span>
446
+ <span fg={hasUser ? "cyan" : "#555555"}>{hasUser ? "u" : "."}</span>
447
+ <span fg={hasProject ? "green" : "#555555"}>{hasProject ? "p" : "."}</span>
448
+ <span fg="#555555">] </span>
449
+ <span fg={nameColor}>{skill.name}</span>
457
450
  {skill.hasUpdate && <span fg="yellow"> ⬆</span>}
458
- {scopeTag && (
459
- <span fg={scopeTag === "u" ? "cyan" : "green"}> [{scopeTag}]</span>
460
- )}
461
451
  {starsStr && (
462
452
  <span fg="yellow">{" "}{starsStr}</span>
463
453
  )}