@skillsgate/tui 0.1.1

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 (41) hide show
  1. package/bin/skillsgate-tui +28 -0
  2. package/bunfig.toml +3 -0
  3. package/package.json +24 -0
  4. package/src/app.tsx +18 -0
  5. package/src/components/agent-filter.tsx +162 -0
  6. package/src/components/confirm-dialog.tsx +56 -0
  7. package/src/components/help-overlay.tsx +101 -0
  8. package/src/components/layout.tsx +272 -0
  9. package/src/components/search-input.tsx +48 -0
  10. package/src/components/skill-list-item.tsx +45 -0
  11. package/src/components/skill-list.tsx +245 -0
  12. package/src/components/status-bar.tsx +34 -0
  13. package/src/data/api-client.ts +151 -0
  14. package/src/data/use-agents.ts +41 -0
  15. package/src/data/use-auth.ts +136 -0
  16. package/src/data/use-favorites.ts +147 -0
  17. package/src/data/use-installed-skills.ts +128 -0
  18. package/src/data/use-search.ts +118 -0
  19. package/src/data/use-skill-actions.ts +333 -0
  20. package/src/db/context.tsx +38 -0
  21. package/src/db/index.ts +19 -0
  22. package/src/db/migrations.ts +72 -0
  23. package/src/db/servers.ts +154 -0
  24. package/src/db/settings.ts +43 -0
  25. package/src/db/skills.ts +138 -0
  26. package/src/db/ssh.ts +319 -0
  27. package/src/index.tsx +37 -0
  28. package/src/store/context.tsx +26 -0
  29. package/src/store/reducers.ts +126 -0
  30. package/src/store/types.ts +124 -0
  31. package/src/utils/colors.ts +42 -0
  32. package/src/views/add-server.tsx +240 -0
  33. package/src/views/discover.tsx +419 -0
  34. package/src/views/favorites.tsx +358 -0
  35. package/src/views/home.tsx +218 -0
  36. package/src/views/login.tsx +202 -0
  37. package/src/views/server-skills.tsx +269 -0
  38. package/src/views/servers.tsx +449 -0
  39. package/src/views/settings.tsx +185 -0
  40. package/src/views/skill-detail.tsx +497 -0
  41. package/tsconfig.json +18 -0
