claudeup 3.12.0 → 3.14.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.12.0",
3
+ "version": "3.14.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
+ }
@@ -111,15 +111,15 @@ export function PluginsScreen() {
111
111
  if (communityPlugins.length > 0) {
112
112
  const communityVirtualMp = {
113
113
  name: COMMUNITY_VIRTUAL_MARKETPLACE,
114
- displayName: "Community",
114
+ displayName: "Anthropic Official — 3rd Party",
115
115
  source: marketplace.source,
116
- description: "Third-party plugins from the community",
116
+ description: "Third-party plugins in the Anthropic Official marketplace",
117
117
  };
118
118
  const communityCollapsed = collapsed.has(COMMUNITY_VIRTUAL_MARKETPLACE);
119
119
  items.push({
120
120
  id: `mp:${COMMUNITY_VIRTUAL_MARKETPLACE}`,
121
121
  type: "category",
122
- label: "Community",
122
+ label: "Anthropic Official — 3rd Party",
123
123
  marketplace: communityVirtualMp,
124
124
  marketplaceEnabled: true,
125
125
  pluginCount: communityPlugins.length,
@@ -708,9 +708,10 @@ export function PluginsScreen() {
708
708
  installedVersion &&
709
709
  latestVersion !== "0.0.0" &&
710
710
  installedVersion !== latestVersion;
711
- // Determine action: if installed in this scope → uninstall
712
- // If installed anywhere else but not this scope uninstall from detected scope
713
- // 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
714
715
  let action;
715
716
  if (isInstalledInScope && hasUpdateInScope) {
716
717
  action = "update";
@@ -718,12 +719,8 @@ export function PluginsScreen() {
718
719
  else if (isInstalledInScope) {
719
720
  action = "uninstall";
720
721
  }
721
- else if (!isInstalledAnywhere) {
722
- action = "install";
723
- }
724
722
  else {
725
- // Installed in a different scope — uninstall from the scope it's actually in
726
- action = "uninstall";
723
+ action = "install";
727
724
  }
728
725
  const actionLabel = action === "update"
729
726
  ? `Updating ${scopeLabel}`
@@ -883,17 +880,12 @@ export function PluginsScreen() {
883
880
  }
884
881
  if (item.type === "plugin" && item.plugin) {
885
882
  const plugin = item.plugin;
886
- let statusIcon = "○";
887
- let statusColor = "gray";
888
883
  const isAnyScope = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
889
- if (plugin.isOrphaned) {
890
- statusIcon = "x";
891
- statusColor = "red";
892
- }
893
- else if (isAnyScope) {
894
- statusIcon = "●";
895
- statusColor = "green";
896
- }
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;
897
889
  // Build version string
898
890
  let versionStr = "";
899
891
  if (plugin.isOrphaned) {
@@ -909,19 +901,17 @@ export function PluginsScreen() {
909
901
  const matches = item._matches;
910
902
  const segments = matches ? highlightMatches(plugin.name, matches) : null;
911
903
  if (isSelected) {
912
- const displayText = ` ${statusIcon} ${plugin.name}${versionStr} `;
913
- return (_jsx("text", { bg: "magenta", fg: "white", children: displayText }));
904
+ return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", _jsx("span", { children: hasUser ? "■" : "□" }), _jsx("span", { children: hasProject ? "■" : "□" }), _jsx("span", { children: hasLocal ? "■" : "□" }), " ", plugin.name, versionStr, " "] }));
914
905
  }
915
- // For non-selected, render with colors
916
906
  const displayName = segments
917
907
  ? segments.map((seg) => seg.text).join("")
918
908
  : plugin.name;
919
909
  if (plugin.isOrphaned) {
920
910
  const ver = plugin.installedVersion && plugin.installedVersion !== "0.0.0"
921
911
  ? ` v${plugin.installedVersion}` : "";
922
- 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" })] }));
912
+ return (_jsxs("text", { children: [_jsx("span", { fg: "red", children: " \u25A0\u25A0\u25A0 " }), _jsx("span", { fg: "gray", children: displayName }), ver && _jsx("span", { fg: "yellow", children: ver }), _jsx("span", { fg: "red", children: " deprecated" })] }));
923
913
  }
924
- return (_jsxs("text", { children: [_jsxs("span", { fg: statusColor, children: [" ", statusIcon, " "] }), _jsx("span", { children: displayName }), _jsx("span", { fg: plugin.hasUpdate ? "yellow" : "gray", children: versionStr })] }));
914
+ return (_jsxs("text", { children: [_jsx("span", { children: " " }), _jsx("span", { fg: hasUser ? "cyan" : "#333333", children: "\u25A0" }), _jsx("span", { fg: hasProject ? "green" : "#333333", children: "\u25A0" }), _jsx("span", { fg: hasLocal ? "yellow" : "#333333", children: "\u25A0" }), _jsx("span", { children: " " }), _jsx("span", { fg: hasAnyScope ? "white" : "gray", children: displayName }), _jsx("span", { fg: plugin.hasUpdate ? "yellow" : "gray", children: versionStr })] }));
925
915
  }
926
916
  return _jsx("text", { fg: "gray", children: item.label });
927
917
  };
@@ -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 {
@@ -171,15 +172,15 @@ export function PluginsScreen() {
171
172
  if (communityPlugins.length > 0) {
172
173
  const communityVirtualMp: Marketplace = {
173
174
  name: COMMUNITY_VIRTUAL_MARKETPLACE,
174
- displayName: "Community",
175
+ displayName: "Anthropic Official — 3rd Party",
175
176
  source: marketplace.source,
176
- description: "Third-party plugins from the community",
177
+ description: "Third-party plugins in the Anthropic Official marketplace",
177
178
  };
178
179
  const communityCollapsed = collapsed.has(COMMUNITY_VIRTUAL_MARKETPLACE);
179
180
  items.push({
180
181
  id: `mp:${COMMUNITY_VIRTUAL_MARKETPLACE}`,
181
182
  type: "category",
182
- label: "Community",
183
+ label: "Anthropic Official — 3rd Party",
183
184
  marketplace: communityVirtualMp,
184
185
  marketplaceEnabled: true,
185
186
  pluginCount: communityPlugins.length,
@@ -897,19 +898,17 @@ export function PluginsScreen() {
897
898
  latestVersion !== "0.0.0" &&
898
899
  installedVersion !== latestVersion;
899
900
 
900
- // Determine action: if installed in this scope → uninstall
901
- // If installed anywhere else but not this scope uninstall from detected scope
902
- // 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
903
905
  let action: "update" | "install" | "uninstall";
904
906
  if (isInstalledInScope && hasUpdateInScope) {
905
907
  action = "update";
906
908
  } else if (isInstalledInScope) {
907
909
  action = "uninstall";
908
- } else if (!isInstalledAnywhere) {
909
- action = "install";
910
910
  } else {
911
- // Installed in a different scope — uninstall from the scope it's actually in
912
- action = "uninstall";
911
+ action = "install";
913
912
  }
914
913
 
915
914
  const actionLabel =
@@ -1139,17 +1138,13 @@ export function PluginsScreen() {
1139
1138
 
1140
1139
  if (item.type === "plugin" && item.plugin) {
1141
1140
  const plugin = item.plugin;
1142
- let statusIcon = "○";
1143
- let statusColor = "gray";
1144
-
1145
1141
  const isAnyScope = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
1146
- if (plugin.isOrphaned) {
1147
- statusIcon = "x";
1148
- statusColor = "red";
1149
- } else if (isAnyScope) {
1150
- statusIcon = "●";
1151
- statusColor = "green";
1152
- }
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;
1153
1148
 
1154
1149
  // Build version string
1155
1150
  let versionStr = "";
@@ -1167,15 +1162,17 @@ export function PluginsScreen() {
1167
1162
  const segments = matches ? highlightMatches(plugin.name, matches) : null;
1168
1163
 
1169
1164
  if (isSelected) {
1170
- const displayText = ` ${statusIcon} ${plugin.name}${versionStr} `;
1171
1165
  return (
1172
1166
  <text bg="magenta" fg="white">
1173
- {displayText}
1167
+ {" "}
1168
+ <span>{hasUser ? "■" : "□"}</span>
1169
+ <span>{hasProject ? "■" : "□"}</span>
1170
+ <span>{hasLocal ? "■" : "□"}</span>
1171
+ {" "}{plugin.name}{versionStr}{" "}
1174
1172
  </text>
1175
1173
  );
1176
1174
  }
1177
1175
 
1178
- // For non-selected, render with colors
1179
1176
  const displayName = segments
1180
1177
  ? segments.map((seg) => seg.text).join("")
1181
1178
  : plugin.name;
@@ -1185,7 +1182,7 @@ export function PluginsScreen() {
1185
1182
  ? ` v${plugin.installedVersion}` : "";
1186
1183
  return (
1187
1184
  <text>
1188
- <span fg="red">{" "}{statusIcon} </span>
1185
+ <span fg="red"> ■■■ </span>
1189
1186
  <span fg="gray">{displayName}</span>
1190
1187
  {ver && <span fg="yellow">{ver}</span>}
1191
1188
  <span fg="red"> deprecated</span>
@@ -1195,11 +1192,12 @@ export function PluginsScreen() {
1195
1192
 
1196
1193
  return (
1197
1194
  <text>
1198
- <span fg={statusColor}>
1199
- {" "}
1200
- {statusIcon}{" "}
1201
- </span>
1202
- <span>{displayName}</span>
1195
+ <span> </span>
1196
+ <span fg={hasUser ? "cyan" : "#333333"}>■</span>
1197
+ <span fg={hasProject ? "green" : "#333333"}>■</span>
1198
+ <span fg={hasLocal ? "yellow" : "#333333"}>■</span>
1199
+ <span> </span>
1200
+ <span fg={hasAnyScope ? "white" : "gray"}>{displayName}</span>
1203
1201
  <span fg={plugin.hasUpdate ? "yellow" : "gray"}>{versionStr}</span>
1204
1202
  </text>
1205
1203
  );
@@ -1,5 +1,8 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import { useEffect, useCallback, useMemo, useState, useRef } from "react";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import fs from "fs-extra";
3
6
  import { useApp, useModal } from "../state/AppContext.js";
4
7
  import { useDimensions } from "../state/DimensionsContext.js";
5
8
  import { useKeyboard } from "../hooks/useKeyboard.js";
@@ -103,23 +106,53 @@ export function SkillsScreen() {
103
106
  clearTimeout(searchTimerRef.current);
104
107
  };
105
108
  }, [skillsState.searchQuery]);
109
+ // Scan installed skills from disk (instant, no API)
110
+ const [installedFromDisk, setInstalledFromDisk] = useState({ user: new Set(), project: new Set() });
111
+ useEffect(() => {
112
+ async function scanDisk() {
113
+ const user = new Set();
114
+ const project = new Set();
115
+ const userDir = path.join(os.homedir(), ".claude", "skills");
116
+ const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
117
+ for (const [dir, set] of [[userDir, user], [projDir, project]]) {
118
+ try {
119
+ if (await fs.pathExists(dir)) {
120
+ const entries = await fs.readdir(dir);
121
+ for (const e of entries) {
122
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md")))
123
+ set.add(e);
124
+ }
125
+ }
126
+ }
127
+ catch { /* ignore */ }
128
+ }
129
+ setInstalledFromDisk({ user, project });
130
+ }
131
+ scanDisk();
132
+ }, [state.projectPath, state.dataRefreshVersion]);
106
133
  // Static recommended skills — always available, no API needed
134
+ // Enriched with disk-based install status immediately
107
135
  const staticRecommended = useMemo(() => {
108
- return RECOMMENDED_SKILLS.map((r) => ({
109
- id: `rec:${r.repo}/${r.skillPath}`,
110
- name: r.name,
111
- description: r.description,
112
- source: { label: r.repo, repo: r.repo, skillsPath: "" },
113
- repoPath: `${r.skillPath}/SKILL.md`,
114
- gitBlobSha: "",
115
- frontmatter: null,
116
- installed: false,
117
- installedScope: null,
118
- hasUpdate: false,
119
- isRecommended: true,
120
- stars: undefined,
121
- }));
122
- }, []);
136
+ return RECOMMENDED_SKILLS.map((r) => {
137
+ const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
138
+ const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
139
+ const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
140
+ return {
141
+ id: `rec:${r.repo}/${r.skillPath}`,
142
+ name: r.name,
143
+ description: r.description,
144
+ source: { label: r.repo, repo: r.repo, skillsPath: "" },
145
+ repoPath: `${r.skillPath}/SKILL.md`,
146
+ gitBlobSha: "",
147
+ frontmatter: null,
148
+ installed: isUser || isProj,
149
+ installedScope: isProj ? "project" : isUser ? "user" : null,
150
+ hasUpdate: false,
151
+ isRecommended: true,
152
+ stars: undefined,
153
+ };
154
+ });
155
+ }, [installedFromDisk]);
123
156
  // Merge static recommended with fetched data (to get install status + stars)
124
157
  const mergedRecommended = useMemo(() => {
125
158
  if (skillsState.skills.status !== "success")
@@ -131,10 +164,58 @@ export function SkillsScreen() {
131
164
  return match || staticSkill;
132
165
  });
133
166
  }, [staticRecommended, skillsState.skills]);
134
- // Build list: recommended always shown, then search results or popular
167
+ // Build installed skills list from disk (always available)
168
+ const installedSkills = useMemo(() => {
169
+ const all = [];
170
+ for (const [scope, names] of [
171
+ ["user", installedFromDisk.user],
172
+ ["project", installedFromDisk.project],
173
+ ]) {
174
+ for (const name of names) {
175
+ // Skip if already in the list (avoid dupes)
176
+ if (all.some((s) => s.name === name))
177
+ continue;
178
+ all.push({
179
+ id: `installed:${scope}/${name}`,
180
+ name,
181
+ description: "",
182
+ source: { label: "local", repo: "local", skillsPath: "" },
183
+ repoPath: "",
184
+ gitBlobSha: "",
185
+ frontmatter: null,
186
+ installed: true,
187
+ installedScope: scope,
188
+ hasUpdate: false,
189
+ stars: undefined,
190
+ });
191
+ }
192
+ }
193
+ return all;
194
+ }, [installedFromDisk]);
195
+ // Build list: installed first, then recommended, then search/popular
135
196
  const allItems = useMemo(() => {
136
197
  const query = skillsState.searchQuery.toLowerCase();
137
198
  const items = [];
199
+ // ── INSTALLED: always shown at top (if any) ──
200
+ const installedFiltered = query
201
+ ? installedSkills.filter((s) => s.name.toLowerCase().includes(query))
202
+ : installedSkills;
203
+ if (installedFiltered.length > 0) {
204
+ items.push({
205
+ id: "cat:installed",
206
+ type: "category",
207
+ label: `Installed (${installedFiltered.length})`,
208
+ categoryKey: "installed",
209
+ });
210
+ for (const skill of installedFiltered) {
211
+ items.push({
212
+ id: `skill:${skill.id}`,
213
+ type: "skill",
214
+ label: skill.name,
215
+ skill,
216
+ });
217
+ }
218
+ }
138
219
  // ── RECOMMENDED: always shown, filtered when searching ──
139
220
  const filteredRec = query
140
221
  ? mergedRecommended.filter((s) => s.name.toLowerCase().includes(query) ||
@@ -229,21 +310,15 @@ export function SkillsScreen() {
229
310
  try {
230
311
  await installSkill(selectedSkill, scope, state.projectPath);
231
312
  modal.hideModal();
232
- dispatch({
233
- type: "SKILLS_UPDATE_ITEM",
234
- name: selectedSkill.name,
235
- updates: {
236
- installed: true,
237
- installedScope: scope,
238
- },
239
- });
313
+ // Refetch to pick up install status for all skills including recommended
314
+ await fetchData();
240
315
  await modal.message("Installed", `${selectedSkill.name} installed to ${scope === "user" ? "~/.claude/skills/" : ".claude/skills/"}`, "success");
241
316
  }
242
317
  catch (error) {
243
318
  modal.hideModal();
244
319
  await modal.message("Error", `Failed to install: ${error}`, "error");
245
320
  }
246
- }, [selectedSkill, state.projectPath, dispatch, modal]);
321
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
247
322
  // Uninstall handler
248
323
  const handleUninstall = useCallback(async () => {
249
324
  if (!selectedSkill || !selectedSkill.installed)
@@ -258,21 +333,15 @@ export function SkillsScreen() {
258
333
  try {
259
334
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
260
335
  modal.hideModal();
261
- dispatch({
262
- type: "SKILLS_UPDATE_ITEM",
263
- name: selectedSkill.name,
264
- updates: {
265
- installed: false,
266
- installedScope: null,
267
- },
268
- });
336
+ // Refetch to pick up uninstall status
337
+ await fetchData();
269
338
  await modal.message("Uninstalled", `${selectedSkill.name} removed.`, "success");
270
339
  }
271
340
  catch (error) {
272
341
  modal.hideModal();
273
342
  await modal.message("Error", `Failed to uninstall: ${error}`, "error");
274
343
  }
275
- }, [selectedSkill, state.projectPath, dispatch, modal]);
344
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
276
345
  // Keyboard handling — same pattern as PluginsScreen
277
346
  useKeyboard((event) => {
278
347
  if (state.modal)
@@ -357,6 +426,20 @@ export function SkillsScreen() {
357
426
  else if (event.name === "r") {
358
427
  fetchData();
359
428
  }
429
+ else if (event.name === "o" && selectedSkill) {
430
+ // Open in browser
431
+ const repo = selectedSkill.source.repo;
432
+ const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
433
+ if (repo && repo !== "local") {
434
+ const url = `https://github.com/${repo}/tree/main/${repoPath}`;
435
+ import("node:child_process").then(({ execSync: exec }) => {
436
+ try {
437
+ exec(`open "${url}"`);
438
+ }
439
+ catch { /* ignore */ }
440
+ });
441
+ }
442
+ }
360
443
  // "/" to enter search mode
361
444
  else if (event.name === "/") {
362
445
  dispatch({ type: "SET_SEARCHING", isSearching: true });
@@ -365,8 +448,9 @@ export function SkillsScreen() {
365
448
  const renderListItem = (item, _idx, isSelected) => {
366
449
  if (item.type === "category") {
367
450
  const isRec = item.categoryKey === "recommended";
368
- const bgColor = isRec ? "green" : "cyan";
369
- const star = isRec ? " " : "";
451
+ const isInstalled = item.categoryKey === "installed";
452
+ const bgColor = isInstalled ? "#7e57c2" : isRec ? "#2e7d32" : "#00695c";
453
+ const star = isRec ? "★ " : isInstalled ? "● " : "";
370
454
  if (isSelected) {
371
455
  return (_jsx("text", { bg: "magenta", fg: "white", children: _jsxs("strong", { children: [" ", star, item.label, " "] }) }));
372
456
  }
@@ -374,14 +458,14 @@ export function SkillsScreen() {
374
458
  }
375
459
  if (item.type === "skill" && item.skill) {
376
460
  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
461
  const starsStr = formatStars(skill.stars);
462
+ const hasUser = skill.installedScope === "user";
463
+ const hasProject = skill.installedScope === "project";
464
+ const nameColor = skill.installed ? "white" : "gray";
381
465
  if (isSelected) {
382
- return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", indicator, " ", skill.name, skill.hasUpdate ? "" : "", scopeTag ? ` [${scopeTag}]` : "", starsStr ? ` ${starsStr}` : "", " "] }));
466
+ return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", _jsx("span", { children: hasUser ? "■" : "□" }), _jsx("span", { children: hasProject ? "" : "" }), " ", skill.name, skill.hasUpdate ? " ⬆" : "", starsStr ? ` ${starsStr}` : "", " "] }));
383
467
  }
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] }))] }));
468
+ return (_jsxs("text", { children: [_jsx("span", { children: " " }), _jsx("span", { fg: hasUser ? "cyan" : "#333333", children: "\u25A0" }), _jsx("span", { fg: hasProject ? "green" : "#333333", children: "\u25A0" }), _jsx("span", { children: " " }), _jsx("span", { fg: nameColor, children: skill.name }), skill.hasUpdate && _jsx("span", { fg: "yellow", children: " \u2B06" }), starsStr && (_jsxs("span", { fg: "yellow", children: [" ", starsStr] }))] }));
385
469
  }
386
470
  return _jsx("text", { fg: "gray", children: item.label });
387
471
  };
@@ -425,6 +509,6 @@ export function SkillsScreen() {
425
509
  }
426
510
  : undefined, footerHints: isSearchActive
427
511
  ? "type to filter │ Enter:done │ Esc:clear"
428
- : "u:user │ p:project │ d:uninstall │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: selectableItems, selectedIndex: skillsState.selectedIndex, renderItem: renderListItem, maxHeight: dimensions.listPanelHeight }), !query && skillsState.skills.status === "loading" && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsx("text", { fg: "yellow", children: "Loading popular skills..." }) })), query.length >= 2 && isSearchLoading && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsxs("text", { fg: "yellow", children: ["Searching for \"", skillsState.searchQuery, "\"..."] }) })), query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderDetail() }));
512
+ : "u:user │ p:project │ o:open │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: selectableItems, selectedIndex: skillsState.selectedIndex, renderItem: renderListItem, maxHeight: dimensions.listPanelHeight }), !query && skillsState.skills.status === "loading" && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsx("text", { fg: "yellow", children: "Loading popular skills..." }) })), query.length >= 2 && isSearchLoading && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsxs("text", { fg: "yellow", children: ["Searching for \"", skillsState.searchQuery, "\"..."] }) })), query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderDetail() }));
429
513
  }
