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.
@@ -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 firstSkill = allItems.findIndex((i) => i.kind === "skill");
218
- if (firstSkill >= 0)
219
- dispatch({ type: "SKILLS_SELECT", index: firstSkill });
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
- dispatch({
230
- type: "SKILLS_UPDATE_ITEM",
231
- name: selectedSkill.name,
232
- updates: { frontmatter: fm },
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" && selectedSkill) {
331
- if (selectedSkill.installed && selectedSkill.installedScope === "user")
332
- handleUninstall();
333
- else
334
- handleInstall("user");
335
- return;
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" && selectedSkill) {
338
- if (selectedSkill.installed && selectedSkill.installedScope === "project")
339
- handleUninstall();
340
- else
341
- handleInstall("project");
342
- return;
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
- const repo = selectedSkill.source.repo;
363
- const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
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
- import { ScrollableList } from "../components/ScrollableList.js";
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 firstSkill = allItems.findIndex((i) => i.kind === "skill");
250
- if (firstSkill >= 0) dispatch({ type: "SKILLS_SELECT", index: firstSkill });
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
- dispatch({
264
- type: "SKILLS_UPDATE_ITEM",
265
- name: selectedSkill.name,
266
- updates: { frontmatter: fm },
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" && selectedSkill) {
364
- if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
365
- else handleInstall("user");
366
- return;
367
- } else if (event.name === "p" && selectedSkill) {
368
- if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
369
- else handleInstall("project");
370
- return;
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
- const repo = selectedSkill.source.repo;
392
- const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
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}>