claudeup 4.5.2 → 4.5.3

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": "4.5.2",
3
+ "version": "4.5.3",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -50,12 +50,12 @@ function pluginRow(item, isSelected) {
50
50
  const hasProject = !!plugin.projectScope?.enabled;
51
51
  const hasLocal = !!plugin.localScope?.enabled;
52
52
  const hasAnyScope = hasUser || hasProject || hasLocal;
53
- // Build version string
53
+ // Build version string — only show if plugin is installed in at least one scope
54
54
  let versionStr = "";
55
55
  if (plugin.isOrphaned) {
56
56
  versionStr = " deprecated";
57
57
  }
58
- else if (plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
58
+ else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
59
59
  versionStr = ` v${plugin.installedVersion}`;
60
60
  if (plugin.hasUpdate && plugin.version) {
61
61
  versionStr += ` → v${plugin.version}`;
@@ -98,8 +98,8 @@ function pluginDetail(item) {
98
98
  components.push(`${Object.keys(plugin.lspServers).length} LSP`);
99
99
  }
100
100
  const showVersion = plugin.version && plugin.version !== "0.0.0";
101
- const showInstalledVersion = plugin.installedVersion && plugin.installedVersion !== "0.0.0";
102
- return (_jsxs("box", { flexDirection: "column", children: [_jsx("box", { justifyContent: "center", children: _jsx("text", { bg: theme.selection.bg, fg: theme.selection.fg, children: _jsxs("strong", { children: [" ", plugin.name, plugin.hasUpdate ? " ⬆" : "", " "] }) }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: isInstalled ? theme.colors.success : theme.colors.muted, children: isInstalled ? "● Installed" : "○ Not installed" }) }), _jsx("box", { marginTop: 1, marginBottom: 1, children: _jsx("text", { fg: theme.colors.text, children: plugin.description }) }), showVersion ? (_jsx(KeyValueLine, { label: "Version", value: _jsxs("span", { children: [_jsxs("span", { fg: theme.colors.link, children: ["v", plugin.version] }), showInstalledVersion && plugin.installedVersion !== plugin.version ? (_jsxs("span", { children: [" (v", plugin.installedVersion, " installed)"] })) : null] }) })) : null, plugin.category ? (_jsx(KeyValueLine, { label: "Category", value: _jsx("span", { fg: theme.colors.accent, children: plugin.category }) })) : null, plugin.author ? (_jsx(KeyValueLine, { label: "Author", value: _jsx("span", { children: plugin.author.name }) })) : null, components.length > 0 ? (_jsx(KeyValueLine, { label: "Contains", value: _jsx("span", { fg: theme.colors.warning, children: components.join(" · ") }) })) : null, _jsxs(DetailSection, { children: [_jsx("text", { children: "─".repeat(24) }), _jsx("text", { children: _jsx("strong", { children: "Scopes:" }) }), _jsxs("box", { marginTop: 1, flexDirection: "column", children: [_jsxs("text", { children: [_jsxs("span", { bg: theme.scopes.user, fg: "black", children: [" ", "u", " "] }), _jsx("span", { fg: plugin.userScope?.enabled ? theme.scopes.user : theme.colors.muted, children: plugin.userScope?.enabled ? " ● " : " ○ " }), _jsx("span", { fg: theme.scopes.user, children: "User" }), _jsx("span", { children: " global" }), plugin.userScope?.version ? (_jsxs("span", { fg: theme.scopes.user, children: [" v", plugin.userScope.version] })) : null] }), _jsxs("text", { children: [_jsxs("span", { bg: theme.scopes.project, fg: "black", children: [" ", "p", " "] }), _jsx("span", { fg: plugin.projectScope?.enabled ? theme.scopes.project : theme.colors.muted, children: plugin.projectScope?.enabled ? " ● " : " ○ " }), _jsx("span", { fg: theme.scopes.project, children: "Project" }), _jsx("span", { children: " team" }), plugin.projectScope?.version ? (_jsxs("span", { fg: theme.scopes.project, children: [" v", plugin.projectScope.version] })) : null] }), _jsxs("text", { children: [_jsxs("span", { bg: theme.scopes.local, fg: "black", children: [" ", "l", " "] }), _jsx("span", { fg: plugin.localScope?.enabled ? theme.scopes.local : theme.colors.muted, children: plugin.localScope?.enabled ? " ● " : " ○ " }), _jsx("span", { fg: theme.scopes.local, children: "Local" }), _jsx("span", { children: " private" }), plugin.localScope?.version ? (_jsxs("span", { fg: theme.scopes.local, children: [" v", plugin.localScope.version] })) : null] })] })] }), isInstalled && plugin.hasUpdate ? (_jsx(ActionHints, { hints: [{ key: "U", label: `Update to v${plugin.version}`, tone: "primary" }] })) : null] }));
101
+ const showInstalledVersion = isInstalled && plugin.installedVersion && plugin.installedVersion !== "0.0.0";
102
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx("box", { justifyContent: "center", children: _jsx("text", { bg: theme.selection.bg, fg: theme.selection.fg, children: _jsxs("strong", { children: [" ", plugin.name, isInstalled && plugin.hasUpdate ? " ⬆" : "", " "] }) }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: isInstalled ? theme.colors.success : theme.colors.muted, children: isInstalled ? "● Installed" : "○ Not installed" }) }), _jsx("box", { marginTop: 1, marginBottom: 1, children: _jsx("text", { fg: theme.colors.text, children: plugin.description }) }), showVersion ? (_jsx(KeyValueLine, { label: "Version", value: _jsxs("span", { children: [_jsxs("span", { fg: theme.colors.link, children: ["v", plugin.version] }), showInstalledVersion && plugin.installedVersion !== plugin.version ? (_jsxs("span", { children: [" (v", plugin.installedVersion, " installed)"] })) : null] }) })) : null, plugin.category ? (_jsx(KeyValueLine, { label: "Category", value: _jsx("span", { fg: theme.colors.accent, children: plugin.category }) })) : null, plugin.author ? (_jsx(KeyValueLine, { label: "Author", value: _jsx("span", { children: plugin.author.name }) })) : null, components.length > 0 ? (_jsx(KeyValueLine, { label: "Contains", value: _jsx("span", { fg: theme.colors.warning, children: components.join(" · ") }) })) : null, _jsxs(DetailSection, { children: [_jsx("text", { children: "─".repeat(24) }), _jsx("text", { children: _jsx("strong", { children: "Scopes:" }) }), _jsxs("box", { marginTop: 1, flexDirection: "column", children: [_jsxs("text", { children: [_jsxs("span", { bg: theme.scopes.user, fg: "black", children: [" ", "u", " "] }), _jsx("span", { fg: plugin.userScope?.enabled ? theme.scopes.user : theme.colors.muted, children: plugin.userScope?.enabled ? " ● " : " ○ " }), _jsx("span", { fg: theme.scopes.user, children: "User" }), _jsx("span", { children: " global" }), plugin.userScope?.version ? (_jsxs("span", { fg: theme.scopes.user, children: [" v", plugin.userScope.version] })) : null] }), _jsxs("text", { children: [_jsxs("span", { bg: theme.scopes.project, fg: "black", children: [" ", "p", " "] }), _jsx("span", { fg: plugin.projectScope?.enabled ? theme.scopes.project : theme.colors.muted, children: plugin.projectScope?.enabled ? " ● " : " ○ " }), _jsx("span", { fg: theme.scopes.project, children: "Project" }), _jsx("span", { children: " team" }), plugin.projectScope?.version ? (_jsxs("span", { fg: theme.scopes.project, children: [" v", plugin.projectScope.version] })) : null] }), _jsxs("text", { children: [_jsxs("span", { bg: theme.scopes.local, fg: "black", children: [" ", "l", " "] }), _jsx("span", { fg: plugin.localScope?.enabled ? theme.scopes.local : theme.colors.muted, children: plugin.localScope?.enabled ? " ● " : " ○ " }), _jsx("span", { fg: theme.scopes.local, children: "Local" }), _jsx("span", { children: " private" }), plugin.localScope?.version ? (_jsxs("span", { fg: theme.scopes.local, children: [" v", plugin.localScope.version] })) : null] })] })] }), isInstalled && plugin.hasUpdate ? (_jsx(ActionHints, { hints: [{ key: "U", label: `Update to v${plugin.version}`, tone: "primary" }] })) : null] }));
103
103
  }