430
514
  export default SkillsScreen;
@@ -1,10 +1,14 @@
1
1
  import React, { useEffect, useCallback, useMemo, useState, useRef } from "react";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
2
5
  import { useApp, useModal } from "../state/AppContext.js";
3
6
  import { useDimensions } from "../state/DimensionsContext.js";
4
7
  import { useKeyboard } from "../hooks/useKeyboard.js";
5
8
  import { ScreenLayout } from "../components/layout/index.js";
6
9
  import { ScrollableList } from "../components/ScrollableList.js";
7
10
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
11
+ import { scopeIndicatorText } from "../components/ScopeIndicator.js";
8
12
  import {
9
13
  fetchAvailableSkills,
10
14
  fetchSkillFrontmatter,
@@ -124,23 +128,56 @@ export function SkillsScreen() {
124
128
  };
125
129
  }, [skillsState.searchQuery]);
126
130
 
131
+ // Scan installed skills from disk (instant, no API)
132
+ const [installedFromDisk, setInstalledFromDisk] = useState<{
133
+ user: Set<string>;
134
+ project: Set<string>;
135
+ }>({ user: new Set(), project: new Set() });
136
+
137
+ useEffect(() => {
138
+ async function scanDisk() {
139
+ const user = new Set<string>();
140
+ const project = new Set<string>();
141
+ const userDir = path.join(os.homedir(), ".claude", "skills");
142
+ const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
143
+ for (const [dir, set] of [[userDir, user], [projDir, project]] as const) {
144
+ try {
145
+ if (await fs.pathExists(dir)) {
146
+ const entries = await fs.readdir(dir);
147
+ for (const e of entries) {
148
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
149
+ }
150
+ }
151
+ } catch { /* ignore */ }
152
+ }
153
+ setInstalledFromDisk({ user, project });
154
+ }
155
+ scanDisk();
156
+ }, [state.projectPath, state.dataRefreshVersion]);
157
+
127
158
  // Static recommended skills — always available, no API needed
