claudeup 3.14.0 → 3.16.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 +11 -0
- package/src/data/skill-repos.ts +12 -0
- package/src/services/skills-manager.js +40 -13
- package/src/services/skills-manager.ts +38 -16
- package/src/ui/adapters/skillsAdapter.js +106 -0
- package/src/ui/adapters/skillsAdapter.ts +160 -0
- package/src/ui/components/primitives/ActionHints.js +13 -0
- package/src/ui/components/primitives/ActionHints.tsx +41 -0
- package/src/ui/components/primitives/DetailSection.js +7 -0
- package/src/ui/components/primitives/DetailSection.tsx +22 -0
- package/src/ui/components/primitives/KeyValueLine.js +8 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +19 -0
- package/src/ui/components/primitives/ListCategoryRow.js +8 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +38 -0
- package/src/ui/components/primitives/MetaText.js +8 -0
- package/src/ui/components/primitives/MetaText.tsx +14 -0
- package/src/ui/components/primitives/ScopeDetail.js +32 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.js +11 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +33 -0
- package/src/ui/components/primitives/SelectableRow.js +5 -0
- package/src/ui/components/primitives/SelectableRow.tsx +24 -0
- package/src/ui/components/primitives/index.js +8 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/registry.js +1 -0
- package/src/ui/registry.ts +27 -0
- package/src/ui/renderers/skillRenderers.js +75 -0
- package/src/ui/renderers/skillRenderers.tsx +220 -0
- package/src/ui/screens/SkillsScreen.js +46 -195
- package/src/ui/screens/SkillsScreen.tsx +436 -796
- package/src/ui/theme.js +47 -0
- package/src/ui/theme.ts +53 -0
|
@@ -8,809 +8,449 @@ import { useKeyboard } from "../hooks/useKeyboard.js";
|
|
|
8
8
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
9
9
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
10
10
|
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
11
|
-
import { scopeIndicatorText } from "../components/ScopeIndicator.js";
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
fetchAvailableSkills,
|
|
13
|
+
fetchSkillFrontmatter,
|
|
14
|
+
installSkill,
|
|
15
|
+
uninstallSkill,
|
|
17
16
|
} from "../../services/skills-manager.js";
|
|
18
17
|
import { searchSkills } from "../../services/skillsmp-client.js";
|
|
19
18
|
import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS } from "../../data/skill-repos.js";
|
|
20
19
|
import type { SkillInfo, SkillSource } from "../../types/index.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
id: string;
|
|
24
|
-
type: "category" | "skill";
|
|
25
|
-
label: string;
|
|
26
|
-
skill?: SkillInfo;
|
|
27
|
-
categoryKey?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function formatStars(stars?: number): string {
|
|
31
|
-
if (!stars) return "";
|
|
32
|
-
if (stars >= 1000000) return `★ ${(stars / 1000000).toFixed(1)}M`;
|
|
33
|
-
if (stars >= 10000) return `★ ${Math.round(stars / 1000)}K`;
|
|
34
|
-
if (stars >= 1000) return `★ ${(stars / 1000).toFixed(1)}K`;
|
|
35
|
-
return `★ ${stars}`;
|
|
36
|
-
}
|
|
20
|
+
import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
|
|
21
|
+
import { renderSkillRow, renderSkillDetail } from "../renderers/skillRenderers.js";
|
|
37
22
|
|
|
38
23
|
export function SkillsScreen() {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Action shortcuts (work when not actively typing, even with filter visible)
|
|
472
|
-
if (event.name === "u" && selectedSkill) {
|
|
473
|
-
if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
|
|
474
|
-
else handleInstall("user");
|
|
475
|
-
} else if (event.name === "p" && selectedSkill) {
|
|
476
|
-
if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
|
|
477
|
-
else handleInstall("project");
|
|
478
|
-
} else if (event.name === "d" && selectedSkill?.installed) {
|
|
479
|
-
handleUninstall();
|
|
480
|
-
} else if (event.name === "r") {
|
|
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
|
-
}
|
|
492
|
-
}
|
|
493
|
-
// "/" to enter search mode
|
|
494
|
-
else if (event.name === "/") {
|
|
495
|
-
dispatch({ type: "SET_SEARCHING", isSearching: true });
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
const renderListItem = (
|
|
500
|
-
item: SkillListItem,
|
|
501
|
-
_idx: number,
|
|
502
|
-
isSelected: boolean,
|
|
503
|
-
) => {
|
|
504
|
-
if (item.type === "category") {
|
|
505
|
-
const isRec = item.categoryKey === "recommended";
|
|
506
|
-
const isInstalled = item.categoryKey === "installed";
|
|
507
|
-
const bgColor = isInstalled ? "#7e57c2" : isRec ? "#2e7d32" : "#00695c";
|
|
508
|
-
const star = isRec ? "★ " : isInstalled ? "● " : "";
|
|
509
|
-
|
|
510
|
-
if (isSelected) {
|
|
511
|
-
return (
|
|
512
|
-
<text bg="magenta" fg="white">
|
|
513
|
-
<strong> {star}{item.label} </strong>
|
|
514
|
-
</text>
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
return (
|
|
518
|
-
<text bg={bgColor} fg="white">
|
|
519
|
-
<strong> {star}{item.label} </strong>
|
|
520
|
-
</text>
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (item.type === "skill" && item.skill) {
|
|
525
|
-
const skill = item.skill;
|
|
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";
|
|
530
|
-
|
|
531
|
-
if (isSelected) {
|
|
532
|
-
return (
|
|
533
|
-
<text bg="magenta" fg="white">
|
|
534
|
-
{" "}
|
|
535
|
-
<span>{hasUser ? "■" : "□"}</span>
|
|
536
|
-
<span>{hasProject ? "■" : "□"}</span>
|
|
537
|
-
{" "}{skill.name}{skill.hasUpdate ? " ⬆" : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
|
|
538
|
-
</text>
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
return (
|
|
543
|
-
<text>
|
|
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>
|
|
549
|
-
{skill.hasUpdate && <span fg="yellow"> ⬆</span>}
|
|
550
|
-
{starsStr && (
|
|
551
|
-
<span fg="yellow">{" "}{starsStr}</span>
|
|
552
|
-
)}
|
|
553
|
-
</text>
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
return <text fg="gray">{item.label}</text>;
|
|
558
|
-
};
|
|
559
|
-
|
|
560
|
-
const renderDetail = () => {
|
|
561
|
-
if (skillsState.skills.status === "loading") {
|
|
562
|
-
return <text fg="gray">Loading skills...</text>;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
if (skillsState.skills.status === "error") {
|
|
566
|
-
return (
|
|
567
|
-
<box flexDirection="column">
|
|
568
|
-
<text fg="red">Failed to load skills</text>
|
|
569
|
-
<box marginTop={1}>
|
|
570
|
-
<text fg="gray">{skillsState.skills.error.message}</text>
|
|
571
|
-
</box>
|
|
572
|
-
</box>
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
if (!selectedItem) {
|
|
577
|
-
return <text fg="gray">Select a skill to see details</text>;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (selectedItem.type === "category") {
|
|
581
|
-
const isRec = selectedItem.categoryKey === "recommended";
|
|
582
|
-
const isNoResults = selectedItem.categoryKey === "no-results";
|
|
583
|
-
|
|
584
|
-
if (isNoResults) {
|
|
585
|
-
return (
|
|
586
|
-
<box flexDirection="column">
|
|
587
|
-
<text fg="yellow">
|
|
588
|
-
<strong>No skills found</strong>
|
|
589
|
-
</text>
|
|
590
|
-
<box marginTop={1}>
|
|
591
|
-
<text fg="gray">
|
|
592
|
-
Nothing matched "{skillsState.searchQuery}".
|
|
593
|
-
</text>
|
|
594
|
-
</box>
|
|
595
|
-
<box marginTop={1}>
|
|
596
|
-
<text fg="gray">
|
|
597
|
-
Try a different search term, or if you think this is a mistake, create an issue at:
|
|
598
|
-
</text>
|
|
599
|
-
</box>
|
|
600
|
-
<box marginTop={1}>
|
|
601
|
-
<text fg="#5c9aff">github.com/MadAppGang/magus/issues</text>
|
|
602
|
-
</box>
|
|
603
|
-
<box marginTop={2}>
|
|
604
|
-
<text fg="gray">Press Esc to clear the search.</text>
|
|
605
|
-
</box>
|
|
606
|
-
</box>
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
return (
|
|
611
|
-
<box flexDirection="column">
|
|
612
|
-
<text fg={isRec ? "green" : "cyan"}>
|
|
613
|
-
<strong>{isRec ? "★ " : ""}{selectedItem.label}</strong>
|
|
614
|
-
</text>
|
|
615
|
-
<box marginTop={1}>
|
|
616
|
-
<text fg="gray">
|
|
617
|
-
{isRec ? "Curated skills recommended for most projects" : "Popular skills sorted by stars"}
|
|
618
|
-
</text>
|
|
619
|
-
</box>
|
|
620
|
-
</box>
|
|
621
|
-
);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
if (!selectedSkill) return null;
|
|
625
|
-
|
|
626
|
-
const fm = selectedSkill.frontmatter;
|
|
627
|
-
const description = fm?.description || selectedSkill.description || "Loading...";
|
|
628
|
-
const scopeColor = selectedSkill.installedScope === "user" ? "cyan" : "green";
|
|
629
|
-
const starsStr = formatStars(selectedSkill.stars);
|
|
630
|
-
|
|
631
|
-
return (
|
|
632
|
-
<box flexDirection="column">
|
|
633
|
-
<text fg="cyan">
|
|
634
|
-
<strong>{selectedSkill.name}</strong>
|
|
635
|
-
{starsStr && <span fg="yellow"> {starsStr}</span>}
|
|
636
|
-
</text>
|
|
637
|
-
|
|
638
|
-
<box marginTop={1}>
|
|
639
|
-
<text fg="white">
|
|
640
|
-
{description}
|
|
641
|
-
</text>
|
|
642
|
-
</box>
|
|
643
|
-
|
|
644
|
-
{fm?.category && (
|
|
645
|
-
<box marginTop={1}>
|
|
646
|
-
<text>
|
|
647
|
-
<span fg="gray">Category </span>
|
|
648
|
-
<span fg="cyan">{fm.category}</span>
|
|
649
|
-
</text>
|
|
650
|
-
</box>
|
|
651
|
-
)}
|
|
652
|
-
|
|
653
|
-
{fm?.author && (
|
|
654
|
-
<box>
|
|
655
|
-
<text>
|
|
656
|
-
<span fg="gray">Author </span>
|
|
657
|
-
<span>{fm.author}</span>
|
|
658
|
-
</text>
|
|
659
|
-
</box>
|
|
660
|
-
)}
|
|
661
|
-
|
|
662
|
-
{fm?.version && (
|
|
663
|
-
<box>
|
|
664
|
-
<text>
|
|
665
|
-
<span fg="gray">Version </span>
|
|
666
|
-
<span>{fm.version}</span>
|
|
667
|
-
</text>
|
|
668
|
-
</box>
|
|
669
|
-
)}
|
|
670
|
-
|
|
671
|
-
{fm?.tags && fm.tags.length > 0 && (
|
|
672
|
-
<box>
|
|
673
|
-
<text>
|
|
674
|
-
<span fg="gray">Tags </span>
|
|
675
|
-
<span>{fm.tags.join(", ")}</span>
|
|
676
|
-
</text>
|
|
677
|
-
</box>
|
|
678
|
-
)}
|
|
679
|
-
|
|
680
|
-
<box marginTop={1} flexDirection="column">
|
|
681
|
-
<text>
|
|
682
|
-
<span fg="gray">Source </span>
|
|
683
|
-
<span fg="#5c9aff">{selectedSkill.source.repo}</span>
|
|
684
|
-
</text>
|
|
685
|
-
<text>
|
|
686
|
-
<span fg="gray"> </span>
|
|
687
|
-
<span fg="gray">{selectedSkill.repoPath}</span>
|
|
688
|
-
</text>
|
|
689
|
-
</box>
|
|
690
|
-
|
|
691
|
-
{selectedSkill.installed && selectedSkill.installedScope && (
|
|
692
|
-
<box marginTop={1} flexDirection="column">
|
|
693
|
-
<text>{"─".repeat(24)}</text>
|
|
694
|
-
<text>
|
|
695
|
-
<span fg="gray">Installed </span>
|
|
696
|
-
<span fg={scopeColor}>
|
|
697
|
-
{selectedSkill.installedScope === "user"
|
|
698
|
-
? "~/.claude/skills/"
|
|
699
|
-
: ".claude/skills/"}
|
|
700
|
-
{selectedSkill.name}/
|
|
701
|
-
</span>
|
|
702
|
-
</text>
|
|
703
|
-
</box>
|
|
704
|
-
)}
|
|
705
|
-
|
|
706
|
-
<box marginTop={1} flexDirection="column">
|
|
707
|
-
<text>{"─".repeat(24)}</text>
|
|
708
|
-
<text>
|
|
709
|
-
<strong>Install scope:</strong>
|
|
710
|
-
</text>
|
|
711
|
-
<box marginTop={1} flexDirection="column">
|
|
712
|
-
<text>
|
|
713
|
-
<span bg="cyan" fg="black"> u </span>
|
|
714
|
-
<span fg={selectedSkill.installedScope === "user" ? "cyan" : "gray"}>
|
|
715
|
-
{selectedSkill.installedScope === "user" ? " ● " : " ○ "}
|
|
716
|
-
</span>
|
|
717
|
-
<span fg="cyan">User</span>
|
|
718
|
-
<span fg="gray"> ~/.claude/skills/</span>
|
|
719
|
-
</text>
|
|
720
|
-
<text>
|
|
721
|
-
<span bg="green" fg="black"> p </span>
|
|
722
|
-
<span fg={selectedSkill.installedScope === "project" ? "green" : "gray"}>
|
|
723
|
-
{selectedSkill.installedScope === "project" ? " ● " : " ○ "}
|
|
724
|
-
</span>
|
|
725
|
-
<span fg="green">Project</span>
|
|
726
|
-
<span fg="gray"> .claude/skills/</span>
|
|
727
|
-
</text>
|
|
728
|
-
</box>
|
|
729
|
-
</box>
|
|
730
|
-
|
|
731
|
-
{selectedSkill.hasUpdate && (
|
|
732
|
-
<box marginTop={1}>
|
|
733
|
-
<text bg="yellow" fg="black"> UPDATE AVAILABLE </text>
|
|
734
|
-
</box>
|
|
735
|
-
)}
|
|
736
|
-
|
|
737
|
-
<box marginTop={1} flexDirection="column">
|
|
738
|
-
{!selectedSkill.installed && (
|
|
739
|
-
<text fg="gray">Press u/p to install in scope</text>
|
|
740
|
-
)}
|
|
741
|
-
{selectedSkill.installed && (
|
|
742
|
-
<text fg="gray">Press d to uninstall</text>
|
|
743
|
-
)}
|
|
744
|
-
</box>
|
|
745
|
-
</box>
|
|
746
|
-
);
|
|
747
|
-
};
|
|
748
|
-
|
|
749
|
-
const skills =
|
|
750
|
-
skillsState.skills.status === "success" ? skillsState.skills.data : [];
|
|
751
|
-
const installedCount = skills.filter((s) => s.installed).length;
|
|
752
|
-
const query = skillsState.searchQuery.trim();
|
|
753
|
-
|
|
754
|
-
const statusContent = (
|
|
755
|
-
<text>
|
|
756
|
-
<span fg="gray">Skills: </span>
|
|
757
|
-
<span fg="cyan">{installedCount} installed</span>
|
|
758
|
-
{query.length >= 2 && isSearchLoading && (
|
|
759
|
-
<span fg="yellow"> │ searching...</span>
|
|
760
|
-
)}
|
|
761
|
-
{query.length >= 2 && !isSearchLoading && searchResults.length > 0 && (
|
|
762
|
-
<span fg="green"> │ {searchResults.length} found</span>
|
|
763
|
-
)}
|
|
764
|
-
{!query && (
|
|
765
|
-
<span fg="gray"> │ 89K+ searchable</span>
|
|
766
|
-
)}
|
|
767
|
-
</text>
|
|
768
|
-
);
|
|
769
|
-
|
|
770
|
-
return (
|
|
771
|
-
<ScreenLayout
|
|
772
|
-
title="claudeup Skills"
|
|
773
|
-
currentScreen="skills"
|
|
774
|
-
statusLine={statusContent}
|
|
775
|
-
search={
|
|
776
|
-
skillsState.searchQuery || isSearchActive
|
|
777
|
-
? {
|
|
778
|
-
isActive: isSearchActive,
|
|
779
|
-
query: skillsState.searchQuery,
|
|
780
|
-
placeholder: "type to search",
|
|
781
|
-
}
|
|
782
|
-
: undefined
|
|
783
|
-
}
|
|
784
|
-
footerHints={isSearchActive
|
|
785
|
-
? "type to filter │ Enter:done │ Esc:clear"
|
|
786
|
-
: "u:user │ p:project │ o:open │ /:search"
|
|
787
|
-
}
|
|
788
|
-
listPanel={
|
|
789
|
-
<box flexDirection="column">
|
|
790
|
-
<ScrollableList
|
|
791
|
-
items={selectableItems}
|
|
792
|
-
selectedIndex={skillsState.selectedIndex}
|
|
793
|
-
renderItem={renderListItem}
|
|
794
|
-
maxHeight={dimensions.listPanelHeight}
|
|
795
|
-
/>
|
|
796
|
-
{!query && skillsState.skills.status === "loading" && (
|
|
797
|
-
<box marginTop={2} paddingLeft={2}>
|
|
798
|
-
<text fg="yellow">Loading popular skills...</text>
|
|
799
|
-
</box>
|
|
800
|
-
)}
|
|
801
|
-
{query.length >= 2 && isSearchLoading && (
|
|
802
|
-
<box marginTop={2} paddingLeft={2}>
|
|
803
|
-
<text fg="yellow">Searching for "{skillsState.searchQuery}"...</text>
|
|
804
|
-
</box>
|
|
805
|
-
)}
|
|
806
|
-
{query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (
|
|
807
|
-
<EmptyFilterState query={skillsState.searchQuery} entityName="skills" />
|
|
808
|
-
)}
|
|
809
|
-
</box>
|
|
810
|
-
}
|
|
811
|
-
detailPanel={renderDetail()}
|
|
812
|
-
/>
|
|
813
|
-
);
|
|
24
|
+
const { state, dispatch } = useApp();
|
|
25
|
+
const { skills: skillsState } = state;
|
|
26
|
+
const modal = useModal();
|
|
27
|
+
const dimensions = useDimensions();
|
|
28
|
+
|
|
29
|
+
const isSearchActive =
|
|
30
|
+
state.isSearching &&
|
|
31
|
+
state.currentRoute.screen === "skills" &&
|
|
32
|
+
!state.modal;
|
|
33
|
+
|
|
34
|
+
// ── Data fetching ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const fetchData = useCallback(async () => {
|
|
37
|
+
dispatch({ type: "SKILLS_DATA_LOADING" });
|
|
38
|
+
try {
|
|
39
|
+
const skills = await fetchAvailableSkills(
|
|
40
|
+
DEFAULT_SKILL_REPOS,
|
|
41
|
+
state.projectPath,
|
|
42
|
+
);
|
|
43
|
+
dispatch({ type: "SKILLS_DATA_SUCCESS", skills });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
dispatch({
|
|
46
|
+
type: "SKILLS_DATA_ERROR",
|
|
47
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}, [dispatch, state.projectPath]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
fetchData();
|
|
54
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
55
|
+
|
|
56
|
+
// ── Remote search (debounced, cached) ─────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const [searchResults, setSearchResults] = useState<SkillInfo[]>([]);
|
|
59
|
+
const [isSearchLoading, setIsSearchLoading] = useState(false);
|
|
60
|
+
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
61
|
+
const searchCacheRef = useRef<Map<string, SkillInfo[]>>(new Map());
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const query = skillsState.searchQuery.trim();
|
|
65
|
+
if (query.length < 2) {
|
|
66
|
+
setSearchResults([]);
|
|
67
|
+
setIsSearchLoading(false);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cached = searchCacheRef.current.get(query);
|
|
72
|
+
if (cached) {
|
|
73
|
+
setSearchResults(cached);
|
|
74
|
+
setIsSearchLoading(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setIsSearchLoading(true);
|
|
79
|
+
|
|
80
|
+
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
81
|
+
searchTimerRef.current = setTimeout(async () => {
|
|
82
|
+
try {
|
|
83
|
+
const results = await searchSkills(query, { limit: 30 });
|
|
84
|
+
const mapped: SkillInfo[] = results.map((r) => {
|
|
85
|
+
const source: SkillSource = {
|
|
86
|
+
label: r.repo || "unknown",
|
|
87
|
+
repo: r.repo || "unknown",
|
|
88
|
+
skillsPath: "",
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
id: `remote:${r.repo}/${r.skillPath}`,
|
|
92
|
+
name: r.name,
|
|
93
|
+
description: r.description || "",
|
|
94
|
+
source,
|
|
95
|
+
repoPath: r.skillPath ? `${r.skillPath}/SKILL.md` : "SKILL.md",
|
|
96
|
+
gitBlobSha: "",
|
|
97
|
+
frontmatter: null,
|
|
98
|
+
installed: false,
|
|
99
|
+
installedScope: null,
|
|
100
|
+
hasUpdate: false,
|
|
101
|
+
stars: r.stars,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
searchCacheRef.current.set(query, mapped);
|
|
105
|
+
setSearchResults(mapped);
|
|
106
|
+
} catch {
|
|
107
|
+
setSearchResults([]);
|
|
108
|
+
}
|
|
109
|
+
setIsSearchLoading(false);
|
|
110
|
+
}, 400);
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
114
|
+
};
|
|
115
|
+
}, [skillsState.searchQuery]);
|
|
116
|
+
|
|
117
|
+
// ── Disk scan for installed skills ────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const [installedFromDisk, setInstalledFromDisk] = useState<{
|
|
120
|
+
user: Set<string>;
|
|
121
|
+
project: Set<string>;
|
|
122
|
+
}>({ user: new Set(), project: new Set() });
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
async function scanDisk() {
|
|
126
|
+
const user = new Set<string>();
|
|
127
|
+
const project = new Set<string>();
|
|
128
|
+
const userDir = path.join(os.homedir(), ".claude", "skills");
|
|
129
|
+
const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
|
|
130
|
+
for (const [dir, set] of [[userDir, user], [projDir, project]] as const) {
|
|
131
|
+
try {
|
|
132
|
+
if (await fs.pathExists(dir)) {
|
|
133
|
+
const entries = await fs.readdir(dir);
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
setInstalledFromDisk({ user, project });
|
|
141
|
+
}
|
|
142
|
+
scanDisk();
|
|
143
|
+
}, [state.projectPath, state.dataRefreshVersion]);
|
|
144
|
+
|
|
145
|
+
// ── Derived data ──────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
const staticRecommended = useMemo((): SkillInfo[] => {
|
|
148
|
+
return RECOMMENDED_SKILLS.map((r) => {
|
|
149
|
+
const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
150
|
+
const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
|
|
151
|
+
const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
|
|
152
|
+
return {
|
|
153
|
+
id: `rec:${r.repo}/${r.skillPath}`,
|
|
154
|
+
name: r.name,
|
|
155
|
+
description: r.description,
|
|
156
|
+
source: { label: r.repo, repo: r.repo, skillsPath: "" },
|
|
157
|
+
repoPath: `${r.skillPath}/SKILL.md`,
|
|
158
|
+
gitBlobSha: "",
|
|
159
|
+
frontmatter: null,
|
|
160
|
+
installed: isUser || isProj,
|
|
161
|
+
installedScope: isProj ? "project" : isUser ? "user" : null,
|
|
162
|
+
hasUpdate: false,
|
|
163
|
+
isRecommended: true,
|
|
164
|
+
stars: r.stars,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}, [installedFromDisk]);
|
|
168
|
+
|
|
169
|
+
const mergedRecommended = useMemo((): SkillInfo[] => {
|
|
170
|
+
if (skillsState.skills.status !== "success") return staticRecommended;
|
|
171
|
+
const fetched = skillsState.skills.data.filter((s) => s.isRecommended);
|
|
172
|
+
return staticRecommended.map((staticSkill) => {
|
|
173
|
+
const match = fetched.find(
|
|
174
|
+
(f) => f.source.repo === staticSkill.source.repo && f.name === staticSkill.name,
|
|
175
|
+
);
|
|
176
|
+
if (!match) return staticSkill;
|
|
177
|
+
// Merge: prefer fetched data but keep static stars as fallback
|
|
178
|
+
return { ...staticSkill, ...match, stars: match.stars || staticSkill.stars };
|
|
179
|
+
});
|
|
180
|
+
}, [staticRecommended, skillsState.skills]);
|
|
181
|
+
|
|
182
|
+
const installedSkills = useMemo((): SkillInfo[] => {
|
|
183
|
+
const all: SkillInfo[] = [];
|
|
184
|
+
for (const [scope, names] of [
|
|
185
|
+
["user", installedFromDisk.user],
|
|
186
|
+
["project", installedFromDisk.project],
|
|
187
|
+
] as const) {
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
if (all.some((s) => s.name === name)) continue;
|
|
190
|
+
all.push({
|
|
191
|
+
id: `installed:${scope}/${name}`,
|
|
192
|
+
name,
|
|
193
|
+
description: "",
|
|
194
|
+
source: { label: "local", repo: "local", skillsPath: "" },
|
|
195
|
+
repoPath: "",
|
|
196
|
+
gitBlobSha: "",
|
|
197
|
+
frontmatter: null,
|
|
198
|
+
installed: true,
|
|
199
|
+
installedScope: scope,
|
|
200
|
+
hasUpdate: false,
|
|
201
|
+
stars: undefined,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return all;
|
|
206
|
+
}, [installedFromDisk]);
|
|
207
|
+
|
|
208
|
+
const popularSkills = useMemo((): SkillInfo[] => {
|
|
209
|
+
if (skillsState.skills.status !== "success") return [];
|
|
210
|
+
return skillsState.skills.data
|
|
211
|
+
.filter((s) => !s.isRecommended)
|
|
212
|
+
.sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
|
|
213
|
+
}, [skillsState.skills]);
|
|
214
|
+
|
|
215
|
+
// ── List items (built by adapter) ─────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const allItems = useMemo(
|
|
218
|
+
() =>
|
|
219
|
+
buildSkillBrowserItems({
|
|
220
|
+
recommended: mergedRecommended,
|
|
221
|
+
popular: popularSkills,
|
|
222
|
+
installed: installedSkills,
|
|
223
|
+
searchResults,
|
|
224
|
+
query: skillsState.searchQuery,
|
|
225
|
+
isSearchLoading,
|
|
226
|
+
}),
|
|
227
|
+
[
|
|
228
|
+
mergedRecommended,
|
|
229
|
+
popularSkills,
|
|
230
|
+
installedSkills,
|
|
231
|
+
searchResults,
|
|
232
|
+
skillsState.searchQuery,
|
|
233
|
+
isSearchLoading,
|
|
234
|
+
],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const selectedItem = allItems[skillsState.selectedIndex];
|
|
238
|
+
const selectedSkill =
|
|
239
|
+
selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
|
|
240
|
+
|
|
241
|
+
// ── Lazy-load frontmatter for selected skill ───────────────────────────────
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (!selectedSkill || selectedSkill.frontmatter) return;
|
|
245
|
+
fetchSkillFrontmatter(selectedSkill).then((fm) => {
|
|
246
|
+
dispatch({
|
|
247
|
+
type: "SKILLS_UPDATE_ITEM",
|
|
248
|
+
name: selectedSkill.name,
|
|
249
|
+
updates: { frontmatter: fm },
|
|
250
|
+
});
|
|
251
|
+
}).catch(() => {});
|
|
252
|
+
}, [selectedSkill?.id, dispatch]);
|
|
253
|
+
|
|
254
|
+
// ── Action handlers ───────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
const handleInstall = useCallback(async (scope: "user" | "project") => {
|
|
257
|
+
if (!selectedSkill) return;
|
|
258
|
+
modal.loading(`Installing ${selectedSkill.name}...`);
|
|
259
|
+
try {
|
|
260
|
+
await installSkill(selectedSkill, scope, state.projectPath);
|
|
261
|
+
modal.hideModal();
|
|
262
|
+
await fetchData();
|
|
263
|
+
await modal.message(
|
|
264
|
+
"Installed",
|
|
265
|
+
`${selectedSkill.name} installed to ${scope === "user" ? "~/.claude/skills/" : ".claude/skills/"}`,
|
|
266
|
+
"success",
|
|
267
|
+
);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
modal.hideModal();
|
|
270
|
+
await modal.message("Error", `Failed to install: ${error}`, "error");
|
|
271
|
+
}
|
|
272
|
+
}, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
|
|
273
|
+
|
|
274
|
+
const handleUninstall = useCallback(async () => {
|
|
275
|
+
if (!selectedSkill || !selectedSkill.installed) return;
|
|
276
|
+
const scope = selectedSkill.installedScope;
|
|
277
|
+
if (!scope) return;
|
|
278
|
+
|
|
279
|
+
const confirmed = await modal.confirm(
|
|
280
|
+
`Uninstall "${selectedSkill.name}"?`,
|
|
281
|
+
`This will remove it from the ${scope} scope.`,
|
|
282
|
+
);
|
|
283
|
+
if (!confirmed) return;
|
|
284
|
+
|
|
285
|
+
modal.loading(`Uninstalling ${selectedSkill.name}...`);
|
|
286
|
+
try {
|
|
287
|
+
await uninstallSkill(selectedSkill.name, scope, state.projectPath);
|
|
288
|
+
modal.hideModal();
|
|
289
|
+
await fetchData();
|
|
290
|
+
await modal.message("Uninstalled", `${selectedSkill.name} removed.`, "success");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
modal.hideModal();
|
|
293
|
+
await modal.message("Error", `Failed to uninstall: ${error}`, "error");
|
|
294
|
+
}
|
|
295
|
+
}, [selectedSkill, state.projectPath, dispatch, modal, fetchData]);
|
|
296
|
+
|
|
297
|
+
// ── Keyboard handling ─────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
useKeyboard((event) => {
|
|
300
|
+
if (state.modal) return;
|
|
301
|
+
|
|
302
|
+
const hasQuery = skillsState.searchQuery.length > 0;
|
|
303
|
+
|
|
304
|
+
if (event.name === "escape") {
|
|
305
|
+
if (hasQuery || isSearchActive) {
|
|
306
|
+
dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
|
|
307
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
308
|
+
dispatch({ type: "SKILLS_SELECT", index: 0 });
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (event.name === "backspace" || event.name === "delete") {
|
|
314
|
+
if (hasQuery) {
|
|
315
|
+
const newQuery = skillsState.searchQuery.slice(0, -1);
|
|
316
|
+
dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
|
|
317
|
+
dispatch({ type: "SKILLS_SELECT", index: 0 });
|
|
318
|
+
if (!newQuery) dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (event.name === "up" || event.name === "k") {
|
|
324
|
+
if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
325
|
+
const newIndex = Math.max(0, skillsState.selectedIndex - 1);
|
|
326
|
+
dispatch({ type: "SKILLS_SELECT", index: newIndex });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (event.name === "down" || event.name === "j") {
|
|
330
|
+
if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
331
|
+
const newIndex = Math.min(
|
|
332
|
+
Math.max(0, allItems.length - 1),
|
|
333
|
+
skillsState.selectedIndex + 1,
|
|
334
|
+
);
|
|
335
|
+
dispatch({ type: "SKILLS_SELECT", index: newIndex });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (event.name === "return" || event.name === "enter") {
|
|
340
|
+
if (isSearchActive) {
|
|
341
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (selectedSkill && !selectedSkill.installed) {
|
|
345
|
+
handleInstall("project");
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (isSearchActive) {
|
|
351
|
+
if (event.name === "k" || event.name === "j") {
|
|
352
|
+
const delta = event.name === "k" ? -1 : 1;
|
|
353
|
+
const newIndex = Math.max(0, Math.min(allItems.length - 1, skillsState.selectedIndex + delta));
|
|
354
|
+
dispatch({ type: "SKILLS_SELECT", index: newIndex });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (event.name && event.name.length === 1 && !event.ctrl && !event.meta && !/[0-9]/.test(event.name)) {
|
|
358
|
+
dispatch({ type: "SKILLS_SET_SEARCH", query: skillsState.searchQuery + event.name });
|
|
359
|
+
dispatch({ type: "SKILLS_SELECT", index: 0 });
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (event.name === "u" && selectedSkill) {
|
|
365
|
+
if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
|
|
366
|
+
else handleInstall("user");
|
|
367
|
+
} else if (event.name === "p" && selectedSkill) {
|
|
368
|
+
if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
|
|
369
|
+
else handleInstall("project");
|
|
370
|
+
} else if (event.name === "r") {
|
|
371
|
+
fetchData();
|
|
372
|
+
} else if (event.name === "o" && selectedSkill) {
|
|
373
|
+
const repo = selectedSkill.source.repo;
|
|
374
|
+
const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
|
|
375
|
+
if (repo && repo !== "local") {
|
|
376
|
+
const url = `https://github.com/${repo}/tree/main/${repoPath}`;
|
|
377
|
+
import("node:child_process").then(({ execSync: exec }) => {
|
|
378
|
+
try { exec(`open "${url}"`); } catch { /* ignore */ }
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
} else if (event.name === "/") {
|
|
382
|
+
dispatch({ type: "SET_SEARCHING", isSearching: true });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ── Status line ───────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
const skills =
|
|
389
|
+
skillsState.skills.status === "success" ? skillsState.skills.data : [];
|
|
390
|
+
const installedCount = skills.filter((s) => s.installed).length;
|
|
391
|
+
const query = skillsState.searchQuery.trim();
|
|
392
|
+
|
|
393
|
+
const statusContent = (
|
|
394
|
+
<text>
|
|
395
|
+
<span fg="gray">Skills: </span>
|
|
396
|
+
<span fg="cyan">{installedCount} installed</span>
|
|
397
|
+
{query.length >= 2 && isSearchLoading && (
|
|
398
|
+
<span fg="yellow"> │ searching...</span>
|
|
399
|
+
)}
|
|
400
|
+
{query.length >= 2 && !isSearchLoading && searchResults.length > 0 && (
|
|
401
|
+
<span fg="green"> │ {searchResults.length} found</span>
|
|
402
|
+
)}
|
|
403
|
+
{!query && <span fg="gray"> │ 89K+ searchable</span>}
|
|
404
|
+
</text>
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<ScreenLayout
|
|
411
|
+
title="claudeup Skills"
|
|
412
|
+
currentScreen="skills"
|
|
413
|
+
statusLine={statusContent}
|
|
414
|
+
search={
|
|
415
|
+
skillsState.searchQuery || isSearchActive
|
|
416
|
+
? {
|
|
417
|
+
isActive: isSearchActive,
|
|
418
|
+
query: skillsState.searchQuery,
|
|
419
|
+
placeholder: "type to search",
|
|
420
|
+
}
|
|
421
|
+
: undefined
|
|
422
|
+
}
|
|
423
|
+
footerHints={
|
|
424
|
+
isSearchActive
|
|
425
|
+
? "type to filter │ Enter:done │ Esc:clear"
|
|
426
|
+
: "u:user │ p:project │ o:open │ /:search"
|
|
427
|
+
}
|
|
428
|
+
listPanel={
|
|
429
|
+
<box flexDirection="column">
|
|
430
|
+
<ScrollableList
|
|
431
|
+
items={allItems}
|
|
432
|
+
selectedIndex={skillsState.selectedIndex}
|
|
433
|
+
renderItem={renderSkillRow}
|
|
434
|
+
maxHeight={dimensions.listPanelHeight}
|
|
435
|
+
/>
|
|
436
|
+
{!query && skillsState.skills.status === "loading" && (
|
|
437
|
+
<box marginTop={2} paddingLeft={2}>
|
|
438
|
+
<text fg="yellow">Loading popular skills...</text>
|
|
439
|
+
</box>
|
|
440
|
+
)}
|
|
441
|
+
{query.length >= 2 && isSearchLoading && (
|
|
442
|
+
<box marginTop={2} paddingLeft={2}>
|
|
443
|
+
<text fg="yellow">Searching for "{skillsState.searchQuery}"...</text>
|
|
444
|
+
</box>
|
|
445
|
+
)}
|
|
446
|
+
{query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (
|
|
447
|
+
<EmptyFilterState query={skillsState.searchQuery} entityName="skills" />
|
|
448
|
+
)}
|
|
449
|
+
</box>
|
|
450
|
+
}
|
|
451
|
+
detailPanel={renderSkillDetail(selectedItem)}
|
|
452
|
+
/>
|
|
453
|
+
);
|
|
814
454
|
}
|
|
815
455
|
|
|
816
456
|
export default SkillsScreen;
|