@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,497 @@
1
+ import { useState, useEffect } from "react"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { exec } from "node:child_process"
5
+ import { useKeyboard } from "@opentui/react"
6
+ import { useStore, useDispatch } from "../store/context.js"
7
+ import { useSkillActions } from "../data/use-skill-actions.js"
8
+ import { ConfirmDialog } from "../components/confirm-dialog.js"
9
+ import { colors, agentBadges as badgeMap } from "../utils/colors.js"
10
+ import { agents } from "../../../cli/src/core/agents.js"
11
+
12
+ /**
13
+ * Reads the full SKILL.md content for display.
14
+ * Uses synchronous read since we need it immediately and it's a local file.
15
+ */
16
+ function readSkillContent(filePath: string): string {
17
+ try {
18
+ return fs.readFileSync(filePath, "utf-8")
19
+ } catch {
20
+ return "(Could not read skill file)"
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Strips frontmatter (--- delimited block at the top) from markdown content
26
+ * so we display only the body.
27
+ */
28
+ function stripFrontmatter(content: string): string {
29
+ const lines = content.split("\n")
30
+ if (lines[0]?.trim() !== "---") return content
31
+
32
+ let endIndex = -1
33
+ for (let i = 1; i < lines.length; i++) {
34
+ if (lines[i].trim() === "---") {
35
+ endIndex = i
36
+ break
37
+ }
38
+ }
39
+
40
+ if (endIndex === -1) return content
41
+ return lines.slice(endIndex + 1).join("\n").trimStart()
42
+ }
43
+
44
+ /**
45
+ * Returns the display name for an agent key (e.g. "claude-code" -> "Claude Code").
46
+ */
47
+ function agentDisplayName(agentName: string): string {
48
+ return agents[agentName]?.displayName ?? agentName
49
+ }
50
+
51
+ type DetailPendingAction = "remove" | "install" | null
52
+ type RemoveMode = null | "confirm" | "select-agent"
53
+
54
+ export function SkillDetailView() {
55
+ const state = useStore()
56
+ const dispatch = useDispatch()
57
+ const { installSkill, removeSkill, removeSkillFromOneAgent } = useSkillActions()
58
+ const skill = state.selectedSkill
59
+
60
+ const [content, setContent] = useState("")
61
+ const [rawContent, setRawContent] = useState("") // full file content for editing
62
+ const [contentLoading, setContentLoading] = useState(false)
63
+ const [editMode, setEditMode] = useState(false)
64
+ const [pendingAction, setPendingAction] = useState<DetailPendingAction>(null)
65
+ const [removeMode, setRemoveMode] = useState<RemoveMode>(null)
66
+
67
+ useEffect(() => {
68
+ if (!skill) return
69
+
70
+ // Local skill: read from disk
71
+ if (skill.filePath) {
72
+ const raw = readSkillContent(skill.filePath)
73
+ setRawContent(raw)
74
+ setContent(stripFrontmatter(raw))
75
+ return
76
+ }
77
+
78
+ // Catalog skill: fetch content from API
79
+ const githubUrl = skill.metadata?.githubUrl as string | undefined
80
+ const urlPath = skill.metadata?.urlPath as string | undefined
81
+ if (githubUrl || urlPath) {
82
+ setContentLoading(true)
83
+ const detailPath = urlPath
84
+ ? `/api/v1/skills/detail?path=${encodeURIComponent(urlPath)}`
85
+ : `/api/v1/skills/detail?path=${encodeURIComponent(skill.name)}`
86
+ fetch(`https://api.skillsgate.ai${detailPath}`)
87
+ .then(res => res.ok ? res.json() : null)
88
+ .then(data => {
89
+ if (data?.content) {
90
+ setContent(stripFrontmatter(data.content))
91
+ } else {
92
+ setContent(skill.description || "(No content available)")
93
+ }
94
+ })
95
+ .catch(() => setContent(skill.description || "(Could not load content)"))
96
+ .finally(() => setContentLoading(false))
97
+ } else {
98
+ setContent(skill.description || "(No content available)")
99
+ }
100
+ }, [skill?.name, skill?.filePath])
101
+
102
+ // Detail view keyboard handling
103
+ useKeyboard((key) => {
104
+ if (state.activeView !== "detail") return
105
+ if (state.showHelp) return
106
+
107
+ // Handle agent selection menu for per-agent delete
108
+ if (removeMode === "select-agent" && skill) {
109
+ if (key.name === "n" || key.name === "escape") {
110
+ setRemoveMode(null)
111
+ return
112
+ }
113
+ if (key.name === "a") {
114
+ // Remove from all agents
115
+ setRemoveMode(null)
116
+ setPendingAction("remove")
117
+ return
118
+ }
119
+ // Number keys 1-9 to select a specific agent
120
+ const num = parseInt(key.raw ?? "", 10)
121
+ if (num >= 1 && num <= skill.agents.length) {
122
+ const agentName = skill.agents[num - 1]
123
+ setRemoveMode(null)
124
+ removeSkillFromOneAgent(skill, agentName).then(() => {
125
+ // If that was the last agent, go back to list
126
+ if (skill.agents.length <= 1) {
127
+ dispatch({ type: "GO_BACK" })
128
+ }
129
+ })
130
+ return
131
+ }
132
+ return
133
+ }
134
+
135
+ if (pendingAction) return // Block during confirm dialog
136
+
137
+ // q or Esc to go back
138
+ if (key.name === "q" || key.name === "escape") {
139
+ dispatch({ type: "GO_BACK" })
140
+ return
141
+ }
142
+
143
+ // e to toggle between rendered view and raw source (only for local skills)
144
+ if (key.name === "e" && skill?.filePath) {
145
+ setEditMode(!editMode)
146
+ return
147
+ }
148
+
149
+ // o to open folder (local skills) or source URL (catalog/github skills)
150
+ if (key.name === "o" && skill) {
151
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open"
152
+
153
+ if (skill.filePath) {
154
+ // Local skill: open the containing folder
155
+ const dir = path.dirname(skill.filePath)
156
+ try {
157
+ exec(`${cmd} "${dir}"`)
158
+ dispatch({
159
+ type: "SHOW_NOTIFICATION",
160
+ notification: { type: "info", message: `Opening ${dir}` },
161
+ })
162
+ } catch {
163
+ dispatch({
164
+ type: "SHOW_NOTIFICATION",
165
+ notification: { type: "error", message: "Failed to open folder" },
166
+ })
167
+ }
168
+ } else if (skill.lock?.sourceType === "github") {
169
+ // Catalog/GitHub skill: open the source URL
170
+ const url = skill.lock.originalUrl
171
+ if (url) {
172
+ try {
173
+ exec(`${cmd} "${url}"`)
174
+ dispatch({
175
+ type: "SHOW_NOTIFICATION",
176
+ notification: { type: "info", message: `Opening ${url}` },
177
+ })
178
+ } catch {
179
+ dispatch({
180
+ type: "SHOW_NOTIFICATION",
181
+ notification: { type: "error", message: "Failed to open URL" },
182
+ })
183
+ }
184
+ }
185
+ }
186
+ return
187
+ }
188
+
189
+ // d to remove skill
190
+ if (key.name === "d" && skill && skill.agents.length > 0) {
191
+ if (skill.agents.length > 1) {
192
+ // Multiple agents: show selection menu
193
+ setRemoveMode("select-agent")
194
+ } else {
195
+ // Single agent: simple confirm
196
+ setPendingAction("remove")
197
+ }
198
+ return
199
+ }
200
+
201
+ // i to install (for catalog skills not yet installed)
202
+ if (key.name === "i" && skill && skill.agents.length === 0) {
203
+ setPendingAction("install")
204
+ return
205
+ }
206
+ })
207
+
208
+ // Agent selection menu for per-agent delete
209
+ if (removeMode === "select-agent" && skill) {
210
+ return (
211
+ <box
212
+ style={{
213
+ width: "100%",
214
+ height: "100%",
215
+ justifyContent: "center",
216
+ alignItems: "center",
217
+ backgroundColor: colors.bg,
218
+ }}
219
+ >
220
+ <box
221
+ style={{
222
+ width: 60,
223
+ border: true,
224
+ borderColor: colors.primary,
225
+ backgroundColor: "#1a1a2e",
226
+ paddingLeft: 2,
227
+ paddingRight: 2,
228
+ paddingTop: 1,
229
+ paddingBottom: 1,
230
+ flexDirection: "column",
231
+ }}
232
+ title="Remove"
233
+ >
234
+ <text fg={colors.text}>
235
+ Remove "<span fg={colors.primary}>{skill.name}</span>" from:
236
+ </text>
237
+ <text>{" "}</text>
238
+ {skill.agents.map((agentName, i) => {
239
+ const badge = badgeMap[agentName]
240
+ return (
241
+ <text key={agentName} fg={colors.text}>
242
+ {" "}<span fg={colors.primary}>{i + 1}</span>{" "}<span fg={badge?.color ?? colors.agent}>{agentDisplayName(agentName)}</span>
243
+ </text>
244
+ )
245
+ })}
246
+ <text>{" "}</text>
247
+ <text fg={colors.text}>
248
+ {" "}<span fg={colors.error}>a</span>{" "}All agents (removes completely)
249
+ </text>
250
+ <text fg={colors.text}>
251
+ {" "}<span fg={colors.textDim}>n</span>{" "}Cancel
252
+ </text>
253
+ </box>
254
+ </box>
255
+ )
256
+ }
257
+
258
+ // Confirm dialog for remove/install
259
+ if (pendingAction && skill) {
260
+ const actionLabel = pendingAction === "remove" ? "Remove" : "Install"
261
+ return (
262
+ <ConfirmDialog
263
+ message={`${actionLabel} "${skill.name}"?`}
264
+ onConfirm={async () => {
265
+ const action = pendingAction
266
+ setPendingAction(null)
267
+ if (action === "remove") {
268
+ await removeSkill(skill)
269
+ dispatch({ type: "GO_BACK" })
270
+ } else if (action === "install") {
271
+ await installSkill(skill)
272
+ }
273
+ }}
274
+ onCancel={() => setPendingAction(null)}
275
+ />
276
+ )
277
+ }
278
+
279
+ if (!skill) {
280
+ return (
281
+ <box style={{ padding: 1 }}>
282
+ <text fg={colors.textDim}>No skill selected</text>
283
+ </box>
284
+ )
285
+ }
286
+
287
+ // Build metadata lines
288
+ const sourceType = skill.lock?.sourceType ?? "unknown"
289
+ const sourceUrl = skill.lock?.originalUrl ?? ""
290
+ const agentBadgeElements = skill.agents.map((a, i) => {
291
+ const badge = badgeMap[a]
292
+ return (
293
+ <text key={a} fg={badge?.color ?? colors.agent}>
294
+ {i > 0 ? " " : ""}{badge?.label ?? a.slice(0, 2).toUpperCase()}
295
+ </text>
296
+ )
297
+ })
298
+ const isInstalled = skill.agents.length > 0
299
+ const isLocal = !!skill.filePath
300
+ const installedAt = skill.lock?.installedAt
301
+ ? new Date(skill.lock.installedAt).toLocaleDateString()
302
+ : null
303
+ const updatedAt = skill.lock?.updatedAt
304
+ ? new Date(skill.lock.updatedAt).toLocaleDateString()
305
+ : null
306
+
307
+ return (
308
+ <box style={{ flexDirection: "row", width: "100%", flexGrow: 1 }}>
309
+ {/* Left side: Content (70%) - view or edit mode */}
310
+ {editMode ? (
311
+ <box style={{ width: "70%", flexGrow: 1, flexDirection: "column" }}>
312
+ <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
313
+ <text fg={colors.warning}>RAW: {skill.filePath} (o=open folder Esc=back to view)</text>
314
+ </box>
315
+ <scrollbox
316
+ focused={false}
317
+ style={{
318
+ width: "100%",
319
+ flexGrow: 1,
320
+ rootOptions: { backgroundColor: colors.bg },
321
+ viewportOptions: { backgroundColor: colors.bg },
322
+ contentOptions: { backgroundColor: colors.bg },
323
+ }}
324
+ >
325
+ <box style={{ paddingLeft: 1, paddingRight: 1, paddingTop: 1, flexDirection: "column" }}>
326
+ {rawContent.split("\n").map((line, i) => (
327
+ <text key={i} fg={colors.text}>{line || " "}</text>
328
+ ))}
329
+ </box>
330
+ </scrollbox>
331
+ </box>
332
+ ) : (
333
+ <scrollbox
334
+ focused={false}
335
+ style={{
336
+ width: "70%",
337
+ flexGrow: 1,
338
+ rootOptions: { backgroundColor: colors.bg },
339
+ viewportOptions: { backgroundColor: colors.bg },
340
+ contentOptions: { backgroundColor: colors.bg },
341
+ scrollbarOptions: {
342
+ trackOptions: {
343
+ foregroundColor: colors.primary,
344
+ backgroundColor: colors.border,
345
+ },
346
+ },
347
+ }}
348
+ >
349
+ <box style={{ paddingLeft: 1, paddingRight: 1, paddingTop: 1, flexDirection: "column" }}>
350
+ {contentLoading && (
351
+ <text fg={colors.textDim}>Loading content...</text>
352
+ )}
353
+ {!contentLoading && content.split("\n").map((line, i) => {
354
+ // Style headings differently
355
+ if (line.startsWith("### ")) {
356
+ return (
357
+ <text key={i} fg={colors.primary}>
358
+ {line}
359
+ </text>
360
+ )
361
+ }
362
+ if (line.startsWith("## ")) {
363
+ return (
364
+ <text key={i} fg={colors.primary}>
365
+ <strong>{line}</strong>
366
+ </text>
367
+ )
368
+ }
369
+ if (line.startsWith("# ")) {
370
+ return (
371
+ <text key={i} fg={colors.primary}>
372
+ <strong>{line}</strong>
373
+ </text>
374
+ )
375
+ }
376
+ // Code blocks
377
+ if (line.startsWith("```")) {
378
+ return (
379
+ <text key={i} fg={colors.textDim}>
380
+ {line}
381
+ </text>
382
+ )
383
+ }
384
+ // Bullet points
385
+ if (line.trimStart().startsWith("- ") || line.trimStart().startsWith("* ")) {
386
+ return (
387
+ <text key={i} fg={colors.text}>
388
+ {line}
389
+ </text>
390
+ )
391
+ }
392
+ // Empty line
393
+ if (!line.trim()) {
394
+ return <text key={i}>{" "}</text>
395
+ }
396
+ // Normal text
397
+ return (
398
+ <text key={i} fg={colors.text}>
399
+ {line}
400
+ </text>
401
+ )
402
+ })}
403
+ </box>
404
+ </scrollbox>
405
+ )}
406
+
407
+ {/* Right side: Metadata panel (30%) */}
408
+ <box
409
+ style={{
410
+ width: "30%",
411
+ flexDirection: "column",
412
+ backgroundColor: colors.bgAlt,
413
+ borderLeft: true,
414
+ borderColor: colors.border,
415
+ paddingLeft: 1,
416
+ paddingRight: 1,
417
+ paddingTop: 1,
418
+ }}
419
+ >
420
+ {/* Skill name */}
421
+ <text fg={colors.primary}>
422
+ <strong>{skill.name}</strong>
423
+ </text>
424
+ <text>{" "}</text>
425
+
426
+ {/* Description */}
427
+ <text fg={colors.text}>{skill.description}</text>
428
+ <text>{" "}</text>
429
+
430
+ {/* Source */}
431
+ <text fg={colors.textDim}>Source</text>
432
+ <text fg={colors.text}> {sourceType}</text>
433
+ <text>{" "}</text>
434
+
435
+ {/* Source URL */}
436
+ {sourceUrl ? (
437
+ <>
438
+ <text fg={colors.textDim}>URL</text>
439
+ <text fg={colors.primary}> {sourceUrl}</text>
440
+ <text>{" "}</text>
441
+ </>
442
+ ) : null}
443
+
444
+ {/* Status */}
445
+ <text fg={colors.textDim}>Status</text>
446
+ <text fg={isInstalled ? colors.success : colors.textDim}>
447
+ {" "}{isInstalled ? "Installed" : "Not installed"}
448
+ </text>
449
+ <text>{" "}</text>
450
+
451
+ {/* Agents (only if installed) */}
452
+ {isInstalled && agentBadgeElements.length > 0 && (
453
+ <>
454
+ <text fg={colors.textDim}>Agents</text>
455
+ <box style={{ flexDirection: "row", paddingLeft: 2 }}>
456
+ {agentBadgeElements}
457
+ </box>
458
+ <text>{" "}</text>
459
+ </>
460
+ )}
461
+
462
+ {/* Dates (only if installed) */}
463
+ {installedAt && (
464
+ <>
465
+ <text fg={colors.textDim}>Installed</text>
466
+ <text fg={colors.text}> {installedAt}</text>
467
+ <text>{" "}</text>
468
+ </>
469
+ )}
470
+ {updatedAt && (
471
+ <>
472
+ <text fg={colors.textDim}>Last updated</text>
473
+ <text fg={colors.text}> {updatedAt}</text>
474
+ <text>{" "}</text>
475
+ </>
476
+ )}
477
+
478
+ {/* Shortcut hints -- contextual based on skill type */}
479
+ <text fg={colors.border}>---</text>
480
+ <text fg={colors.textDim}>q/Esc Go back</text>
481
+ {isLocal && (
482
+ <text fg={colors.textDim}>e {editMode ? "Back to view" : "View raw source"}</text>
483
+ )}
484
+ {isLocal ? (
485
+ <text fg={colors.textDim}>o Open folder</text>
486
+ ) : sourceType === "github" ? (
487
+ <text fg={colors.textDim}>o Open URL</text>
488
+ ) : null}
489
+ {isInstalled ? (
490
+ <text fg={colors.textDim}>d Remove skill</text>
491
+ ) : (
492
+ <text fg={colors.textDim}>i Install skill</text>
493
+ )}
494
+ </box>
495
+ </box>
496
+ )
497
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "lib": ["esnext"],
5
+ "module": "esnext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "@opentui/react",
9
+ "strict": true,
10
+ "noEmit": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "paths": {
14
+ "@cli/*": ["../cli/src/*"]
15
+ }
16
+ },
17
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
18
+ }