159
+ // Enriched with disk-based install status immediately
128
160
  const staticRecommended = useMemo((): SkillInfo[] => {
129
- return RECOMMENDED_SKILLS.map((r) => ({
130
- id: `rec:${r.repo}/${r.skillPath}`,
131
- name: r.name,
132
- description: r.description,
133
- source: { label: r.repo, repo: r.repo, skillsPath: "" },
134
- repoPath: `${r.skillPath}/SKILL.md`,
135
- gitBlobSha: "",
136
- frontmatter: null,
137
- installed: false,
138
- installedScope: null,
139
- hasUpdate: false,
140
- isRecommended: true,
141
- stars: undefined,
142
- }));
143
- }, []);
161
+ return RECOMMENDED_SKILLS.map((r) => {
162
+ const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
163
+ const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
164
+ const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
165
+ return {
166
+ id: `rec:${r.repo}/${r.skillPath}`,
167
+ name: r.name,
168
+ description: r.description,
169
+ source: { label: r.repo, repo: r.repo, skillsPath: "" },
170
+ repoPath: `${r.skillPath}/SKILL.md`,
171
+ gitBlobSha: "",
172
+ frontmatter: null,
173
+ installed: isUser || isProj,
174
+ installedScope: isProj ? "project" : isUser ? "user" : null,
175
+ hasUpdate: false,
176
+ isRecommended: true,
177
+ stars: undefined,
178
+ };
179
+ });
180
+ }, [installedFromDisk]);
144
181
 