@@ -0,0 +1,358 @@
1
+ import { useState, useMemo, useEffect } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useFavorites } from "../data/use-favorites.js"
5
+ import { useSkillActions } from "../data/use-skill-actions.js"
6
+ import { ConfirmDialog } from "../components/confirm-dialog.js"
7
+ import { colors } from "../utils/colors.js"
8
+ import type { CatalogSkill } from "../data/api-client.js"
9
+ import type { EnrichedSkill } from "../store/types.js"
10
+
11
+ /**
12
+ * Favorites view: two-column layout.
13
+ * LEFT - Favorites list (40%)
14
+ * RIGHT - Selected favorite detail (flexGrow)
15
+ *
16
+ * Requires authentication. Shows a prompt to login if not authenticated.
17
+ */
18
+ export function FavoritesView() {
19
+ const state = useStore()
20
+ const dispatch = useDispatch()
21
+ const { favorites, loading, error, toggle } = useFavorites()
22
+ const { installSkill } = useSkillActions()
23
+ const [selectedIndex, setSelectedIndex] = useState(0)
24
+ const [installTarget, setInstallTarget] = useState<CatalogSkill | null>(null)
25
+ const [previewSkill, setPreviewSkill] = useState<CatalogSkill | null>(null)
26
+
27
+ // Build a set of installed skill names for the "installed" badge
28
+ const installedNames = useMemo(() => {
29
+ return new Set(state.installedSkills.map((s) => s.name.toLowerCase()))
30
+ }, [state.installedSkills])
31
+
32
+ // Update preview when selection changes
33
+ useEffect(() => {
34
+ if (favorites[selectedIndex]) {
35
+ setPreviewSkill(favorites[selectedIndex])
36
+ } else {
37
+ setPreviewSkill(null)
38
+ }
39
+ }, [selectedIndex, favorites])
40
+
41
+ // Keyboard navigation for the favorites list
42
+ useKeyboard((key) => {
43
+ if (state.activeView !== "favorites") return
44
+ if (state.showHelp) return
45
+ if (!state.auth) return // No navigation when not logged in
46
+ if (installTarget) return // Block navigation during confirm dialog
47
+
48
+ // j/k or arrow keys
49
+ if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
50
+ setSelectedIndex((i) => Math.max(0, i - 1))
51
+ }
52
+ if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
53
+ setSelectedIndex((i) => Math.min(favorites.length - 1, i + 1))
54
+ }
55
+
56
+ // g = first, G = last
57
+ if (key.name === "g" && !key.shift) {
58
+ setSelectedIndex(0)
59
+ }
60
+ if (key.name === "g" && key.shift) {
61
+ setSelectedIndex(Math.max(0, favorites.length - 1))
62
+ }
63
+
64
+ // v to view full detail
65
+ if (key.name === "v" && favorites[selectedIndex]) {
66
+ const skill = favorites[selectedIndex]
67
+ dispatch({
68
+ type: "SELECT_SKILL",
69
+ skill: catalogSkillToEnriched(skill, installedNames),
70
+ })
71
+ return
72
+ }
73
+
74
+ // x to unfavorite
75
+ if (key.name === "x" && favorites[selectedIndex]) {
76
+ const skill = favorites[selectedIndex]
77
+ toggle(skill.id)
78
+ dispatch({
79
+ type: "SHOW_NOTIFICATION",
80
+ notification: { type: "info", message: `Removed "${skill.name}" from favorites` },
81
+ })
82
+ // Adjust selection if we removed the last item
83
+ if (selectedIndex >= favorites.length - 1 && selectedIndex > 0) {
84
+ setSelectedIndex(selectedIndex - 1)
85
+ }
86
+ return
87
+ }
88
+
89
+ // i to install
90
+ if (key.name === "i" && favorites[selectedIndex]) {
91
+ setInstallTarget(favorites[selectedIndex])
92
+ return
93
+ }
94
+ })
95
+
96
+ // Confirm dialog for install
97
+ if (installTarget) {
98
+ return (
99
+ <ConfirmDialog
100
+ message={`Install "${installTarget.name}"?`}
101
+ onConfirm={async () => {
102
+ const skill = catalogSkillToEnriched(installTarget, installedNames)
103
+ setInstallTarget(null)
104
+ await installSkill(skill)
105
+ }}
106
+ onCancel={() => setInstallTarget(null)}
107
+ />
108
+ )
109
+ }
110
+
111
+ // Not authenticated
112
+ if (!state.auth) {
113
+ return (
114
+ <box style={{ flexDirection: "column", padding: 2 }}>
115
+ <text fg={colors.text}>
116
+ Sign in to view your favorites
117
+ </text>
118
+ <text>{" "}</text>
119
+ <text fg={colors.textDim}>
120
+ Press <span fg={colors.primary}>l</span> to login
121
+ </text>
122
+ </box>
123
+ )
124
+ }
125
+
126
+ // Loading state
127
+ if (loading && favorites.length === 0) {
128
+ return (
129
+ <box style={{ padding: 1 }}>
130
+ <text fg={colors.textDim}>Loading favorites...</text>
131
+ </box>
132
+ )
133
+ }
134
+
135
+ // Error state
136
+ if (error && favorites.length === 0) {
137
+ return (
138
+ <box style={{ padding: 1 }}>
139
+ <text fg={colors.error}>Error: {error}</text>
140
+ </box>
141
+ )
142
+ }
143
+
144
+ return (
145
+ <box style={{ flexDirection: "column", width: "100%", flexGrow: 1 }}>
146
+ {/* Status line */}
147
+ <box
148
+ style={{
149
+ height: 1,
150
+ width: "100%",
151
+ paddingLeft: 1,
152
+ backgroundColor: colors.bgAlt,
153
+ }}
154
+ >
155
+ <text fg={colors.textDim}>
156
+ {favorites.length} favorite{favorites.length !== 1 ? "s" : ""}
157
+ {loading ? " (refreshing...)" : ""}
158
+ </text>
159
+ </box>
160
+
161
+ {/* Two-column content: list | detail */}
162
+ <box style={{ flexDirection: "row", flexGrow: 1, width: "100%" }}>
163
+ {/* LEFT: Favorites list */}
164
+ <box
165
+ style={{
166
+ width: "40%",
167
+ borderRight: true,
168
+ borderColor: colors.border,
169
+ flexDirection: "column",
170
+ }}
171
+ >
172
+ {/* List header */}
173
+ <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
174
+ <text fg={colors.textDim}>FAVORITES</text>
175
+ </box>
176
+
177
+ {favorites.length === 0 ? (
178
+ <box style={{ padding: 1 }}>
179
+ <text fg={colors.textDim}>
180
+ No favorites yet. Browse the Discover tab to find and favorite skills.
181
+ </text>
182
+ </box>
183
+ ) : (
184
+ <scrollbox
185
+ focused={state.activeView === "favorites" && !state.showHelp}
186
+ style={{
187
+ width: "100%",
188
+ flexGrow: 1,
189
+ rootOptions: { backgroundColor: colors.bg },
190
+ viewportOptions: { backgroundColor: colors.bg },
191
+ contentOptions: { backgroundColor: colors.bg },
192
+ scrollbarOptions: {
193
+ trackOptions: {
194
+ foregroundColor: colors.primary,
195
+ backgroundColor: colors.border,
196
+ },
197
+ },
198
+ }}
199
+ >
200
+ {favorites.map((skill, i) => {
201
+ const isInstalled = installedNames.has(skill.name?.toLowerCase() ?? "")
202
+ return (
203
+ <box
204
+ key={skill.id ?? `${skill.slug}-${i}`}
205
+ style={{
206
+ width: "100%",
207
+ paddingLeft: 1,
208
+ paddingRight: 1,
209
+ flexDirection: "row",
210
+ backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
211
+ }}
212
+ >
213
+ <text fg={i === selectedIndex ? colors.primary : colors.text}>
214
+ {skill.name}
215
+ </text>
216
+ {isInstalled ? (
217
+ <text fg={colors.success}> *</text>
218
+ ) : null}
219
+ </box>
220
+ )
221
+ })}
222
+ </scrollbox>
223
+ )}
224
+ </box>
225
+
226
+ {/* RIGHT: Detail panel */}
227
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
228
+ {previewSkill ? (
229
+ <FavoriteDetailPanel
230
+ skill={previewSkill}
231
+ isInstalled={installedNames.has(previewSkill.name?.toLowerCase() ?? "")}
232
+ />
233
+ ) : (
234
+ <box style={{ padding: 1 }}>
235
+ <text fg={colors.textDim}>Select a favorite to view details</text>
236
+ </box>
237
+ )}
238
+ </box>
239
+ </box>
240
+ </box>
241
+ )
242
+ }
243
+
244
+ // ---------- Inline Detail Panel ----------
245
+
246
+ interface FavoriteDetailPanelProps {
247
+ skill: CatalogSkill
248
+ isInstalled: boolean
249
+ }
250
+
251
+ function FavoriteDetailPanel({ skill, isInstalled }: FavoriteDetailPanelProps) {
252
+ const description = skill.summary || skill.description || ""
253
+ const categories = skill.categories?.join(", ") ?? ""
254
+ const sourceLabel = skill.githubUrl ? "github" : "skillsgate"
255
+
256
+ return (
257
+ <scrollbox
258
+ focused={false}
259
+ style={{
260
+ width: "100%",
261
+ flexGrow: 1,
262
+ rootOptions: { backgroundColor: colors.bg },
263
+ viewportOptions: { backgroundColor: colors.bg },
264
+ contentOptions: { backgroundColor: colors.bg },
265
+ scrollbarOptions: {
266
+ trackOptions: {
267
+ foregroundColor: colors.primary,
268
+ backgroundColor: colors.border,
269
+ },
270
+ },
271
+ }}
272
+ >
273
+ <box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "column" }}>
274
+ {/* Name */}
275
+ <text fg={colors.primary}>
276
+ <strong>{skill.name}</strong>
277
+ </text>
278
+
279
+ {/* Status */}
280
+ {isInstalled ? (
281
+ <text fg={colors.success}>Installed</text>
282
+ ) : (
283
+ <text fg={colors.textDim}>Not installed</text>
284
+ )}
285
+ <text>{" "}</text>
286
+
287
+ {/* Description */}
288
+ <text fg={colors.text}>{description}</text>
289
+ <text>{" "}</text>
290
+
291
+ {/* Source */}
292
+ <box style={{ flexDirection: "row", height: 1 }}>
293
+ <text fg={colors.textDim}>Source: </text>
294
+ <text fg={colors.secondary}>{sourceLabel}</text>
295
+ </box>
296
+
297
+ {/* Categories */}
298
+ {categories ? (
299
+ <box style={{ flexDirection: "row", height: 1 }}>
300
+ <text fg={colors.textDim}>Categories: </text>
301
+ <text fg={colors.secondary}>{categories}</text>
302
+ </box>
303
+ ) : null}
304
+
305
+ {/* GitHub URL */}
306
+ {skill.githubUrl ? (
307
+ <box style={{ flexDirection: "row", height: 1 }}>
308
+ <text fg={colors.textDim}>GitHub: </text>
309
+ <text fg={colors.primary}>{skill.githubUrl}</text>
310
+ </box>
311
+ ) : null}
312
+
313
+ {/* Install command */}
314
+ {skill.installCommand ? (
315
+ <>
316
+ <text>{" "}</text>
317
+ <text fg={colors.textDim}>Install:</text>
318
+ <text fg={colors.success}> {skill.installCommand}</text>
319
+ </>
320
+ ) : null}
321
+
322
+ <text>{" "}</text>
323
+ <text fg={colors.textDim}>v=full detail x=unfavorite i=install</text>
324
+ </box>
325
+ </scrollbox>
326
+ )
327
+ }
328
+
329
+ // ---------- Helpers ----------
330
+
331
+ function catalogSkillToEnriched(
332
+ skill: CatalogSkill,
333
+ installedNames: Set<string>
334
+ ): EnrichedSkill {
335
+ return {
336
+ name: skill.name,
337
+ description: skill.summary || skill.description || "",
338
+ filePath: "",
339
+ agents: [],
340
+ metadata: {
341
+ categories: skill.categories,
342
+ capabilities: skill.capabilities,
343
+ keywords: skill.keywords,
344
+ githubUrl: skill.githubUrl,
345
+ installCommand: skill.installCommand,
346
+ },
347
+ lock: skill.githubUrl
348
+ ? {
349
+ source: skill.githubUrl,
350
+ sourceType: "github" as const,
351
+ originalUrl: skill.githubUrl,
352
+ skillFolderHash: "",
353
+ installedAt: "",
354
+ updatedAt: "",
355
+ }
356
+ : undefined,
357
+ }
358
+ }
@@ -0,0 +1,218 @@
1
+ import { useMemo, useState, useEffect } from "react"
2
+ import fs from "node:fs"
3
+ import { useStore } from "../store/context.js"
4
+ import { AgentFilter } from "../components/agent-filter.js"
5
+ import { SkillList } from "../components/skill-list.js"
6
+ import { colors, agentBadges as badgeMap } from "../utils/colors.js"
7
+ import type { EnrichedSkill } from "../store/types.js"
8
+
9
+ /**
10
+ * Reads the full SKILL.md content for inline display.
11
+ */
12
+ function readSkillContent(filePath: string): string {
13
+ try {
14
+ return fs.readFileSync(filePath, "utf-8")
15
+ } catch {
16
+ return ""
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Strips frontmatter (--- delimited block at the top) from markdown content.
22
+ */
23
+ function stripFrontmatter(content: string): string {
24
+ const lines = content.split("\n")
25
+ if (lines[0]?.trim() !== "---") return content
26
+
27
+ let endIndex = -1
28
+ for (let i = 1; i < lines.length; i++) {
29
+ if (lines[i].trim() === "---") {
30
+ endIndex = i
31
+ break
32
+ }
33
+ }
34
+
35
+ if (endIndex === -1) return content
36
+ return lines.slice(endIndex + 1).join("\n").trimStart()
37
+ }
38
+
39
+ /**
40
+ * Three-panel home view:
41
+ * LEFT - Agent filter sidebar (fixed 22 chars)
42
+ * MIDDLE - Compact skill name list (30%)
43
+ * RIGHT - Skill detail panel (flexGrow)
44
+ */
45
+ export function HomeView() {
46
+ const state = useStore()
47
+
48
+ // Apply agent filter and text filter
49
+ const filteredSkills = useMemo(() => {
50
+ let skills = state.installedSkills
51
+
52
+ // Agent filter
53
+ if (state.selectedAgentFilter !== "all") {
54
+ skills = skills.filter((s) =>
55
+ s.agents.includes(state.selectedAgentFilter as any)
56
+ )
57
+ }
58
+
59
+ // Text filter
60
+ if (state.installedFilter) {
61
+ const q = state.installedFilter.toLowerCase()
62
+ skills = skills.filter(
63
+ (s) =>
64
+ s.name.toLowerCase().includes(q) ||
65
+ s.description.toLowerCase().includes(q)
66
+ )
67
+ }
68
+
69
+ return skills
70
+ }, [state.installedSkills, state.selectedAgentFilter, state.installedFilter])
71
+
72
+ return (
73
+ <box style={{ flexDirection: "row", width: "100%", flexGrow: 1 }}>
74
+ {/* LEFT: Agent filter sidebar */}
75
+ <AgentFilter />
76
+
77
+ {/* MIDDLE: Skill list */}
78
+ <box
79
+ style={{
80
+ width: "30%",
81
+ borderRight: true,
82
+ borderColor: state.focusedPane === "list" ? colors.primary : colors.border,
83
+ flexDirection: "column",
84
+ }}
85
+ >
86
+ <SkillList skills={filteredSkills} />
87
+ </box>
88
+
89
+ {/* RIGHT: Detail panel */}
90
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
91
+ {state.selectedSkill ? (
92
+ <DetailPanel skill={state.selectedSkill} />
93
+ ) : (
94
+ <box style={{ padding: 1 }}>
95
+ <text fg={colors.textDim}>
96
+ {filteredSkills.length > 0
97
+ ? "Select a skill to view details"
98
+ : "No skills to display"}
99
+ </text>
100
+ </box>
101
+ )}
102
+ </box>
103
+ </box>
104
+ )
105
+ }
106
+
107
+ // ---------- Inline Detail Panel ----------
108
+
109
+ interface DetailPanelProps {
110
+ skill: EnrichedSkill
111
+ }
112
+
113
+ function DetailPanel({ skill }: DetailPanelProps) {
114
+ const state = useStore()
115
+ const [content, setContent] = useState("")
116
+
117
+ useEffect(() => {
118
+ if (skill.filePath) {
119
+ const raw = readSkillContent(skill.filePath)
120
+ setContent(stripFrontmatter(raw))
121
+ } else {
122
+ setContent("")
123
+ }
124
+ }, [skill.filePath, skill.name])
125
+
126
+ const sourceType = skill.lock?.sourceType ?? "unknown"
127
+ const sourceUrl = skill.lock?.originalUrl ?? ""
128
+
129
+ return (
130
+ <scrollbox
131
+ focused={false}
132
+ style={{
133
+ width: "100%",
134
+ flexGrow: 1,
135
+ rootOptions: { backgroundColor: colors.bg },
136
+ viewportOptions: { backgroundColor: colors.bg },
137
+ contentOptions: { backgroundColor: colors.bg },
138
+ scrollbarOptions: {
139
+ trackOptions: {
140
+ foregroundColor: colors.primary,
141
+ backgroundColor: colors.border,
142
+ },
143
+ },
144
+ }}
145
+ >
146
+ <box style={{ paddingLeft: 1, paddingRight: 1, paddingTop: 0, flexDirection: "column" }}>
147
+ {/* Skill name */}
148
+ <text fg={colors.primary}>
149
+ <strong>{skill.name}</strong>
150
+ </text>
151
+
152
+ {/* Description */}
153
+ <text fg={colors.text}>{skill.description}</text>
154
+ <text>{" "}</text>
155
+
156
+ {/* Metadata row: source + agents */}
157
+ <box style={{ flexDirection: "row", height: 1 }}>
158
+ <text fg={colors.textDim}>Source: </text>
159
+ <text fg={colors.secondary}>{sourceType}</text>
160
+ {sourceUrl ? (
161
+ <>
162
+ <text fg={colors.textDim}> URL: </text>
163
+ <text fg={colors.primary}>{sourceUrl}</text>
164
+ </>
165
+ ) : null}
166
+ </box>
167
+
168
+ {/* Agent badges */}
169
+ {skill.agents.length > 0 ? (
170
+ <box style={{ flexDirection: "row", height: 1 }}>
171
+ <text fg={colors.textDim}>Agents: </text>
172
+ {skill.agents.map((a, i) => {
173
+ const badge = badgeMap[a]
174
+ return (
175
+ <text key={a} fg={badge?.color ?? colors.agent}>
176
+ {i > 0 ? " " : ""}{badge?.label ?? a.slice(0, 2).toUpperCase()}
177
+ </text>
178
+ )
179
+ })}
180
+ </box>
181
+ ) : null}
182
+
183
+ <text>{" "}</text>
184
+
185
+ {/* Shortcut hints */}
186
+ <text fg={colors.textDim}>v=view detail d=remove u=update Tab=switch pane</text>
187
+ <text fg={colors.border}>---</text>
188
+
189
+ {/* SKILL.md content */}
190
+ {content ? (
191
+ content.split("\n").map((line, i) => {
192
+ if (line.startsWith("### ")) {
193
+ return <text key={i} fg={colors.primary}>{line}</text>
194
+ }
195
+ if (line.startsWith("## ")) {
196
+ return <text key={i} fg={colors.primary}><strong>{line}</strong></text>
197
+ }
198
+ if (line.startsWith("# ")) {
199
+ return <text key={i} fg={colors.primary}><strong>{line}</strong></text>
200
+ }
201
+ if (line.startsWith("```")) {
202
+ return <text key={i} fg={colors.textDim}>{line}</text>
203
+ }
204
+ if (line.trimStart().startsWith("- ") || line.trimStart().startsWith("* ")) {
205
+ return <text key={i} fg={colors.text}>{line}</text>
206
+ }
207
+ if (!line.trim()) {
208
+ return <text key={i}>{" "}</text>
209
+ }
210
+ return <text key={i} fg={colors.text}>{line}</text>
211
+ })
212
+ ) : (
213
+ <text fg={colors.textDim}>(No skill content available)</text>
214
+ )}
215
+ </box>
216
+ </scrollbox>
217
+ )
218
+ }