104
104
  // ─── Public dispatch functions ────────────────────────────────────────────────
105
105
  /**
@@ -113,11 +113,11 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
113
113
  const hasLocal = !!plugin.localScope?.enabled;
114
114
  const hasAnyScope = hasUser || hasProject || hasLocal;
115
115
 
116
- // Build version string
116
+ // Build version string — only show if plugin is installed in at least one scope
117
117
  let versionStr = "";
118
118
  if (plugin.isOrphaned) {
119
119
  versionStr = " deprecated";
120
- } else if (plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
120
+ } else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
121
121
  versionStr = ` v${plugin.installedVersion}`;
122
122
  if (plugin.hasUpdate && plugin.version) {
123
123
  versionStr += ` → v${plugin.version}`;
@@ -210,7 +210,7 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
210
210
 
211
211
  const showVersion = plugin.version && plugin.version !== "0.0.0";
212
212
  const showInstalledVersion =
213
- plugin.installedVersion && plugin.installedVersion !== "0.0.0";
213
+ isInstalled && plugin.installedVersion && plugin.installedVersion !== "0.0.0";
214
214
 
215
215
  return (
216
216
  <box flexDirection="column">
@@ -220,7 +220,7 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
220
220
  <strong>
221
221
  {" "}
222
222
  {plugin.name}
223
- {plugin.hasUpdate ? " ⬆" : ""}{" "}
223
+ {isInstalled && plugin.hasUpdate ? " ⬆" : ""}{" "}
224
224
  </strong>
225
225
  </text>
226
226
  </box>
@@ -643,7 +643,7 @@ export function PluginsScreen() {
643
643
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
644
644
  const plugins = pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
645
645
  const installedCount = plugins.filter((p) => p.enabled).length;
646
- const updateCount = plugins.filter((p) => p.hasUpdate).length;
646
+ const updateCount = plugins.filter((p) => p.hasUpdate && (p.userScope?.enabled || p.projectScope?.enabled || p.localScope?.enabled)).length;
647
647
  const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
648
648
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""} │ / to search`;
649
649
  return (_jsx(ScreenLayout, { title: "claudeup Plugins", subtitle: subtitle, currentScreen: "plugins", search: {
@@ -799,7 +799,7 @@ export function PluginsScreen() {
799
799
  const plugins: PluginInfo[] =
800
800
  pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
801
801
  const installedCount = plugins.filter((p) => p.enabled).length;
802
- const updateCount = plugins.filter((p) => p.hasUpdate).length;
802
+ const updateCount = plugins.filter((p) => p.hasUpdate && (p.userScope?.enabled || p.projectScope?.enabled || p.localScope?.enabled)).length;
803
803
  const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
804
804
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""} │ / to search`;
805
805
 
@@ -98,28 +98,28 @@ export function SkillsScreen() {
98
98
  }, [skillsState.searchQuery]);
99
99
  // ── Disk scan for installed skills ────────────────────────────────────────
100
100
  const [installedFromDisk, setInstalledFromDisk] = useState({ user: new Set(), project: new Set() });
101
- useEffect(() => {
102
- async function scanDisk() {
103
- const user = new Set();
104
- const project = new Set();
105
- const userDir = path.join(os.homedir(), ".claude", "skills");
106
- const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
107
- for (const [dir, set] of [[userDir, user], [projDir, project]]) {
108
- try {
109
- if (await fs.pathExists(dir)) {
110
- const entries = await fs.readdir(dir);
111
- for (const e of entries) {
112
- if (await fs.pathExists(path.join(dir, e, "SKILL.md")))
113
- set.add(e);
114
- }
101
+ const scanDisk = useCallback(async () => {
102
+ const user = new Set();
103
+ const project = new Set();
104
+ const userDir = path.join(os.homedir(), ".claude", "skills");
105
+ const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
106
+ for (const [dir, set] of [[userDir, user], [projDir, project]]) {
107
+ try {
108
+ if (await fs.pathExists(dir)) {
109
+ const entries = await fs.readdir(dir);
110
+ for (const e of entries) {
111
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md")))
112
+ set.add(e);
115
113
  }
116
114
  }
117
- catch { /* ignore */ }
118
115
  }
