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 +1 -1
- package/src/services/skills-manager.js +17 -6
- package/src/services/skills-manager.ts +21 -6
- package/src/ui/components/ScopeIndicator.js +30 -0
- package/src/ui/components/ScopeIndicator.tsx +57 -0
- package/src/ui/screens/PluginsScreen.js +16 -26
- package/src/ui/screens/PluginsScreen.tsx +27 -29
- package/src/ui/screens/SkillsScreen.js +126 -42
- package/src/ui/screens/SkillsScreen.tsx +135 -46
package/package.json
CHANGED
|
@@ -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
|
|
169
|
-
const
|
|
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(),
|
|
261
|
-
: path.join(getProjectSkillsDir(projectPath),
|
|
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(),
|
|
268
|
-
: path.join(getProjectSkillsDir(projectPath),
|
|
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
|
|
233
|
-
const
|
|
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(),
|
|
340
|
-
: path.join(getProjectSkillsDir(projectPath),
|
|
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(),
|
|
354
|
-
: path.join(getProjectSkillsDir(projectPath),
|
|
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: "
|
|
114
|
+
displayName: "Anthropic Official — 3rd Party",
|
|
115
115
|
source: marketplace.source,
|
|
116
|
-
description: "Third-party plugins
|
|
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: "
|
|
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
|
|
712
|
-
//
|
|
713
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
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: [
|
|
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: [
|
|
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: "
|
|
175
|
+
displayName: "Anthropic Official — 3rd Party",
|
|
175
176
|
source: marketplace.source,
|
|
176
|
-
description: "Third-party plugins
|
|
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: "
|
|
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
|
|
901
|
-
//
|
|
902
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
{
|
|
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">
|
|
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
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
<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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
369
|
-
const
|
|
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: [" ",
|
|
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: [
|
|
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 │
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
422
|
-
const
|
|
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
|
-
{" "}
|
|
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
|
|
456
|
-
<span fg="
|
|
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 │
|
|
786
|
+
: "u:user │ p:project │ o:open │ /:search"
|
|
698
787
|
}
|
|
699
788
|
listPanel={
|
|
700
789
|
<box flexDirection="column">
|