claudeup 4.19.0 → 4.23.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.
Files changed (182) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/cli-router.test.ts +65 -0
  7. package/src/__tests__/conventions-integration.test.ts +745 -0
  8. package/src/__tests__/conventions-manager.test.ts +1172 -0
  9. package/src/__tests__/doctor-bins.test.ts +35 -0
  10. package/src/__tests__/doctor.test.ts +450 -0
  11. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  12. package/src/__tests__/file-locking.test.ts +208 -0
  13. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  14. package/src/__tests__/install-command.test.ts +81 -0
  15. package/src/__tests__/install-plan.test.ts +98 -0
  16. package/src/__tests__/manifest.test.ts +136 -0
  17. package/src/__tests__/marketplace-badge.test.ts +91 -0
  18. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  19. package/src/__tests__/plugin-requires.test.ts +133 -0
  20. package/src/__tests__/plugin-setup.test.ts +7 -0
  21. package/src/__tests__/profile-command.test.ts +116 -0
  22. package/src/__tests__/profile-materializer.test.ts +82 -0
  23. package/src/__tests__/profile-sync.test.ts +136 -0
  24. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  25. package/src/__tests__/resolve-executable.test.ts +42 -0
  26. package/src/__tests__/resolver.test.ts +218 -0
  27. package/src/__tests__/symlink-manager.test.ts +99 -0
  28. package/src/__tests__/toolchain.test.ts +56 -0
  29. package/src/cli/claude.ts +21 -0
  30. package/src/cli/doctor.ts +132 -0
  31. package/src/cli/install.ts +360 -0
  32. package/src/cli/profile.ts +166 -0
  33. package/src/cli/router.ts +107 -0
  34. package/src/cli/update.ts +85 -0
  35. package/src/data/cli-tools.ts +7 -7
  36. package/src/data/gitignore-defaults.ts +4 -0
  37. package/src/data/marketplaces.ts +12 -0
  38. package/src/data/predefined-profiles.ts +3 -4
  39. package/src/main.tsx +11 -154
  40. package/src/prerunner/index.ts +62 -27
  41. package/src/services/claude-cli.ts +19 -22
  42. package/src/services/claude-settings.ts +99 -26
  43. package/src/services/conventions-manager.ts +865 -0
  44. package/src/services/doctor-bins.ts +49 -0
  45. package/src/services/doctor.ts +509 -0
  46. package/src/services/install-plan.ts +107 -0
  47. package/src/services/manifest.ts +166 -0
  48. package/src/services/marketplace-refresh.ts +173 -0
  49. package/src/services/plugin-manager.ts +7 -1
  50. package/src/services/plugin-requires.ts +215 -0
  51. package/src/services/plugin-version-check.ts +9 -4
  52. package/src/services/profile-materializer.ts +61 -0
  53. package/src/services/profile-sync.ts +150 -0
  54. package/src/services/resolver.ts +293 -0
  55. package/src/services/symlink-manager.ts +146 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/version-check.ts +10 -11
  58. package/src/types/index.ts +138 -31
  59. package/src/ui/App.tsx +0 -4
  60. package/src/ui/adapters/pluginsAdapter.ts +13 -0
  61. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  62. package/src/ui/screens/index.ts +0 -1
  63. package/src/ui/state/reducer.ts +0 -101
  64. package/src/ui/state/types.ts +0 -35
  65. package/src/utils/command-utils.ts +23 -0
  66. package/src/utils/file-locking.ts +144 -0
  67. package/src/data/alias-flags.js +0 -205
  68. package/src/data/cli-tools.js +0 -123
  69. package/src/data/gitignore-defaults.js +0 -24
  70. package/src/data/gitignore-reasons.js +0 -97
  71. package/src/data/gitignore-templates.js +0 -21
  72. package/src/data/marketplaces.js +0 -138
  73. package/src/data/mcp-servers.js +0 -509
  74. package/src/data/predefined-profiles.js +0 -248
  75. package/src/data/settings-catalog.js +0 -625
  76. package/src/data/skill-repos.js +0 -160
  77. package/src/data/statuslines.js +0 -159
  78. package/src/data/statuslines.ts +0 -188
  79. package/src/index.js +0 -4
  80. package/src/index.ts +0 -5
  81. package/src/main.js +0 -170
  82. package/src/prerunner/index.js +0 -252
  83. package/src/services/alias-settings.js +0 -51
  84. package/src/services/alias-shell-writer.js +0 -1018
  85. package/src/services/alias-store.js +0 -129
  86. package/src/services/claude-cli.js +0 -189
  87. package/src/services/claude-runner.js +0 -28
  88. package/src/services/claude-settings.js +0 -1223
  89. package/src/services/gitignore-detector.js +0 -155
  90. package/src/services/gitignore-fixer.js +0 -231
  91. package/src/services/gitignore-prerun.js +0 -46
  92. package/src/services/gitignore-resolver.js +0 -143
  93. package/src/services/gitignore-service.js +0 -117
  94. package/src/services/local-marketplace.js +0 -339
  95. package/src/services/marketplace-fetcher.js +0 -96
  96. package/src/services/marketplace-sync.js +0 -94
  97. package/src/services/mcp-registry.js +0 -87
  98. package/src/services/plugin-manager.js +0 -473
  99. package/src/services/plugin-mcp-config.js +0 -177
  100. package/src/services/plugin-setup.js +0 -499
  101. package/src/services/plugin-version-check.js +0 -262
  102. package/src/services/profiles.js +0 -161
  103. package/src/services/settings-manager.js +0 -243
  104. package/src/services/skills-manager.js +0 -393
  105. package/src/services/skillsmp-client.js +0 -87
  106. package/src/services/update-cache.js +0 -52
  107. package/src/services/version-check.js +0 -99
  108. package/src/types/gitignore.js +0 -6
  109. package/src/types/index.js +0 -1
  110. package/src/ui/App.js +0 -355
  111. package/src/ui/adapters/pluginsAdapter.js +0 -139
  112. package/src/ui/adapters/settingsAdapter.js +0 -111
  113. package/src/ui/adapters/skillsAdapter.js +0 -154
  114. package/src/ui/components/CategoryHeader.js +0 -9
  115. package/src/ui/components/EmptyFilterState.js +0 -4
  116. package/src/ui/components/FlagDetailEditor.js +0 -0
  117. package/src/ui/components/ScopeIndicator.js +0 -30
  118. package/src/ui/components/ScrollableList.js +0 -36
  119. package/src/ui/components/SearchInput.js +0 -19
  120. package/src/ui/components/StyledText.js +0 -39
  121. package/src/ui/components/TabBar.js +0 -19
  122. package/src/ui/components/layout/FooterHints.js +0 -29
  123. package/src/ui/components/layout/Panel.js +0 -6
  124. package/src/ui/components/layout/ProgressBar.js +0 -14
  125. package/src/ui/components/layout/ScopeTabs.js +0 -6
  126. package/src/ui/components/layout/ScreenLayout.js +0 -16
  127. package/src/ui/components/layout/index.js +0 -5
  128. package/src/ui/components/modals/ConfirmModal.js +0 -14
  129. package/src/ui/components/modals/InputModal.js +0 -5
  130. package/src/ui/components/modals/LoadingModal.js +0 -17
  131. package/src/ui/components/modals/MessageModal.js +0 -16
  132. package/src/ui/components/modals/ModalContainer.js +0 -137
  133. package/src/ui/components/modals/SelectModal.js +0 -18
  134. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  135. package/src/ui/components/modals/index.js +0 -6
  136. package/src/ui/components/primitives/ActionHints.js +0 -13
  137. package/src/ui/components/primitives/DetailSection.js +0 -7
  138. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  139. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  140. package/src/ui/components/primitives/MetaText.js +0 -8
  141. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  142. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  143. package/src/ui/components/primitives/SelectableRow.js +0 -5
  144. package/src/ui/components/primitives/index.js +0 -8
  145. package/src/ui/hooks/index.js +0 -4
  146. package/src/ui/hooks/useAsyncData.js +0 -77
  147. package/src/ui/hooks/useGitignoreModal.js +0 -74
  148. package/src/ui/hooks/useKeyboard.js +0 -13
  149. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  150. package/src/ui/hooks/useMismatchModal.js +0 -77
  151. package/src/ui/registry.js +0 -1
  152. package/src/ui/renderers/cliToolRenderers.js +0 -54
  153. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  154. package/src/ui/renderers/mcpRenderers.js +0 -26
  155. package/src/ui/renderers/pluginRenderers.js +0 -124
  156. package/src/ui/renderers/profileRenderers.js +0 -177
  157. package/src/ui/renderers/settingsRenderers.js +0 -73
  158. package/src/ui/renderers/skillRenderers.js +0 -138
  159. package/src/ui/screens/AliasScreen.js +0 -1111
  160. package/src/ui/screens/CliToolsScreen.js +0 -338
  161. package/src/ui/screens/EnvVarsScreen.js +0 -152
  162. package/src/ui/screens/GitignoreScreen.js +0 -328
  163. package/src/ui/screens/McpRegistryScreen.js +0 -238
  164. package/src/ui/screens/McpScreen.js +0 -176
  165. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  166. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  167. package/src/ui/screens/PluginsScreen.js +0 -769
  168. package/src/ui/screens/ProfilesScreen.js +0 -298
  169. package/src/ui/screens/SkillsScreen.js +0 -549
  170. package/src/ui/screens/StatusLineScreen.js +0 -206
  171. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  172. package/src/ui/screens/index.js +0 -10
  173. package/src/ui/state/AnimationContext.js +0 -34
  174. package/src/ui/state/AppContext.js +0 -162
  175. package/src/ui/state/DimensionsContext.js +0 -71
  176. package/src/ui/state/reducer.js +0 -547
  177. package/src/ui/state/types.js +0 -1
  178. package/src/ui/theme.js +0 -47
  179. package/src/utils/clipboard.js +0 -56
  180. package/src/utils/command-utils.js +0 -19
  181. package/src/utils/fuzzy-search.js +0 -101
  182. package/src/utils/string-utils.js +0 -62
