claudeup 4.19.0 → 4.22.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 (180) 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-refresh.test.ts +267 -0
  18. package/src/__tests__/plugin-requires.test.ts +133 -0
  19. package/src/__tests__/plugin-setup.test.ts +7 -0
  20. package/src/__tests__/profile-command.test.ts +116 -0
  21. package/src/__tests__/profile-materializer.test.ts +82 -0
  22. package/src/__tests__/profile-sync.test.ts +136 -0
  23. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  24. package/src/__tests__/resolve-executable.test.ts +42 -0
  25. package/src/__tests__/resolver.test.ts +218 -0
  26. package/src/__tests__/symlink-manager.test.ts +99 -0
  27. package/src/__tests__/toolchain.test.ts +56 -0
  28. package/src/cli/claude.ts +21 -0
  29. package/src/cli/doctor.ts +132 -0
  30. package/src/cli/install.ts +360 -0
  31. package/src/cli/profile.ts +166 -0
  32. package/src/cli/router.ts +107 -0
  33. package/src/cli/update.ts +85 -0
  34. package/src/data/cli-tools.ts +7 -7
  35. package/src/data/gitignore-defaults.ts +4 -0
  36. package/src/data/marketplaces.ts +12 -0
  37. package/src/data/predefined-profiles.ts +3 -4
  38. package/src/main.tsx +11 -154
  39. package/src/prerunner/index.ts +62 -27
  40. package/src/services/claude-cli.ts +19 -22
  41. package/src/services/claude-settings.ts +99 -26
  42. package/src/services/conventions-manager.ts +865 -0
  43. package/src/services/doctor-bins.ts +49 -0
  44. package/src/services/doctor.ts +509 -0
  45. package/src/services/install-plan.ts +107 -0
  46. package/src/services/manifest.ts +166 -0
  47. package/src/services/marketplace-refresh.ts +173 -0
  48. package/src/services/plugin-manager.ts +7 -1
  49. package/src/services/plugin-requires.ts +215 -0
  50. package/src/services/plugin-version-check.ts +9 -4
  51. package/src/services/profile-materializer.ts +61 -0
  52. package/src/services/profile-sync.ts +150 -0
  53. package/src/services/resolver.ts +293 -0
  54. package/src/services/symlink-manager.ts +146 -0
  55. package/src/services/toolchain.ts +97 -0
  56. package/src/services/version-check.ts +10 -11
  57. package/src/types/index.ts +138 -31
  58. package/src/ui/App.tsx +0 -4
  59. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  60. package/src/ui/screens/index.ts +0 -1
  61. package/src/ui/state/reducer.ts +0 -101
  62. package/src/ui/state/types.ts +0 -35
  63. package/src/utils/command-utils.ts +23 -0
  64. package/src/utils/file-locking.ts +144 -0
  65. package/src/data/alias-flags.js +0 -205
  66. package/src/data/cli-tools.js +0 -123
  67. package/src/data/gitignore-defaults.js +0 -24
  68. package/src/data/gitignore-reasons.js +0 -97
  69. package/src/data/gitignore-templates.js +0 -21
  70. package/src/data/marketplaces.js +0 -138
  71. package/src/data/mcp-servers.js +0 -509
  72. package/src/data/predefined-profiles.js +0 -248
  73. package/src/data/settings-catalog.js +0 -625
  74. package/src/data/skill-repos.js +0 -160
  75. package/src/data/statuslines.js +0 -159
  76. package/src/data/statuslines.ts +0 -188
  77. package/src/index.js +0 -4
  78. package/src/index.ts +0 -5
  79. package/src/main.js +0 -170
  80. package/src/prerunner/index.js +0 -252
  81. package/src/services/alias-settings.js +0 -51
  82. package/src/services/alias-shell-writer.js +0 -1018
  83. package/src/services/alias-store.js +0 -129
  84. package/src/services/claude-cli.js +0 -189
  85. package/src/services/claude-runner.js +0 -28
  86. package/src/services/claude-settings.js +0 -1223
  87. package/src/services/gitignore-detector.js +0 -155
  88. package/src/services/gitignore-fixer.js +0 -231
  89. package/src/services/gitignore-prerun.js +0 -46
  90. package/src/services/gitignore-resolver.js +0 -143
  91. package/src/services/gitignore-service.js +0 -117
  92. package/src/services/local-marketplace.js +0 -339
  93. package/src/services/marketplace-fetcher.js +0 -96
  94. package/src/services/marketplace-sync.js +0 -94
  95. package/src/services/mcp-registry.js +0 -87
  96. package/src/services/plugin-manager.js +0 -473
  97. package/src/services/plugin-mcp-config.js +0 -177
  98. package/src/services/plugin-setup.js +0 -499
  99. package/src/services/plugin-version-check.js +0 -262
  100. package/src/services/profiles.js +0 -161
  101. package/src/services/settings-manager.js +0 -243
  102. package/src/services/skills-manager.js +0 -393
  103. package/src/services/skillsmp-client.js +0 -87
  104. package/src/services/update-cache.js +0 -52
  105. package/src/services/version-check.js +0 -99
  106. package/src/types/gitignore.js +0 -6
  107. package/src/types/index.js +0 -1
  108. package/src/ui/App.js +0 -355
  109. package/src/ui/adapters/pluginsAdapter.js +0 -139
  110. package/src/ui/adapters/settingsAdapter.js +0 -111
  111. package/src/ui/adapters/skillsAdapter.js +0 -154
  112. package/src/ui/components/CategoryHeader.js +0 -9
  113. package/src/ui/components/EmptyFilterState.js +0 -4
  114. package/src/ui/components/FlagDetailEditor.js +0 -0
  115. package/src/ui/components/ScopeIndicator.js +0 -30
  116. package/src/ui/components/ScrollableList.js +0 -36
  117. package/src/ui/components/SearchInput.js +0 -19
  118. package/src/ui/components/StyledText.js +0 -39
  119. package/src/ui/components/TabBar.js +0 -19
  120. package/src/ui/components/layout/FooterHints.js +0 -29
  121. package/src/ui/components/layout/Panel.js +0 -6
  122. package/src/ui/components/layout/ProgressBar.js +0 -14
  123. package/src/ui/components/layout/ScopeTabs.js +0 -6
  124. package/src/ui/components/layout/ScreenLayout.js +0 -16
  125. package/src/ui/components/layout/index.js +0 -5
  126. package/src/ui/components/modals/ConfirmModal.js +0 -14
  127. package/src/ui/components/modals/InputModal.js +0 -5
  128. package/src/ui/components/modals/LoadingModal.js +0 -17
  129. package/src/ui/components/modals/MessageModal.js +0 -16
  130. package/src/ui/components/modals/ModalContainer.js +0 -137
  131. package/src/ui/components/modals/SelectModal.js +0 -18
  132. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  133. package/src/ui/components/modals/index.js +0 -6
  134. package/src/ui/components/primitives/ActionHints.js +0 -13
  135. package/src/ui/components/primitives/DetailSection.js +0 -7
  136. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  137. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  138. package/src/ui/components/primitives/MetaText.js +0 -8
  139. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  140. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  141. package/src/ui/components/primitives/SelectableRow.js +0 -5
  142. package/src/ui/components/primitives/index.js +0 -8
  143. package/src/ui/hooks/index.js +0 -4
  144. package/src/ui/hooks/useAsyncData.js +0 -77
  145. package/src/ui/hooks/useGitignoreModal.js +0 -74
  146. package/src/ui/hooks/useKeyboard.js +0 -13
  147. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  148. package/src/ui/hooks/useMismatchModal.js +0 -77
  149. package/src/ui/registry.js +0 -1
  150. package/src/ui/renderers/cliToolRenderers.js +0 -54
  151. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  152. package/src/ui/renderers/mcpRenderers.js +0 -26
  153. package/src/ui/renderers/pluginRenderers.js +0 -124
  154. package/src/ui/renderers/profileRenderers.js +0 -177
  155. package/src/ui/renderers/settingsRenderers.js +0 -73
  156. package/src/ui/renderers/skillRenderers.js +0 -138
  157. package/src/ui/screens/AliasScreen.js +0 -1111
  158. package/src/ui/screens/CliToolsScreen.js +0 -338
  159. package/src/ui/screens/EnvVarsScreen.js +0 -152
  160. package/src/ui/screens/GitignoreScreen.js +0 -328
  161. package/src/ui/screens/McpRegistryScreen.js +0 -238
  162. package/src/ui/screens/McpScreen.js +0 -176
  163. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  164. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  165. package/src/ui/screens/PluginsScreen.js +0 -769
  166. package/src/ui/screens/ProfilesScreen.js +0 -298
  167. package/src/ui/screens/SkillsScreen.js +0 -549
  168. package/src/ui/screens/StatusLineScreen.js +0 -206
  169. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  170. package/src/ui/screens/index.js +0 -10
  171. package/src/ui/state/AnimationContext.js +0 -34
  172. package/src/ui/state/AppContext.js +0 -162
  173. package/src/ui/state/DimensionsContext.js +0 -71
  174. package/src/ui/state/reducer.js +0 -547
  175. package/src/ui/state/types.js +0 -1
  176. package/src/ui/theme.js +0 -47
  177. package/src/utils/clipboard.js +0 -56
  178. package/src/utils/command-utils.js +0 -19
  179. package/src/utils/fuzzy-search.js +0 -101
  180. 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;