claudeup 4.5.4 → 4.6.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/data/skill-repos.js +56 -0
- package/src/data/skill-repos.ts +70 -1
- package/src/prerunner/index.js +12 -1
- package/src/prerunner/index.ts +14 -0
- package/src/services/claude-settings.js +128 -10
- package/src/services/claude-settings.ts +149 -9
- package/src/services/skills-manager.js +50 -2
- package/src/services/skills-manager.ts +65 -2
- package/src/types/index.ts +29 -0
- package/src/ui/adapters/skillsAdapter.js +57 -9
- package/src/ui/adapters/skillsAdapter.ts +72 -10
- package/src/ui/components/ScrollableList.js +8 -20
- package/src/ui/components/ScrollableList.tsx +16 -29
- package/src/ui/renderers/skillRenderers.js +72 -9
- package/src/ui/renderers/skillRenderers.tsx +176 -11
- package/src/ui/screens/PluginsScreen.js +1 -1
- package/src/ui/screens/PluginsScreen.tsx +1 -0
- package/src/ui/screens/SkillsScreen.js +177 -39
- package/src/ui/screens/SkillsScreen.tsx +199 -34
|
@@ -7,13 +7,13 @@ import { useApp, useModal } from "../state/AppContext.js";
|
|
|
7
7
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
8
8
|
import { useKeyboard } from "../hooks/useKeyboard.js";
|
|
9
9
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
10
|
-
import { ScrollableList } from "../components/ScrollableList.js";
|
|
11
10
|
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
12
|
-
import { fetchAvailableSkills, fetchSkillFrontmatter, installSkill, uninstallSkill, } from "../../services/skills-manager.js";
|
|
11
|
+
import { fetchAvailableSkills, fetchSkillFrontmatter, fetchSkillSetSkills, installSkill, uninstallSkill, } from "../../services/skills-manager.js";
|
|
13
12
|
import { searchSkills } from "../../services/skillsmp-client.js";
|
|
14
|
-
import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS } from "../../data/skill-repos.js";
|
|
13
|
+
import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS, RECOMMENDED_SKILL_SETS, classifyStarReliability } from "../../data/skill-repos.js";
|
|
15
14
|
import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
|
|
16
15
|
import { renderSkillRow, renderSkillDetail } from "../renderers/skillRenderers.js";
|
|
16
|
+
import { ScrollableList } from "../components/ScrollableList.js";
|
|
17
17
|
export function SkillsScreen() {
|
|
18
18
|
const { state, dispatch } = useApp();
|
|
19
19
|
const { skills: skillsState } = state;
|
|
@@ -81,6 +81,7 @@ export function SkillsScreen() {
|
|
|
81
81
|
installedScope: null,
|
|
82
82
|
hasUpdate: false,
|
|
83
83
|
stars: r.stars,
|
|
84
|
+
starReliability: classifyStarReliability(r.repo || "unknown", r.stars),
|
|
84
85
|
};
|
|
85
86
|
});
|
|
86
87
|
searchCacheRef.current.set(query, mapped);
|
|
@@ -120,6 +121,106 @@ export function SkillsScreen() {
|
|
|
120
121
|
useEffect(() => {
|
|
121
122
|
scanDisk();
|
|
122
123
|
}, [scanDisk, state.dataRefreshVersion]);
|
|
124
|
+
// ── Skill Sets state ──────────────────────────────────────────────────────
|
|
125
|
+
const [skillSets, setSkillSets] = useState(() => RECOMMENDED_SKILL_SETS.map((rs) => ({
|
|
126
|
+
id: rs.repo,
|
|
127
|
+
name: rs.name,
|
|
128
|
+
description: rs.description,
|
|
129
|
+
repo: rs.repo,
|
|
130
|
+
icon: rs.icon,
|
|
131
|
+
stars: rs.stars,
|
|
132
|
+
skills: [],
|
|
133
|
+
loaded: false,
|
|
134
|
+
loading: false,
|
|
135
|
+
})));
|
|
136
|
+
const [expandedSets, setExpandedSets] = useState(new Set());
|
|
137
|
+
// Re-mark installed status on child skills when disk state changes
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
setSkillSets((prev) => prev.map((set) => {
|
|
140
|
+
if (!set.loaded)
|
|
141
|
+
return set;
|
|
142
|
+
const updatedSkills = set.skills.map((skill) => {
|
|
143
|
+
const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
144
|
+
const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(skill.name);
|
|
145
|
+
const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(skill.name);
|
|
146
|
+
return {
|
|
147
|
+
...skill,
|
|
148
|
+
installed: isUser || isProj,
|
|
149
|
+
installedScope: isProj ? "project" : isUser ? "user" : null,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
return { ...set, skills: updatedSkills };
|
|
153
|
+
}));
|
|
154
|
+
}, [installedFromDisk]);
|
|
155
|
+
const handleToggleSet = useCallback(async (setId) => {
|
|
156
|
+
const isExpanded = expandedSets.has(setId);
|
|
157
|
+
const newExpanded = new Set(expandedSets);
|
|
158
|
+
if (isExpanded) {
|
|
159
|
+
newExpanded.delete(setId);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
newExpanded.add(setId);
|
|
163
|
+
}
|
|
164
|
+
setExpandedSets(newExpanded);
|
|
165
|
+
// Fetch skills on first expand
|
|
166
|
+
const set = skillSets.find((s) => s.id === setId);
|
|
167
|
+
if (!isExpanded && set && !set.loaded && !set.loading) {
|
|
168
|
+
setSkillSets((prev) => prev.map((s) => (s.id === setId ? { ...s, loading: true } : s)));
|
|
169
|
+
try {
|
|
170
|
+
const skills = await fetchSkillSetSkills(set.repo, state.projectPath);
|
|
171
|
+
setSkillSets((prev) => prev.map((s) => s.id === setId
|
|
172
|
+
? { ...s, skills, loaded: true, loading: false, error: undefined }
|
|
173
|
+
: s));
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
setSkillSets((prev) => prev.map((s) => s.id === setId
|
|
177
|
+
? {
|
|
178
|
+
...s,
|
|
179
|
+
loading: false,
|
|
180
|
+
error: error instanceof Error ? error.message : String(error),
|
|
181
|
+
}
|
|
182
|
+
: s));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}, [expandedSets, skillSets, state.projectPath]);
|
|
186
|
+
// Status bar message (auto-clears)
|
|
187
|
+
const [statusMsg, setStatusMsg] = useState(null);
|
|
188
|
+
const statusTimerRef = useRef(null);
|
|
189
|
+
const showStatus = useCallback((text, tone = "success") => {
|
|
190
|
+
setStatusMsg({ text, tone });
|
|
191
|
+
if (statusTimerRef.current)
|
|
192
|
+
clearTimeout(statusTimerRef.current);
|
|
193
|
+
statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
|
|
194
|
+
}, []);
|
|
195
|
+
const handleInstallAllFromSet = useCallback(async (setId, scope) => {
|
|
196
|
+
const set = skillSets.find((s) => s.id === setId);
|
|
197
|
+
if (!set || !set.loaded)
|
|
198
|
+
return;
|
|
199
|
+
const toInstall = set.skills.filter((s) => !s.installed);
|
|
200
|
+
if (toInstall.length === 0) {
|
|
201
|
+
showStatus(`All ${set.name} skills already installed`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
|
|
205
|
+
let installed = 0;
|
|
206
|
+
let failed = 0;
|
|
207
|
+
for (const skill of toInstall) {
|
|
208
|
+
try {
|
|
209
|
+
await installSkill(skill, scope, state.projectPath);
|
|
210
|
+
installed++;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
failed++;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
await scanDisk();
|
|
217
|
+
if (failed > 0) {
|
|
218
|
+
showStatus(`Installed ${installed}/${toInstall.length} (${failed} failed)`, "error");
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
showStatus(`Installed ${installed} skills from ${set.name} to ${scope}`);
|
|
222
|
+
}
|
|
223
|
+
}, [skillSets, state.projectPath, scanDisk, showStatus]);
|
|
123
224
|
// ── Derived data ──────────────────────────────────────────────────────────
|
|
124
225
|
const staticRecommended = useMemo(() => {
|
|
125
226
|
return RECOMMENDED_SKILLS.map((r) => {
|
|
@@ -139,6 +240,7 @@ export function SkillsScreen() {
|
|
|
139
240
|
hasUpdate: false,
|
|
140
241
|
isRecommended: true,
|
|
141
242
|
stars: r.stars,
|
|
243
|
+
starReliability: classifyStarReliability(r.repo, r.stars),
|
|
142
244
|
};
|
|
143
245
|
});
|
|
144
246
|
}, [installedFromDisk]);
|
|
@@ -202,6 +304,8 @@ export function SkillsScreen() {
|
|
|
202
304
|
searchResults,
|
|
203
305
|
query: skillsState.searchQuery,
|
|
204
306
|
isSearchLoading,
|
|
307
|
+
skillSets,
|
|
308
|
+
expandedSets,
|
|
205
309
|
}), [
|
|
206
310
|
mergedRecommended,
|
|
207
311
|
popularSkills,
|
|
@@ -209,40 +313,47 @@ export function SkillsScreen() {
|
|
|
209
313
|
searchResults,
|
|
210
314
|
skillsState.searchQuery,
|
|
211
315
|
isSearchLoading,
|
|
316
|
+
skillSets,
|
|
317
|
+
expandedSets,
|
|
212
318
|
]);
|
|
213
319
|
// Auto-correct selection if it lands on a category header (e.g. initial load, reset)
|
|
214
320
|
useEffect(() => {
|
|
215
321
|
const item = allItems[skillsState.selectedIndex];
|
|
216
322
|
if (item && item.kind === "category") {
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
dispatch({ type: "SKILLS_SELECT", index:
|
|
323
|
+
const firstSelectable = allItems.findIndex((i) => i.kind === "skill" || i.kind === "skillset");
|
|
324
|
+
if (firstSelectable >= 0)
|
|
325
|
+
dispatch({ type: "SKILLS_SELECT", index: firstSelectable });
|
|
220
326
|
}
|
|
221
327
|
}, [allItems, skillsState.selectedIndex, dispatch]);
|
|
222
328
|
const selectedItem = allItems[skillsState.selectedIndex];
|
|
223
329
|
const selectedSkill = selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
|
|
330
|
+
const selectedSet = selectedItem?.kind === "skillset" ? selectedItem.skillSet : undefined;
|
|
224
331
|
// ── Lazy-load frontmatter for selected skill ───────────────────────────────
|
|
332
|
+
// Check if selected skill belongs to a skill set (lives in local state, not reducer)
|
|
333
|
+
const isSkillSetChild = selectedSkill
|
|
334
|
+
? skillSets.some((s) => s.loaded && s.skills.some((sk) => sk.id === selectedSkill.id))
|
|
335
|
+
: false;
|
|
225
336
|
useEffect(() => {
|
|
226
337
|
if (!selectedSkill || selectedSkill.frontmatter)
|
|
227
338
|
return;
|
|
228
339
|
fetchSkillFrontmatter(selectedSkill).then((fm) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
340
|
+
if (isSkillSetChild) {
|
|
341
|
+
// Update the local skillSets state
|
|
342
|
+
setSkillSets((prev) => prev.map((set) => ({
|
|
343
|
+
...set,
|
|
344
|
+
skills: set.skills.map((sk) => sk.id === selectedSkill.id ? { ...sk, frontmatter: fm } : sk),
|
|
345
|
+
})));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
dispatch({
|
|
349
|
+
type: "SKILLS_UPDATE_ITEM",
|
|
350
|
+
name: selectedSkill.name,
|
|
351
|
+
updates: { frontmatter: fm },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
234
354
|
}).catch(() => { });
|
|
235
|
-
}, [selectedSkill?.id, dispatch]);
|
|
355
|
+
}, [selectedSkill?.id, isSkillSetChild, dispatch]);
|
|
236
356
|
// ── Action handlers ───────────────────────────────────────────────────────
|
|
237
|
-
// Status bar message (auto-clears)
|
|
238
|
-
const [statusMsg, setStatusMsg] = useState(null);
|
|
239
|
-
const statusTimerRef = useRef(null);
|
|
240
|
-
const showStatus = useCallback((text, tone = "success") => {
|
|
241
|
-
setStatusMsg({ text, tone });
|
|
242
|
-
if (statusTimerRef.current)
|
|
243
|
-
clearTimeout(statusTimerRef.current);
|
|
244
|
-
statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
|
|
245
|
-
}, []);
|
|
246
357
|
const handleInstall = useCallback(async (scope) => {
|
|
247
358
|
if (!selectedSkill)
|
|
248
359
|
return;
|
|
@@ -320,6 +431,10 @@ export function SkillsScreen() {
|
|
|
320
431
|
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
321
432
|
return;
|
|
322
433
|
}
|
|
434
|
+
if (selectedSet) {
|
|
435
|
+
handleToggleSet(selectedSet.id);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
323
438
|
if (selectedSkill && !selectedSkill.installed) {
|
|
324
439
|
handleInstall("project");
|
|
325
440
|
}
|
|
@@ -327,19 +442,31 @@ export function SkillsScreen() {
|
|
|
327
442
|
}
|
|
328
443
|
// Action keys only when NOT actively typing in search
|
|
329
444
|
if (!isSearchActive) {
|
|
330
|
-
if (event.name === "u"
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
445
|
+
if (event.name === "u") {
|
|
446
|
+
if (selectedSet) {
|
|
447
|
+
handleInstallAllFromSet(selectedSet.id, "user");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (selectedSkill) {
|
|
451
|
+
if (selectedSkill.installed && selectedSkill.installedScope === "user")
|
|
452
|
+
handleUninstall();
|
|
453
|
+
else
|
|
454
|
+
handleInstall("user");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
336
457
|
}
|
|
337
|
-
else if (event.name === "p"
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
458
|
+
else if (event.name === "p") {
|
|
459
|
+
if (selectedSet) {
|
|
460
|
+
handleInstallAllFromSet(selectedSet.id, "project");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (selectedSkill) {
|
|
464
|
+
if (selectedSkill.installed && selectedSkill.installedScope === "project")
|
|
465
|
+
handleUninstall();
|
|
466
|
+
else
|
|
467
|
+
handleInstall("project");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
343
470
|
}
|
|
344
471
|
}
|
|
345
472
|
if (isSearchActive) {
|
|
@@ -358,11 +485,9 @@ export function SkillsScreen() {
|
|
|
358
485
|
if (event.name === "r") {
|
|
359
486
|
fetchData();
|
|
360
487
|
}
|
|
361
|
-
else if (event.name === "o" && selectedSkill) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (repo && repo !== "local") {
|
|
365
|
-
const url = `https://github.com/${repo}/tree/main/${repoPath}`;
|
|
488
|
+
else if (event.name === "o" && (selectedSkill || selectedSet)) {
|
|
489
|
+
if (selectedSet) {
|
|
490
|
+
const url = `https://github.com/${selectedSet.repo}`;
|
|
366
491
|
import("node:child_process").then(({ execSync: exec }) => {
|
|
367
492
|
try {
|
|
368
493
|
exec(`open "${url}"`);
|
|
@@ -370,6 +495,19 @@ export function SkillsScreen() {
|
|
|
370
495
|
catch { /* ignore */ }
|
|
371
496
|
});
|
|
372
497
|
}
|
|
498
|
+
else if (selectedSkill) {
|
|
499
|
+
const repo = selectedSkill.source.repo;
|
|
500
|
+
const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
|
|
501
|
+
if (repo && repo !== "local") {
|
|
502
|
+
const url = `https://github.com/${repo}/tree/main/${repoPath}`;
|
|
503
|
+
import("node:child_process").then(({ execSync: exec }) => {
|
|
504
|
+
try {
|
|
505
|
+
exec(`open "${url}"`);
|
|
506
|
+
}
|
|
507
|
+
catch { /* ignore */ }
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
373
511
|
}
|
|
374
512
|
else if (event.name === "/") {
|
|
375
513
|
dispatch({ type: "SET_SEARCHING", isSearching: true });
|
|
@@ -389,6 +527,6 @@ export function SkillsScreen() {
|
|
|
389
527
|
}
|
|
390
528
|
: undefined, footerHints: isSearchActive
|
|
391
529
|
? "type to filter │ Enter:done │ Esc:clear"
|
|
392
|
-
: "u:user │ p:project │ o:open │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: allItems, selectedIndex: skillsState.selectedIndex, renderItem: renderSkillRow, 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: renderSkillDetail(selectedItem) }));
|
|
530
|
+
: "u:user │ p:project │ o:open │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: allItems, selectedIndex: skillsState.selectedIndex, renderItem: renderSkillRow, maxHeight: dimensions.listPanelHeight, getKey: (item, index) => `${index}:${item.id}` }), !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: renderSkillDetail(selectedItem) }));
|
|
393
531
|
}
|
|
394
532
|
export default SkillsScreen;
|
|
@@ -6,19 +6,22 @@ import { useApp, useModal } from "../state/AppContext.js";
|
|
|
6
6
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
7
7
|
import { useKeyboard } from "../hooks/useKeyboard.js";
|
|
8
8
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
11
11
|
import {
|
|
12
12
|
fetchAvailableSkills,
|
|
13
13
|
fetchSkillFrontmatter,
|
|
14
|
+
fetchSkillSetSkills,
|
|
14
15
|
installSkill,
|
|
15
16
|
uninstallSkill,
|
|
16
17
|
} from "../../services/skills-manager.js";
|
|
17
18
|
import { searchSkills } from "../../services/skillsmp-client.js";
|
|
18
|
-
import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS } from "../../data/skill-repos.js";
|
|
19
|
-
import type { SkillInfo, SkillSource } from "../../types/index.js";
|
|
19
|
+
import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS, RECOMMENDED_SKILL_SETS, classifyStarReliability } from "../../data/skill-repos.js";
|
|
20
|
+
import type { SkillInfo, SkillSetInfo, SkillSource } from "../../types/index.js";
|
|
20
21
|
import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
|
|
22
|
+
import type { SkillBrowserItem } from "../adapters/skillsAdapter.js";
|
|
21
23
|
import { renderSkillRow, renderSkillDetail } from "../renderers/skillRenderers.js";
|
|
24
|
+
import { ScrollableList } from "../components/ScrollableList.js";
|
|
22
25
|
|
|
23
26
|
export function SkillsScreen() {
|
|
24
27
|
const { state, dispatch } = useApp();
|
|
@@ -99,6 +102,7 @@ export function SkillsScreen() {
|
|
|
99
102
|
installedScope: null,
|
|
100
103
|
hasUpdate: false,
|
|
101
104
|
stars: r.stars,
|
|
105
|
+
starReliability: classifyStarReliability(r.repo || "unknown", r.stars),
|
|
102
106
|
};
|
|
103
107
|
});
|
|
104
108
|
searchCacheRef.current.set(query, mapped);
|
|
@@ -143,6 +147,128 @@ export function SkillsScreen() {
|
|
|
143
147
|
scanDisk();
|
|
144
148
|
}, [scanDisk, state.dataRefreshVersion]);
|
|
145
149
|
|
|
150
|
+
// ── Skill Sets state ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const [skillSets, setSkillSets] = useState<SkillSetInfo[]>(() =>
|
|
153
|
+
RECOMMENDED_SKILL_SETS.map((rs) => ({
|
|
154
|
+
id: rs.repo,
|
|
155
|
+
name: rs.name,
|
|
156
|
+
description: rs.description,
|
|
157
|
+
repo: rs.repo,
|
|
158
|
+
icon: rs.icon,
|
|
159
|
+
stars: rs.stars,
|
|
160
|
+
skills: [],
|
|
161
|
+
loaded: false,
|
|
162
|
+
loading: false,
|
|
163
|
+
})),
|
|
164
|
+
);
|
|
165
|
+
const [expandedSets, setExpandedSets] = useState<Set<string>>(new Set());
|
|
166
|
+
|
|
167
|
+
// Re-mark installed status on child skills when disk state changes
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
setSkillSets((prev) =>
|
|
170
|
+
prev.map((set) => {
|
|
171
|
+
if (!set.loaded) return set;
|
|
172
|
+
const updatedSkills = set.skills.map((skill) => {
|
|
173
|
+
const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
174
|
+
const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(skill.name);
|
|
175
|
+
const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(skill.name);
|
|
176
|
+
return {
|
|
177
|
+
...skill,
|
|
178
|
+
installed: isUser || isProj,
|
|
179
|
+
installedScope: isProj ? "project" as const : isUser ? "user" as const : null,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
return { ...set, skills: updatedSkills };
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}, [installedFromDisk]);
|
|
186
|
+
|
|
187
|
+
const handleToggleSet = useCallback(
|
|
188
|
+
async (setId: string) => {
|
|
189
|
+
const isExpanded = expandedSets.has(setId);
|
|
190
|
+
const newExpanded = new Set(expandedSets);
|
|
191
|
+
if (isExpanded) {
|
|
192
|
+
newExpanded.delete(setId);
|
|
193
|
+
} else {
|
|
194
|
+
newExpanded.add(setId);
|
|
195
|
+
}
|
|
196
|
+
setExpandedSets(newExpanded);
|
|
197
|
+
|
|
198
|
+
// Fetch skills on first expand
|
|
199
|
+
const set = skillSets.find((s) => s.id === setId);
|
|
200
|
+
if (!isExpanded && set && !set.loaded && !set.loading) {
|
|
201
|
+
setSkillSets((prev) =>
|
|
202
|
+
prev.map((s) => (s.id === setId ? { ...s, loading: true } : s)),
|
|
203
|
+
);
|
|
204
|
+
try {
|
|
205
|
+
const skills = await fetchSkillSetSkills(set.repo, state.projectPath);
|
|
206
|
+
setSkillSets((prev) =>
|
|
207
|
+
prev.map((s) =>
|
|
208
|
+
s.id === setId
|
|
209
|
+
? { ...s, skills, loaded: true, loading: false, error: undefined }
|
|
210
|
+
: s,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
setSkillSets((prev) =>
|
|
215
|
+
prev.map((s) =>
|
|
216
|
+
s.id === setId
|
|
217
|
+
? {
|
|
218
|
+
...s,
|
|
219
|
+
loading: false,
|
|
220
|
+
error: error instanceof Error ? error.message : String(error),
|
|
221
|
+
}
|
|
222
|
+
: s,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
[expandedSets, skillSets, state.projectPath],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Status bar message (auto-clears)
|
|
232
|
+
const [statusMsg, setStatusMsg] = useState<{ text: string; tone: "success" | "error" } | null>(null);
|
|
233
|
+
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
234
|
+
const showStatus = useCallback((text: string, tone: "success" | "error" = "success") => {
|
|
235
|
+
setStatusMsg({ text, tone });
|
|
236
|
+
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
|
237
|
+
statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
const handleInstallAllFromSet = useCallback(
|
|
241
|
+
async (setId: string, scope: "user" | "project") => {
|
|
242
|
+
const set = skillSets.find((s) => s.id === setId);
|
|
243
|
+
if (!set || !set.loaded) return;
|
|
244
|
+
|
|
245
|
+
const toInstall = set.skills.filter((s) => !s.installed);
|
|
246
|
+
if (toInstall.length === 0) {
|
|
247
|
+
showStatus(`All ${set.name} skills already installed`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
|
|
252
|
+
let installed = 0;
|
|
253
|
+
let failed = 0;
|
|
254
|
+
for (const skill of toInstall) {
|
|
255
|
+
try {
|
|
256
|
+
await installSkill(skill, scope, state.projectPath);
|
|
257
|
+
installed++;
|
|
258
|
+
} catch {
|
|
259
|
+
failed++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
await scanDisk();
|
|
263
|
+
if (failed > 0) {
|
|
264
|
+
showStatus(`Installed ${installed}/${toInstall.length} (${failed} failed)`, "error");
|
|
265
|
+
} else {
|
|
266
|
+
showStatus(`Installed ${installed} skills from ${set.name} to ${scope}`);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
[skillSets, state.projectPath, scanDisk, showStatus],
|
|
270
|
+
);
|
|
271
|
+
|
|
146
272
|
// ── Derived data ──────────────────────────────────────────────────────────
|
|
147
273
|
|
|
148
274
|
const staticRecommended = useMemo((): SkillInfo[] => {
|
|
@@ -163,6 +289,7 @@ export function SkillsScreen() {
|
|
|
163
289
|
hasUpdate: false,
|
|
164
290
|
isRecommended: true,
|
|
165
291
|
stars: r.stars,
|
|
292
|
+
starReliability: classifyStarReliability(r.repo, r.stars),
|
|
166
293
|
};
|
|
167
294
|
});
|
|
168
295
|
}, [installedFromDisk]);
|
|
@@ -231,6 +358,8 @@ export function SkillsScreen() {
|
|
|
231
358
|
searchResults,
|
|
232
359
|
query: skillsState.searchQuery,
|
|
233
360
|
isSearchLoading,
|
|
361
|
+
skillSets,
|
|
362
|
+
expandedSets,
|
|
234
363
|
}),
|
|
235
364
|
[
|
|
236
365
|
mergedRecommended,
|
|
@@ -239,6 +368,8 @@ export function SkillsScreen() {
|
|
|
239
368
|
searchResults,
|
|
240
369
|
skillsState.searchQuery,
|
|
241
370
|
isSearchLoading,
|
|
371
|
+
skillSets,
|
|
372
|
+
expandedSets,
|
|
242
373
|
],
|
|
243
374
|
);
|
|
244
375
|
|
|
@@ -246,39 +377,49 @@ export function SkillsScreen() {
|
|
|
246
377
|
useEffect(() => {
|
|
247
378
|
const item = allItems[skillsState.selectedIndex];
|
|
248
379
|
if (item && item.kind === "category") {
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
380
|
+
const firstSelectable = allItems.findIndex((i) => i.kind === "skill" || i.kind === "skillset");
|
|
381
|
+
if (firstSelectable >= 0) dispatch({ type: "SKILLS_SELECT", index: firstSelectable });
|
|
251
382
|
}
|
|
252
383
|
}, [allItems, skillsState.selectedIndex, dispatch]);
|
|
253
384
|
|
|
254
|
-
const selectedItem = allItems[skillsState.selectedIndex];
|
|
385
|
+
const selectedItem: SkillBrowserItem | undefined = allItems[skillsState.selectedIndex];
|
|
255
386
|
const selectedSkill =
|
|
256
387
|
selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
|
|
388
|
+
const selectedSet =
|
|
389
|
+
selectedItem?.kind === "skillset" ? selectedItem.skillSet : undefined;
|
|
257
390
|
|
|
258
391
|
// ── Lazy-load frontmatter for selected skill ───────────────────────────────
|
|
259
392
|
|
|
393
|
+
// Check if selected skill belongs to a skill set (lives in local state, not reducer)
|
|
394
|
+
const isSkillSetChild = selectedSkill
|
|
395
|
+
? skillSets.some((s) => s.loaded && s.skills.some((sk) => sk.id === selectedSkill.id))
|
|
396
|
+
: false;
|
|
397
|
+
|
|
260
398
|
useEffect(() => {
|
|
261
399
|
if (!selectedSkill || selectedSkill.frontmatter) return;
|
|
262
400
|
fetchSkillFrontmatter(selectedSkill).then((fm) => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
401
|
+
if (isSkillSetChild) {
|
|
402
|
+
// Update the local skillSets state
|
|
403
|
+
setSkillSets((prev) =>
|
|
404
|
+
prev.map((set) => ({
|
|
405
|
+
...set,
|
|
406
|
+
skills: set.skills.map((sk) =>
|
|
407
|
+
sk.id === selectedSkill.id ? { ...sk, frontmatter: fm } : sk,
|
|
408
|
+
),
|
|
409
|
+
})),
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
dispatch({
|
|
413
|
+
type: "SKILLS_UPDATE_ITEM",
|
|
414
|
+
name: selectedSkill.name,
|
|
415
|
+
updates: { frontmatter: fm },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
268
418
|
}).catch(() => {});
|
|
269
|
-
}, [selectedSkill?.id, dispatch]);
|
|
419
|
+
}, [selectedSkill?.id, isSkillSetChild, dispatch]);
|
|
270
420
|
|
|
271
421
|
// ── Action handlers ───────────────────────────────────────────────────────
|
|
272
422
|
|
|
273
|
-
// Status bar message (auto-clears)
|
|
274
|
-
const [statusMsg, setStatusMsg] = useState<{ text: string; tone: "success" | "error" } | null>(null);
|
|
275
|
-
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
276
|
-
const showStatus = useCallback((text: string, tone: "success" | "error" = "success") => {
|
|
277
|
-
setStatusMsg({ text, tone });
|
|
278
|
-
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
|
279
|
-
statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
|
|
280
|
-
}, []);
|
|
281
|
-
|
|
282
423
|
const handleInstall = useCallback(async (scope: "user" | "project") => {
|
|
283
424
|
if (!selectedSkill) return;
|
|
284
425
|
try {
|
|
@@ -352,6 +493,10 @@ export function SkillsScreen() {
|
|
|
352
493
|
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
353
494
|
return;
|
|
354
495
|
}
|
|
496
|
+
if (selectedSet) {
|
|
497
|
+
handleToggleSet(selectedSet.id);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
355
500
|
if (selectedSkill && !selectedSkill.installed) {
|
|
356
501
|
handleInstall("project");
|
|
357
502
|
}
|
|
@@ -360,14 +505,26 @@ export function SkillsScreen() {
|
|
|
360
505
|
|
|
361
506
|
// Action keys only when NOT actively typing in search
|
|
362
507
|
if (!isSearchActive) {
|
|
363
|
-
if (event.name === "u"
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (selectedSkill
|
|
369
|
-
|
|
370
|
-
|
|
508
|
+
if (event.name === "u") {
|
|
509
|
+
if (selectedSet) {
|
|
510
|
+
handleInstallAllFromSet(selectedSet.id, "user");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (selectedSkill) {
|
|
514
|
+
if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
|
|
515
|
+
else handleInstall("user");
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
} else if (event.name === "p") {
|
|
519
|
+
if (selectedSet) {
|
|
520
|
+
handleInstallAllFromSet(selectedSet.id, "project");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (selectedSkill) {
|
|
524
|
+
if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
|
|
525
|
+
else handleInstall("project");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
371
528
|
}
|
|
372
529
|
}
|
|
373
530
|
|
|
@@ -387,14 +544,21 @@ export function SkillsScreen() {
|
|
|
387
544
|
|
|
388
545
|
if (event.name === "r") {
|
|
389
546
|
fetchData();
|
|
390
|
-
} else if (event.name === "o" && selectedSkill) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (repo && repo !== "local") {
|
|
394
|
-
const url = `https://github.com/${repo}/tree/main/${repoPath}`;
|
|
547
|
+
} else if (event.name === "o" && (selectedSkill || selectedSet)) {
|
|
548
|
+
if (selectedSet) {
|
|
549
|
+
const url = `https://github.com/${selectedSet.repo}`;
|
|
395
550
|
import("node:child_process").then(({ execSync: exec }) => {
|
|
396
551
|
try { exec(`open "${url}"`); } catch { /* ignore */ }
|
|
397
552
|
});
|
|
553
|
+
} else if (selectedSkill) {
|
|
554
|
+
const repo = selectedSkill.source.repo;
|
|
555
|
+
const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
|
|
556
|
+
if (repo && repo !== "local") {
|
|
557
|
+
const url = `https://github.com/${repo}/tree/main/${repoPath}`;
|
|
558
|
+
import("node:child_process").then(({ execSync: exec }) => {
|
|
559
|
+
try { exec(`open "${url}"`); } catch { /* ignore */ }
|
|
560
|
+
});
|
|
561
|
+
}
|
|
398
562
|
}
|
|
399
563
|
} else if (event.name === "/") {
|
|
400
564
|
dispatch({ type: "SET_SEARCHING", isSearching: true });
|
|
@@ -454,6 +618,7 @@ export function SkillsScreen() {
|
|
|
454
618
|
selectedIndex={skillsState.selectedIndex}
|
|
455
619
|
renderItem={renderSkillRow}
|
|
456
620
|
maxHeight={dimensions.listPanelHeight}
|
|
621
|
+
getKey={(item, index) => `${index}:${item.id}`}
|
|
457
622
|
/>
|
|
458
623
|
{!query && skillsState.skills.status === "loading" && (
|
|
459
624
|
<box marginTop={2} paddingLeft={2}>
|