@@ -1,549 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
- import { useEffect, useCallback, useMemo, useState, useRef } from "react";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import fs from "fs-extra";
6
- import { useApp, useModal } from "../state/AppContext.js";
7
- import { useDimensions } from "../state/DimensionsContext.js";
8
- import { useKeyboard } from "../hooks/useKeyboard.js";
9
- import { ScreenLayout } from "../components/layout/index.js";
10
- import { EmptyFilterState } from "../components/EmptyFilterState.js";
11
- import { fetchAvailableSkills, fetchSkillFrontmatter, fetchSkillSetSkills, installSkill, uninstallSkill, } from "../../services/skills-manager.js";
12
- import { searchSkills } from "../../services/skillsmp-client.js";
13
- import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS, RECOMMENDED_SKILL_SETS, classifyStarReliability } from "../../data/skill-repos.js";
14
- import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
15
- import { renderSkillRow, renderSkillDetail } from "../renderers/skillRenderers.js";
16
- import { ScrollableList } from "../components/ScrollableList.js";
17
- export function SkillsScreen() {
18
- const { state, dispatch } = useApp();
19
- const { skills: skillsState } = state;
20
- const modal = useModal();
21
- const dimensions = useDimensions();
22
- const isSearchActive = state.isSearching &&
23
- state.currentRoute.screen === "skills" &&
24
- !state.modal;
25
- // ── Data fetching ─────────────────────────────────────────────────────────
26
- const fetchData = useCallback(async () => {
27
- dispatch({ type: "SKILLS_DATA_LOADING" });
28
- try {
29
- const skills = await fetchAvailableSkills(DEFAULT_SKILL_REPOS, state.projectPath);
30
- dispatch({ type: "SKILLS_DATA_SUCCESS", skills });
31
- }
32
- catch (error) {
33
- dispatch({
34
- type: "SKILLS_DATA_ERROR",
35
- error: error instanceof Error ? error : new Error(String(error)),
36
- });
37
- }
38
- }, [dispatch, state.projectPath]);
39
- useEffect(() => {
40
- fetchData();
41
- }, [fetchData, state.dataRefreshVersion]);
42
- // ── Remote search (debounced, cached) ─────────────────────────────────────
43
- const [searchResults, setSearchResults] = useState([]);
44
- const [isSearchLoading, setIsSearchLoading] = useState(false);
45
- const [searchError, setSearchError] = useState(null);
46
- const searchTimerRef = useRef(null);
47
- const searchCacheRef = useRef(new Map());
48
- useEffect(() => {
49
- const query = skillsState.searchQuery.trim();
50
- if (query.length < 2) {
51
- setSearchResults([]);
52
- setIsSearchLoading(false);
53
- setSearchError(null);
54
- return;
55
- }
56
- const cached = searchCacheRef.current.get(query);
57
- if (cached) {
58
- setSearchResults(cached);
59
- setIsSearchLoading(false);
60
- setSearchError(null);
61
- return;
62
- }
63
- setIsSearchLoading(true);
64
- setSearchError(null);
65
- if (searchTimerRef.current)
66
- clearTimeout(searchTimerRef.current);
67
- searchTimerRef.current = setTimeout(async () => {
68
- try {
69
- const results = await searchSkills(query, { limit: 30 });
70
- const mapped = results.map((r) => {
71
- const source = {
72
- label: r.repo || "unknown",
73
- repo: r.repo || "unknown",
74
- skillsPath: "",
75
- };
76
- return {
77
- id: `remote:${r.repo}/${r.skillPath}`,
78
- name: r.name,
79
- description: r.description || "",
80
- source,
81
- repoPath: r.skillPath ? `${r.skillPath}/SKILL.md` : "SKILL.md",
82
- gitBlobSha: "",
83
- frontmatter: null,
84
- installed: false,
85
- installedScope: null,
86
- hasUpdate: false,
87
- stars: r.stars,
88
- starReliability: classifyStarReliability(r.repo || "unknown", r.stars),
89
- };
90
- });
91
- searchCacheRef.current.set(query, mapped);
92
- setSearchResults(mapped);
93
- setSearchError(null);
94
- }
95
- catch (error) {
96
- // A thrown error means the search service is unreachable — distinct
97
- // from a successful search that returned zero matches.
98
- setSearchResults([]);
99
- setSearchError(error instanceof Error ? error.message : "Search service unavailable");
100
- }
101
- setIsSearchLoading(false);
102
- }, 400);
103
- return () => {
104
- if (searchTimerRef.current)
105
- clearTimeout(searchTimerRef.current);
106
- };
107
- }, [skillsState.searchQuery]);
108
- // ── Disk scan for installed skills ────────────────────────────────────────
109
- const [installedFromDisk, setInstalledFromDisk] = useState({ user: new Set(), project: new Set() });
110
- const scanDisk = useCallback(async () => {
111
- const user = new Set();
112
- const project = new Set();
113
- const userDir = path.join(os.homedir(), ".claude", "skills");
114
- const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
115
- for (const [dir, set] of [[userDir, user], [projDir, project]]) {
116
- try {
117
- if (await fs.pathExists(dir)) {
118
- const entries = await fs.readdir(dir);
119
- for (const e of entries) {
120
- if (await fs.pathExists(path.join(dir, e, "SKILL.md")))
121
- set.add(e);
122
- }
123
- }
124
- }
125
- catch { /* ignore */ }
126
- }
127
- setInstalledFromDisk({ user, project });
128
- }, [state.projectPath]);
129
- useEffect(() => {
130
- scanDisk();
131
- }, [scanDisk, state.dataRefreshVersion]);
132
- // ── Skill Sets state ──────────────────────────────────────────────────────
133
- const [skillSets, setSkillSets] = useState(() => RECOMMENDED_SKILL_SETS.map((rs) => ({
134
- id: rs.repo,
135
- name: rs.name,
136
- description: rs.description,
137
- repo: rs.repo,
138
- icon: rs.icon,
139
- stars: rs.stars,
140
- skills: [],
141
- loaded: false,
142
- loading: false,
143
- })));
144
- const [expandedSets, setExpandedSets] = useState(new Set());
145
- // Re-mark installed status on child skills when disk state changes
146
- useEffect(() => {
147
- setSkillSets((prev) => prev.map((set) => {
148
- if (!set.loaded)
149
- return set;
150
- const updatedSkills = set.skills.map((skill) => {
151
- const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
152
- const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(skill.name);
153
- const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(skill.name);
154
- return {
155
- ...skill,
156
- installed: isUser || isProj,
157
- installedScope: isProj ? "project" : isUser ? "user" : null,
158
- };
159
- });
160
- return { ...set, skills: updatedSkills };
161
- }));
162
- }, [installedFromDisk]);
163
- const handleToggleSet = useCallback(async (setId) => {
164
- const isExpanded = expandedSets.has(setId);
165
- const newExpanded = new Set(expandedSets);
166
- if (isExpanded) {
167
- newExpanded.delete(setId);
168
- }
169
- else {
170
- newExpanded.add(setId);
171
- }
172
- setExpandedSets(newExpanded);
173
- // Fetch skills on first expand
174
- const set = skillSets.find((s) => s.id === setId);
175
- if (!isExpanded && set && !set.loaded && !set.loading) {
176
- setSkillSets((prev) => prev.map((s) => (s.id === setId ? { ...s, loading: true } : s)));
177
- try {
178
- const skills = await fetchSkillSetSkills(set.repo, state.projectPath);
179
- setSkillSets((prev) => prev.map((s) => s.id === setId
180
- ? { ...s, skills, loaded: true, loading: false, error: undefined }
181
- : s));
182
- }
183
- catch (error) {
184
- setSkillSets((prev) => prev.map((s) => s.id === setId
185
- ? {
186
- ...s,
187
- loading: false,
188
- error: error instanceof Error ? error.message : String(error),
189
- }
190
- : s));
191
- }
192
- }
193
- }, [expandedSets, skillSets, state.projectPath]);
194
- // Status bar message (auto-clears)
195
- const [statusMsg, setStatusMsg] = useState(null);
196
- const statusTimerRef = useRef(null);
197
- const showStatus = useCallback((text, tone = "success") => {
198
- setStatusMsg({ text, tone });
199
- if (statusTimerRef.current)
200
- clearTimeout(statusTimerRef.current);
201
- statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
202
- }, []);
203
- const handleInstallAllFromSet = useCallback(async (setId, scope) => {
204
- const set = skillSets.find((s) => s.id === setId);
205
- if (!set || !set.loaded)
206
- return;
207
- const toInstall = set.skills.filter((s) => !s.installed);
208
- if (toInstall.length === 0) {
209
- showStatus(`All ${set.name} skills already installed`);
210
- return;
211
- }
212
- showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
213
- let installed = 0;
214
- let failed = 0;
215
- for (const skill of toInstall) {
216
- try {
217
- await installSkill(skill, scope, state.projectPath);
218
- installed++;
219
- }
220
- catch {
221
- failed++;
222
- }
223
- }
224
- await scanDisk();
225
- if (failed > 0) {
226
- showStatus(`Installed ${installed}/${toInstall.length} (${failed} failed)`, "error");
227
- }
228
- else {
229
- showStatus(`Installed ${installed} skills from ${set.name} to ${scope}`);
230
- }
231
- }, [skillSets, state.projectPath, scanDisk, showStatus]);
232
- // ── Derived data ──────────────────────────────────────────────────────────
233
- const staticRecommended = useMemo(() => {
234
- return RECOMMENDED_SKILLS.map((r) => {
235
- const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
236
- const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
237
- const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
238
- return {
239
- id: `rec:${r.repo}/${r.skillPath}`,
240
- name: r.name,
241
- description: r.description,
242
- source: { label: r.repo, repo: r.repo, skillsPath: "" },
243
- repoPath: `${r.skillPath}/SKILL.md`,
244
- gitBlobSha: "",
245
- frontmatter: null,
246
- installed: isUser || isProj,
247
- installedScope: isProj ? "project" : isUser ? "user" : null,
248
- hasUpdate: false,
249
- isRecommended: true,
250
- stars: r.stars,
251
- starReliability: classifyStarReliability(r.repo, r.stars),
252
- };
253
- });
254
- }, [installedFromDisk]);
255
- const mergedRecommended = useMemo(() => {
256
- if (skillsState.skills.status !== "success")
257
- return staticRecommended;
258
- const fetched = skillsState.skills.data.filter((s) => s.isRecommended);
259
- return staticRecommended.map((staticSkill) => {
260
- const match = fetched.find((f) => f.source.repo === staticSkill.source.repo && f.name === staticSkill.name);
261
- if (!match)
262
- return staticSkill;
263
- // Merge: prefer fetched data for description/stars, but disk scan is
264
- // authoritative for installed state (staticSkill reflects current disk)
265
- return {
266
- ...staticSkill,
267
- ...match,
268
- installed: staticSkill.installed,
269
- installedScope: staticSkill.installedScope,
270
- stars: match.stars || staticSkill.stars,
271
- };
272
- });
273
- }, [staticRecommended, skillsState.skills]);
274
- const installedSkills = useMemo(() => {
275
- const all = [];
276
- for (const [scope, names] of [
277
- ["user", installedFromDisk.user],
278
- ["project", installedFromDisk.project],
279
- ]) {
280
- for (const name of names) {
281
- if (all.some((s) => s.name === name))
282
- continue;
283
- all.push({
284
- id: `installed:${scope}/${name}`,
285
- name,
286
- description: "",
287
- source: { label: "local", repo: "local", skillsPath: "" },
288
- repoPath: "",
289
- gitBlobSha: "",
290
- frontmatter: null,
291
- installed: true,
292
- installedScope: scope,
293
- hasUpdate: false,
294
- stars: undefined,
295
- });
296
- }
297
- }
298
- return all;
299
- }, [installedFromDisk]);
300
- const popularSkills = useMemo(() => {
301
- if (skillsState.skills.status !== "success")
302
- return [];
303
- return skillsState.skills.data
304
- .filter((s) => !s.isRecommended)
305
- .sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
306
- }, [skillsState.skills]);
307
- // ── List items (built by adapter) ─────────────────────────────────────────
308
- const allItems = useMemo(() => buildSkillBrowserItems({
309
- recommended: mergedRecommended,
310
- popular: popularSkills,
311
- installed: installedSkills,
312
- searchResults,
313
- query: skillsState.searchQuery,
314
- isSearchLoading,
315
- skillSets,
316
- expandedSets,
317
- }), [
318
- mergedRecommended,
319
- popularSkills,
320
- installedSkills,
321
- searchResults,
322
- skillsState.searchQuery,
323
- isSearchLoading,
324
- skillSets,
325
- expandedSets,
326
- ]);
327
- // Auto-correct selection if it lands on a category header (e.g. initial load, reset)
328
- useEffect(() => {
329
- const item = allItems[skillsState.selectedIndex];
330
- if (item && item.kind === "category") {
331
- const firstSelectable = allItems.findIndex((i) => i.kind === "skill" || i.kind === "skillset");
332
- if (firstSelectable >= 0)
333
- dispatch({ type: "SKILLS_SELECT", index: firstSelectable });
334
- }
335
- }, [allItems, skillsState.selectedIndex, dispatch]);
336
- const selectedItem = allItems[skillsState.selectedIndex];
337
- const selectedSkill = selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
338
- const selectedSet = selectedItem?.kind === "skillset" ? selectedItem.skillSet : undefined;
339
- // ── Lazy-load frontmatter for selected skill ───────────────────────────────
340
- // Check if selected skill belongs to a skill set (lives in local state, not reducer)
341
- const isSkillSetChild = selectedSkill
342
- ? skillSets.some((s) => s.loaded && s.skills.some((sk) => sk.id === selectedSkill.id))
343
- : false;
344
- useEffect(() => {
345
- if (!selectedSkill || selectedSkill.frontmatter)
346
- return;
347
- fetchSkillFrontmatter(selectedSkill).then((fm) => {
348
- if (isSkillSetChild) {
349
- // Update the local skillSets state
350
- setSkillSets((prev) => prev.map((set) => ({
351
- ...set,
352
- skills: set.skills.map((sk) => sk.id === selectedSkill.id ? { ...sk, frontmatter: fm } : sk),
353
- })));
354
- }
355
- else {
356
- dispatch({
357
- type: "SKILLS_UPDATE_ITEM",
358
- name: selectedSkill.name,
359
- updates: { frontmatter: fm },
360
- });
361
- }
362
- }).catch(() => { });
363
- }, [selectedSkill?.id, isSkillSetChild, dispatch]);
364
- // ── Action handlers ───────────────────────────────────────────────────────
365
- const handleInstall = useCallback(async (scope) => {
366
- if (!selectedSkill)
367
- return;
368
- try {
369
- await installSkill(selectedSkill, scope, state.projectPath);
370
- await scanDisk(); // Re-scan disk only — no network re-fetch needed
371
- showStatus(`Installed ${selectedSkill.name} to ${scope}`);
372
- }
373
- catch (error) {
374
- showStatus(`Failed: ${error}`, "error");
375
- }
376
- }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
377
- const handleUninstall = useCallback(async () => {
378
- if (!selectedSkill || !selectedSkill.installed)
379
- return;
380
- const scope = selectedSkill.installedScope;
381
- if (!scope)
382
- return;
383
- try {
384
- await uninstallSkill(selectedSkill.name, scope, state.projectPath);
385
- await scanDisk(); // Re-scan disk only — no network re-fetch needed
386
- showStatus(`Removed ${selectedSkill.name} from ${scope}`);
387
- }
388
- catch (error) {
389
- showStatus(`Failed: ${error}`, "error");
390
- }
391
- }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
392
- // ── Keyboard handling ─────────────────────────────────────────────────────
393
- useKeyboard((event) => {
394
- if (state.modal)
395
- return;
396
- const hasQuery = skillsState.searchQuery.length > 0;
397
- if (event.name === "escape") {
398
- if (hasQuery || isSearchActive) {
399
- dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
400
- dispatch({ type: "SET_SEARCHING", isSearching: false });
401
- dispatch({ type: "SKILLS_SELECT", index: 0 });
402
- }
403
- return;
404
- }
405
- if (event.name === "backspace" || event.name === "delete") {
406
- if (hasQuery) {
407
- const newQuery = skillsState.searchQuery.slice(0, -1);
408
- dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
409
- dispatch({ type: "SKILLS_SELECT", index: 0 });
410
- if (!newQuery)
411
- dispatch({ type: "SET_SEARCHING", isSearching: false });
412
- }
413
- return;
414
- }
415
- if (event.name === "up" || event.name === "k") {
416
- if (isSearchActive)
417
- dispatch({ type: "SET_SEARCHING", isSearching: false });
418
- let newIndex = skillsState.selectedIndex - 1;
419
- // Skip category headers
420
- while (newIndex >= 0 && allItems[newIndex]?.kind === "category")
421
- newIndex--;
422
- if (newIndex >= 0)
423
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
424
- return;
425
- }
426
- if (event.name === "down" || event.name === "j") {
427
- if (isSearchActive)
428
- dispatch({ type: "SET_SEARCHING", isSearching: false });
429
- let newIndex = skillsState.selectedIndex + 1;
430
- // Skip category headers
431
- while (newIndex < allItems.length && allItems[newIndex]?.kind === "category")
432
- newIndex++;
433
- if (newIndex < allItems.length)
434
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
435
- return;
436
- }
437
- if (event.name === "return" || event.name === "enter") {
438
- if (isSearchActive) {
439
- dispatch({ type: "SET_SEARCHING", isSearching: false });
440
- return;
441
- }
442
- if (selectedSet) {
443
- handleToggleSet(selectedSet.id);
444
- return;
445
- }
446
- if (selectedSkill && !selectedSkill.installed) {
447
- handleInstall("project");
448
- }
449
- return;
450
- }
451
- // Action keys only when NOT actively typing in search
452
- if (!isSearchActive) {
453
- if (event.name === "u") {
454
- if (selectedSet) {
455
- handleInstallAllFromSet(selectedSet.id, "user");
456
- return;
457
- }
458
- if (selectedSkill) {
459
- if (selectedSkill.installed && selectedSkill.installedScope === "user")
460
- handleUninstall();
461
- else
462
- handleInstall("user");
463
- return;
464
- }
465
- }
466
- else if (event.name === "p") {
467
- if (selectedSet) {
468
- handleInstallAllFromSet(selectedSet.id, "project");
469
- return;
470
- }
471
- if (selectedSkill) {
472
- if (selectedSkill.installed && selectedSkill.installedScope === "project")
473
- handleUninstall();
474
- else
475
- handleInstall("project");
476
- return;
477
- }
478
- }
479
- }
480
- if (isSearchActive) {
481
- if (event.name === "k" || event.name === "j") {
482
- const delta = event.name === "k" ? -1 : 1;
483
- const newIndex = Math.max(0, Math.min(allItems.length - 1, skillsState.selectedIndex + delta));
484
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
485
- return;
486
- }
487
- if (event.name && event.name.length === 1 && !event.ctrl && !event.meta && !/[0-9]/.test(event.name)) {
488
- dispatch({ type: "SKILLS_SET_SEARCH", query: skillsState.searchQuery + event.name });
489
- dispatch({ type: "SKILLS_SELECT", index: 0 });
490
- }
491
- return;
492
- }
493
- if (event.name === "r") {
494
- fetchData();
495
- }
496
- else if (event.name === "o" && (selectedSkill || selectedSet)) {
497
- if (selectedSet) {
498
- const url = `https://github.com/${selectedSet.repo}`;
499
- import("node:child_process").then(({ execSync: exec }) => {
500
- try {
501
- exec(`open "${url}"`);
502
- }
503
- catch { /* ignore */ }
504
- });
505
- }
506
- else if (selectedSkill) {
507
- const repo = selectedSkill.source.repo;
508
- const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
509
- if (repo && repo !== "local") {
510
- const url = `https://github.com/${repo}/tree/main/${repoPath}`;
511
- import("node:child_process").then(({ execSync: exec }) => {
512
- try {
513
- exec(`open "${url}"`);
514
- }
515
- catch { /* ignore */ }
516
- });
517
- }
518
- }
519
- }
520
- else if (event.name === "/") {
521
- dispatch({ type: "SET_SEARCHING", isSearching: true });
522
- }
523
- });
524
- // ── Status line ───────────────────────────────────────────────────────────
525
- const skills = skillsState.skills.status === "success" ? skillsState.skills.data : [];
526
- const installedCount = skills.filter((s) => s.installed).length;
527
- const query = skillsState.searchQuery.trim();
528
- const statusContent = statusMsg ? (_jsx("text", { children: _jsx("span", { fg: statusMsg.tone === "success" ? "green" : "red", children: statusMsg.text }) })) : (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Skills: " }), _jsxs("span", { fg: "cyan", children: [installedCount, " installed"] }), query.length >= 2 && isSearchLoading && (_jsx("span", { fg: "yellow", children: " \u2502 searching..." })), query.length >= 2 && !isSearchLoading && !searchError && searchResults.length > 0 && (_jsxs("span", { fg: "green", children: [" \u2502 ", searchResults.length, " found"] })), query.length >= 2 && !isSearchLoading && searchError && (_jsx("span", { fg: "red", children: " \u2502 search unavailable" })), !query && _jsx("span", { fg: "gray", children: " \u2502 89K+ searchable" })] }));
529
- // ── Render ────────────────────────────────────────────────────────────────
530
- return (_jsx(ScreenLayout, { title: "claudeup Skills", currentScreen: "skills", statusLine: statusContent, search: skillsState.searchQuery || isSearchActive
531
- ? {
532
- isActive: isSearchActive,
533
- query: skillsState.searchQuery,
534
- placeholder: "type to search",
535
- }
536
- : undefined, footerHints: isSearchActive
537
- ? [
538
- { keys: ["type"], label: "filter" },
539
- { keys: ["Enter"], label: "done" },
540
- { keys: ["Esc"], label: "clear" },
541
- ]
542
- : [
543
- { keys: ["u"], label: "user" },
544
- { keys: ["p"], label: "project" },
545
- { keys: ["o"], label: "open" },
546
- { keys: ["/"], label: "search" },
547
- ], 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 && searchError && (_jsxs("box", { flexDirection: "column", marginTop: 2, paddingLeft: 2, paddingRight: 2, children: [_jsx("text", { fg: "red", children: "Skill search is unavailable right now." }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: "gray", children: "The search service could not be reached" }) }), _jsxs("text", { fg: "gray", children: ["(", searchError, "). Check your connection and retry."] }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: "gray", children: "Press Esc to clear the search." }) })] })), query.length >= 2 && !isSearchLoading && !searchError && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderSkillDetail(selectedItem) }));
548
- }
549
- export default SkillsScreen;