119
- setInstalledFromDisk({ user, project });
116
+ catch { /* ignore */ }
120
117
  }
118
+ setInstalledFromDisk({ user, project });
119
+ }, [state.projectPath]);
120
+ useEffect(() => {
121
121
  scanDisk();
122
- }, [state.projectPath, state.dataRefreshVersion]);
122
+ }, [scanDisk, state.dataRefreshVersion]);
123
123
  // ── Derived data ──────────────────────────────────────────────────────────
124
124
  const staticRecommended = useMemo(() => {
125
125
  return RECOMMENDED_SKILLS.map((r) => {
@@ -150,8 +150,15 @@ export function SkillsScreen() {
150
150
  const match = fetched.find((f) => f.source.repo === staticSkill.source.repo && f.name === staticSkill.name);
151
151
  if (!match)
152
152
  return staticSkill;
153
- // Merge: prefer fetched data but keep static stars as fallback
154
- return { ...staticSkill, ...match, stars: match.stars || staticSkill.stars };
153
+ // Merge: prefer fetched data for description/stars, but disk scan is
154
+ // authoritative for installed state (staticSkill reflects current disk)
155
+ return {
156
+ ...staticSkill,
157
+ ...match,
158
+ installed: staticSkill.installed,
159
+ installedScope: staticSkill.installedScope,
160
+ stars: match.stars || staticSkill.stars,
161
+ };
155
162
  });
156
163
  }, [staticRecommended, skillsState.skills]);
157
164
  const installedSkills = useMemo(() => {
@@ -203,6 +210,15 @@ export function SkillsScreen() {
203
210
  skillsState.searchQuery,
204
211
  isSearchLoading,
205
212
  ]);
213
+ // Auto-correct selection if it lands on a category header (e.g. initial load, reset)
214
+ useEffect(() => {
215
+ const item = allItems[skillsState.selectedIndex];
216
+ if (item && item.kind === "category") {
217
+ const firstSkill = allItems.findIndex((i) => i.kind === "skill");
218
+ if (firstSkill >= 0)
219
+ dispatch({ type: "SKILLS_SELECT", index: firstSkill });
220
+ }
221
+ }, [allItems, skillsState.selectedIndex, dispatch]);
206
222
  const selectedItem = allItems[skillsState.selectedIndex];
207
223
  const selectedSkill = selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
208
224
  // ── Lazy-load frontmatter for selected skill ───────────────────────────────
@@ -232,13 +248,13 @@ export function SkillsScreen() {
232
248
  return;
233
249
  try {
234
250
  await installSkill(selectedSkill, scope, state.projectPath);
235
- await fetchData();
251
+ await scanDisk(); // Re-scan disk only — no network re-fetch needed
236
252
  showStatus(`Installed ${selectedSkill.name} to ${scope}`);
237
253
  }
238
254
  catch (error) {
239
255
  showStatus(`Failed: ${error}`, "error");
240
256
  }
241
- }, [selectedSkill, state.projectPath, fetchData, showStatus]);
257
+ }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
242
258
  const handleUninstall = useCallback(async () => {
243
259
  if (!selectedSkill || !selectedSkill.installed)
244
260
  return;
@@ -247,13 +263,13 @@ export function SkillsScreen() {
247
263
  return;
248
264
  try {
249
265
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
250
- await fetchData();
266
+ await scanDisk(); // Re-scan disk only — no network re-fetch needed
251
267
  showStatus(`Removed ${selectedSkill.name} from ${scope}`);
252
268
  }
253
269
  catch (error) {
254
270
  showStatus(`Failed: ${error}`, "error");
255
271
  }
256
- }, [selectedSkill, state.projectPath, fetchData, showStatus]);
272
+ }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
257
273
  // ── Keyboard handling ─────────────────────────────────────────────────────