145
182
  // Merge static recommended with fetched data (to get install status + stars)
146
183
  const mergedRecommended = useMemo((): SkillInfo[] => {
@@ -155,11 +192,61 @@ export function SkillsScreen() {
155
192
  });
156
193
  }, [staticRecommended, skillsState.skills]);
157
194
 
158
- // Build list: recommended always shown, then search results or popular
195
+ // Build installed skills list from disk (always available)
196
+ const installedSkills = useMemo((): SkillInfo[] => {
197
+ const all: SkillInfo[] = [];
198
+ for (const [scope, names] of [
199
+ ["user", installedFromDisk.user],
200
+ ["project", installedFromDisk.project],
201
+ ] as const) {
202
+ for (const name of names) {
203
+ // Skip if already in the list (avoid dupes)
204
+ if (all.some((s) => s.name === name)) continue;
205
+ all.push({
206
+ id: `installed:${scope}/${name}`,
207
+ name,
208
+ description: "",
209
+ source: { label: "local", repo: "local", skillsPath: "" },
210
+ repoPath: "",
211
+ gitBlobSha: "",
212
+ frontmatter: null,
213
+ installed: true,
214
+ installedScope: scope,
215
+ hasUpdate: false,
216
+ stars: undefined,
217
+ });
218
+ }
219
+ }
220
+ return all;
221
+ }, [installedFromDisk]);
222
+
223
+ // Build list: installed first, then recommended, then search/popular
159
224
  const allItems = useMemo((): SkillListItem[] => {
160
225
  const query = skillsState.searchQuery.toLowerCase();
161
226
  const items: SkillListItem[] = [];
162
227
 
228
+ // ── INSTALLED: always shown at top (if any) ──
229
+ const installedFiltered = query
230
+ ? installedSkills.filter((s) => s.name.toLowerCase().includes(query))
231
+ : installedSkills;
232
+
233
+ if (installedFiltered.length > 0) {
234
+ items.push({
235
+ id: "cat:installed",
236
+ type: "category",
237
+ label: `Installed (${installedFiltered.length})`,
238
+ categoryKey: "installed",
239
+ });
240
+ for (const skill of installedFiltered) {
241
+ items.push({
242
+ id: `skill:${skill.id}`,
243
+ type: "skill",
244
+ label: skill.name,
245
+ skill,
246
+ });
247
+ }
248
+ }
249
+
163
250
  // ── RECOMMENDED: always shown, filtered when searching ──
164
251
  const filteredRec = query
165
252
  ? mergedRecommended.filter(
@@ -271,14 +358,8 @@ export function SkillsScreen() {
271
358
  try {
272
359
  await installSkill(selectedSkill, scope, state.projectPath);
273
360
  modal.hideModal();
274
- dispatch({
275
- type: "SKILLS_UPDATE_ITEM",
276
- name: selectedSkill.name,
277
- updates: {
278
- installed: true,
279
- installedScope: scope,
280
- },
281
- });
361
+ // Refetch to pick up install status for all skills including recommended
362
+ await fetchData();
282
363
  await modal.message(
283
364
  "Installed",
284
365
  `${selectedSkill.name} installed to ${scope === "user" ? "~/.claude/skills/" : ".claude/skills/"}`,
@@ -288,7 +369,7 @@ export function SkillsScreen() {
288
369
  modal.hideModal();
289
370
  await modal.message("Error", `Failed to install: ${error}`, "error");
290
371
  }
291
- }, [selectedSkill, state.projectPath, dispatch, modal]);
372
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
292
373
 
293
374
  // Uninstall handler
294
375
  const handleUninstall = useCallback(async () => {
@@ -307,20 +388,14 @@ export function SkillsScreen() {
307
388
  try {
308
389
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
309
390
  modal.hideModal();
310
- dispatch({
311
- type: "SKILLS_UPDATE_ITEM",
312
- name: selectedSkill.name,
313
- updates: {
314
- installed: false,
315
- installedScope: null,
316
- },
317
- });
391
+ // Refetch to pick up uninstall status
392
+ await fetchData();
318
393
  await modal.message("Uninstalled", `${selectedSkill.name} removed.`, "success");
319
394
  } catch (error) {
320
395
  modal.hideModal();
321
396
  await modal.message("Error", `Failed to uninstall: ${error}`, "error");
322
397
  }
323
- }, [selectedSkill, state.projectPath, dispatch, modal]);
398
+ }, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
324
399
 
325
400
  // Keyboard handling — same pattern as PluginsScreen
326
401
  useKeyboard((event) => {
@@ -404,6 +479,16 @@ export function SkillsScreen() {
404
479
  handleUninstall();
405
480
  } else if (event.name === "r") {
406
481
  fetchData();
482
+ } else if (event.name === "o" && selectedSkill) {
483
+ // Open in browser
484
+ const repo = selectedSkill.source.repo;
485
+ const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
486
+ if (repo && repo !== "local") {
487
+ const url = `https://github.com/${repo}/tree/main/${repoPath}`;
488
+ import("node:child_process").then(({ execSync: exec }) => {
489
+ try { exec(`open "${url}"`); } catch { /* ignore */ }
490
+ });
491
+ }
407
492
  }
408
493
  // "/" to enter search mode
409
494
  else if (event.name === "/") {
@@ -418,8 +503,9 @@ export function SkillsScreen() {
418
503
  ) => {
419
504
  if (item.type === "category") {
420
505
  const isRec = item.categoryKey === "recommended";
421
- const bgColor = isRec ? "green" : "cyan";
422
- const star = isRec ? " " : "";
506
+ const isInstalled = item.categoryKey === "installed";
507
+ const bgColor = isInstalled ? "#7e57c2" : isRec ? "#2e7d32" : "#00695c";
508
+ const star = isRec ? "★ " : isInstalled ? "● " : "";
423
509
 
424
510
  if (isSelected) {
425
511
  return (
@@ -437,27 +523,30 @@ export function SkillsScreen() {
437
523
 
438
524
  if (item.type === "skill" && item.skill) {
439
525
  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
526
  const starsStr = formatStars(skill.stars);
527
+ const hasUser = skill.installedScope === "user";
528
+ const hasProject = skill.installedScope === "project";
529
+ const nameColor = skill.installed ? "white" : "gray";
444
530
 
445
531
  if (isSelected) {
446
532
  return (
447
533
  <text bg="magenta" fg="white">
448
- {" "}{indicator} {skill.name}{skill.hasUpdate ? " ⬆" : ""}{scopeTag ? ` [${scopeTag}]` : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
534
+ {" "}
535
+ <span>{hasUser ? "■" : "□"}</span>
536
+ <span>{hasProject ? "■" : "□"}</span>
537
+ {" "}{skill.name}{skill.hasUpdate ? " ⬆" : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
449
538
  </text>
450
539
  );
451
540
  }
452
541
 
453
542
  return (
454
543
  <text>
455
- <span fg={indicatorColor}> {indicator} </span>
456
- <span fg="white">{skill.name}</span>
544
+ <span> </span>
545
+ <span fg={hasUser ? "cyan" : "#333333"}>■</span>
546
+ <span fg={hasProject ? "green" : "#333333"}>■</span>
547
+ <span> </span>
548
+ <span fg={nameColor}>{skill.name}</span>
457
549
  {skill.hasUpdate && <span fg="yellow"> ⬆</span>}
458
- {scopeTag && (
459
- <span fg={scopeTag === "u" ? "cyan" : "green"}> [{scopeTag}]</span>
460
- )}
461
550
  {starsStr && (
462
551
  <span fg="yellow">{" "}{starsStr}</span>
463
552
  )}
@@ -694,7 +783,7 @@ export function SkillsScreen() {
694
783
  }
695
784
  footerHints={isSearchActive
696
785
  ? "type to filter │ Enter:done │ Esc:clear"
697
- : "u:user │ p:project │ d:uninstall │ /:search"
786
+ : "u:user │ p:project │ o:open │ /:search"
698
787
  }
699
788
  listPanel={
700
789
  <box flexDirection="column">