258
274
  useKeyboard((event) => {
259
275
  if (state.modal)
@@ -280,15 +296,23 @@ export function SkillsScreen() {
280
296
  if (event.name === "up" || event.name === "k") {
281
297
  if (isSearchActive)
282
298
  dispatch({ type: "SET_SEARCHING", isSearching: false });
283
- const newIndex = Math.max(0, skillsState.selectedIndex - 1);
284
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
299
+ let newIndex = skillsState.selectedIndex - 1;
300
+ // Skip category headers
301
+ while (newIndex >= 0 && allItems[newIndex]?.kind === "category")
302
+ newIndex--;
303
+ if (newIndex >= 0)
304
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
285
305
  return;
286
306
  }
287
307
  if (event.name === "down" || event.name === "j") {
288
308
  if (isSearchActive)
289
309
  dispatch({ type: "SET_SEARCHING", isSearching: false });
290
- const newIndex = Math.min(Math.max(0, allItems.length - 1), skillsState.selectedIndex + 1);
291
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
310
+ let newIndex = skillsState.selectedIndex + 1;
311
+ // Skip category headers
312
+ while (newIndex < allItems.length && allItems[newIndex]?.kind === "category")
313
+ newIndex++;
314
+ if (newIndex < allItems.length)
315
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
292
316
  return;
293
317
  }
294
318
  if (event.name === "return" || event.name === "enter") {
@@ -301,6 +325,21 @@ export function SkillsScreen() {
301
325
  }
302
326
  return;
303
327
  }
328
+ // Action keys work regardless of search mode when a skill is selected
329
+ if (event.name === "u" && selectedSkill) {
330
+ if (selectedSkill.installed && selectedSkill.installedScope === "user")
331
+ handleUninstall();
332
+ else
333
+ handleInstall("user");
334
+ return;
335
+ }
336
+ else if (event.name === "p" && selectedSkill) {
337
+ if (selectedSkill.installed && selectedSkill.installedScope === "project")
338
+ handleUninstall();
339
+ else
340
+ handleInstall("project");
341
+ return;
342
+ }
304
343
  if (isSearchActive) {
305
344
  if (event.name === "k" || event.name === "j") {
306
345
  const delta = event.name === "k" ? -1 : 1;
@@ -314,19 +353,7 @@ export function SkillsScreen() {
314
353
  }
315
354
  return;
316
355
  }
317
- if (event.name === "u" && selectedSkill) {
318
- if (selectedSkill.installed && selectedSkill.installedScope === "user")
319
- handleUninstall();
320
- else
321
- handleInstall("user");
322
- }
323
- else if (event.name === "p" && selectedSkill) {
324
- if (selectedSkill.installed && selectedSkill.installedScope === "project")
325
- handleUninstall();
326
- else
327
- handleInstall("project");
328
- }
329
- else if (event.name === "r") {
356
+ if (event.name === "r") {
330
357
  fetchData();
331
358
  }
332
359
  else if (event.name === "o" && selectedSkill) {
@@ -121,26 +121,27 @@ export function SkillsScreen() {
121
121
  project: Set<string>;
122
122
  }>({ user: new Set(), project: new Set() });
123
123
 
124
- useEffect(() => {
125
- async function scanDisk() {
126
- const user = new Set<string>();
127
- const project = new Set<string>();
128
- const userDir = path.join(os.homedir(), ".claude", "skills");
129
- const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
130
- for (const [dir, set] of [[userDir, user], [projDir, project]] as const) {
131
- try {
132
- if (await fs.pathExists(dir)) {
133
- const entries = await fs.readdir(dir);
134
- for (const e of entries) {
135
- if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
136
- }
124
+ const scanDisk = useCallback(async () => {
125
+ const user = new Set<string>();
126
+ const project = new Set<string>();
127
+ const userDir = path.join(os.homedir(), ".claude", "skills");
128
+ const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
129
+ for (const [dir, set] of [[userDir, user], [projDir, project]] as const) {
130
+ try {
131
+ if (await fs.pathExists(dir)) {
132
+ const entries = await fs.readdir(dir);
133
+ for (const e of entries) {
134
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
137
135
  }
138
- } catch { /* ignore */ }
139
- }
140
- setInstalledFromDisk({ user, project });
136
+ }
137
+ } catch { /* ignore */ }
141
138
  }
139
+ setInstalledFromDisk({ user, project });
140
+ }, [state.projectPath]);
141
+
142
+ useEffect(() => {
142
143
  scanDisk();
143
- }, [state.projectPath, state.dataRefreshVersion]);
144
+ }, [scanDisk, state.dataRefreshVersion]);
144
145
 
145
146
  // ── Derived data ──────────────────────────────────────────────────────────
146
147
 
@@ -174,8 +175,15 @@ export function SkillsScreen() {
174
175
  (f) => f.source.repo === staticSkill.source.repo && f.name === staticSkill.name,
175
176
  );
176
177
  if (!match) return staticSkill;
177
- // Merge: prefer fetched data but keep static stars as fallback
178
- return { ...staticSkill, ...match, stars: match.stars || staticSkill.stars };
178
+ // Merge: prefer fetched data for description/stars, but disk scan is
179
+ // authoritative for installed state (staticSkill reflects current disk)
180
+ return {
181
+ ...staticSkill,
182
+ ...match,
183
+ installed: staticSkill.installed,
184
+ installedScope: staticSkill.installedScope,
185
+ stars: match.stars || staticSkill.stars,
186
+ };
179
187
  });
180
188
  }, [staticRecommended, skillsState.skills]);
181
189
 
@@ -234,6 +242,15 @@ export function SkillsScreen() {
234
242
  ],
235
243
  );
236
244
 
245
+ // Auto-correct selection if it lands on a category header (e.g. initial load, reset)
246
+ useEffect(() => {
247
+ const item = allItems[skillsState.selectedIndex];
248
+ if (item && item.kind === "category") {
249
+ const firstSkill = allItems.findIndex((i) => i.kind === "skill");
250
+ if (firstSkill >= 0) dispatch({ type: "SKILLS_SELECT", index: firstSkill });
251
+ }
252
+ }, [allItems, skillsState.selectedIndex, dispatch]);
253
+
237
254
  const selectedItem = allItems[skillsState.selectedIndex];
238
255
  const selectedSkill =
239
256
  selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
@@ -266,12 +283,12 @@ export function SkillsScreen() {
266
283
  if (!selectedSkill) return;
267
284
  try {
268
285
  await installSkill(selectedSkill, scope, state.projectPath);
269
- await fetchData();
286
+ await scanDisk(); // Re-scan disk only — no network re-fetch needed
270
287
  showStatus(`Installed ${selectedSkill.name} to ${scope}`);
271
288
  } catch (error) {
272
289
  showStatus(`Failed: ${error}`, "error");
273
290
  }
274
- }, [selectedSkill, state.projectPath, fetchData, showStatus]);
291
+ }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
275
292
 
276
293
  const handleUninstall = useCallback(async () => {
277
294
  if (!selectedSkill || !selectedSkill.installed) return;
@@ -280,12 +297,12 @@ export function SkillsScreen() {
280
297
 
281
298
  try {
282
299
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
283
- await fetchData();
300
+ await scanDisk(); // Re-scan disk only — no network re-fetch needed
284
301
  showStatus(`Removed ${selectedSkill.name} from ${scope}`);
285
302
  } catch (error) {
286
303
  showStatus(`Failed: ${error}`, "error");
287
304
  }
288
- }, [selectedSkill, state.projectPath, fetchData, showStatus]);
305
+ }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
289
306
 
290
307
  // ── Keyboard handling ─────────────────────────────────────────────────────
291
308
 
@@ -315,17 +332,18 @@ export function SkillsScreen() {
315
332
 
316
333
  if (event.name === "up" || event.name === "k") {
317
334
  if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
318
- const newIndex = Math.max(0, skillsState.selectedIndex - 1);
319
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
335
+ let newIndex = skillsState.selectedIndex - 1;
336
+ // Skip category headers
337
+ while (newIndex >= 0 && allItems[newIndex]?.kind === "category") newIndex--;
338
+ if (newIndex >= 0) dispatch({ type: "SKILLS_SELECT", index: newIndex });
320
339
  return;
321
340
  }
322
341
  if (event.name === "down" || event.name === "j") {
323
342
  if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
324
- const newIndex = Math.min(
325
- Math.max(0, allItems.length - 1),
326
- skillsState.selectedIndex + 1,
327
- );
328
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
343
+ let newIndex = skillsState.selectedIndex + 1;
344
+ // Skip category headers
345
+ while (newIndex < allItems.length && allItems[newIndex]?.kind === "category") newIndex++;
346
+ if (newIndex < allItems.length) dispatch({ type: "SKILLS_SELECT", index: newIndex });
329
347
  return;
330
348
  }
331
349
 
@@ -340,6 +358,17 @@ export function SkillsScreen() {
340
358
  return;
341
359
  }
342
360
 
361
+ // Action keys work regardless of search mode when a skill is selected
362
+ if (event.name === "u" && selectedSkill) {
363
+ if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
364
+ else handleInstall("user");
365
+ return;
366
+ } else if (event.name === "p" && selectedSkill) {
367
+ if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
368
+ else handleInstall("project");
369
+ return;
370
+ }
371
+
343
372
  if (isSearchActive) {
344
373
  if (event.name === "k" || event.name === "j") {
345
374
  const delta = event.name === "k" ? -1 : 1;
@@ -354,13 +383,7 @@ export function SkillsScreen() {
354
383
  return;
355
384
  }
356
385
 
357
- if (event.name === "u" && selectedSkill) {
358
- if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
359
- else handleInstall("user");
360
- } else if (event.name === "p" && selectedSkill) {
361
- if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
362
- else handleInstall("project");
363
- } else if (event.name === "r") {
386
+ if (event.name === "r") {
364
387
  fetchData();
365
388
  } else if (event.name === "o" && selectedSkill) {
366
389
  const repo = selectedSkill.source.repo;