@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,202 @@
1
+ import { useState, useCallback } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useAuth } from "../data/use-auth.js"
5
+ import { API_BASE_URL } from "../../../cli/src/constants.js"
6
+ import { colors } from "../utils/colors.js"
7
+
8
+ type LoginStep = "prompt" | "code" | "exchanging"
9
+
10
+ /**
11
+ * Login view implementing the device code flow:
12
+ * 1. Show instructions with the auth URL
13
+ * 2. Prompt to open browser (y/n)
14
+ * 3. Show input for device code (XXXX-XXXX)
15
+ * 4. Exchange code for token, save auth, navigate back
16
+ */
17
+ export function LoginView() {
18
+ const state = useStore()
19
+ const dispatch = useDispatch()
20
+ const { auth, login, logout } = useAuth()
21
+
22
+ const [step, setStep] = useState<LoginStep>("prompt")
23
+ const [error, setError] = useState<string | null>(null)
24
+
25
+ const authUrl = `${API_BASE_URL}/cli/auth`
26
+
27
+ function openBrowser() {
28
+ try {
29
+ const { exec } = require("node:child_process")
30
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open"
31
+ exec(`${cmd} "${authUrl}"`)
32
+ dispatch({
33
+ type: "SHOW_NOTIFICATION",
34
+ notification: { type: "info", message: "Opening browser..." },
35
+ })
36
+ } catch {
37
+ // Best effort
38
+ }
39
+ }
40
+
41
+ // Handle keyboard input
42
+ useKeyboard((key) => {
43
+ if (state.activeView !== "login") return
44
+ if (state.showHelp) return
45
+
46
+ // Esc to go back at any step
47
+ if (key.name === "escape") {
48
+ dispatch({ type: "GO_BACK" })
49
+ return
50
+ }
51
+
52
+ if (step === "prompt") {
53
+ // "r" to re-login (clear old auth, open browser, go to code step)
54
+ if (key.name === "r") {
55
+ logout()
56
+ openBrowser()
57
+ setStep("code")
58
+ return
59
+ }
60
+
61
+ // "o" to logout only (no re-login)
62
+ if (key.name === "o") {
63
+ logout()
64
+ dispatch({
65
+ type: "SHOW_NOTIFICATION",
66
+ notification: { type: "success", message: "Signed out" },
67
+ })
68
+ dispatch({ type: "GO_BACK" })
69
+ return
70
+ }
71
+
72
+ // "y" to open browser and proceed to code input
73
+ if (key.name === "y") {
74
+ openBrowser()
75
+ setStep("code")
76
+ return
77
+ }
78
+
79
+ // "n" to skip browser, go straight to code input
80
+ if (key.name === "n") {
81
+ setStep("code")
82
+ return
83
+ }
84
+ }
85
+ })
86
+
87
+ const handleCodeSubmit = useCallback(async (value: string) => {
88
+ const code = value.trim()
89
+ if (!code) return
90
+
91
+ setStep("exchanging")
92
+ setError(null)
93
+
94
+ const errMsg = await login(code)
95
+
96
+ if (errMsg) {
97
+ setError(errMsg)
98
+ setStep("code") // Let user retry
99
+ } else {
100
+ // Success - navigate back
101
+ dispatch({
102
+ type: "SHOW_NOTIFICATION",
103
+ notification: { type: "success", message: "Logged in successfully!" },
104
+ })
105
+ dispatch({ type: "GO_BACK" })
106
+ }
107
+ }, [login, dispatch])
108
+
109
+ // Already logged in -- offer re-login or logout
110
+ if (auth && step === "prompt") {
111
+ return (
112
+ <box style={{ flexDirection: "column", padding: 2 }}>
113
+ <text fg={colors.success}>
114
+ Logged in as <strong>{auth.user.name}</strong> ({auth.user.email})
115
+ </text>
116
+ <text>{" "}</text>
117
+ <text fg={colors.text}>
118
+ If AI search isn't working, your session may have expired.
119
+ </text>
120
+ <text>{" "}</text>
121
+ <text fg={colors.primary}>r</text>
122
+ <text fg={colors.text}> Re-login with a fresh token</text>
123
+ <text fg={colors.primary}>o</text>
124
+ <text fg={colors.text}> Sign out</text>
125
+ <text fg={colors.textDim}>Esc</text>
126
+ <text fg={colors.text}> Go back</text>
127
+ </box>
128
+ )
129
+ }
130
+
131
+ return (
132
+ <box style={{ flexDirection: "column", padding: 2 }}>
133
+ {/* Title */}
134
+ <text fg={colors.primary}>
135
+ <strong>Sign in to SkillsGate</strong>
136
+ </text>
137
+ <text>{" "}</text>
138
+
139
+ {/* Instructions */}
140
+ <text fg={colors.text}>
141
+ Visit the following URL in your browser to get a login code:
142
+ </text>
143
+ <text>{" "}</text>
144
+ <text fg={colors.primary}>
145
+ {authUrl}
146
+ </text>
147
+ <text>{" "}</text>
148
+
149
+ {step === "prompt" && (
150
+ <>
151
+ <text fg={colors.text}>
152
+ Open browser? <span fg={colors.textDim}>(y/n)</span>
153
+ </text>
154
+ </>
155
+ )}
156
+
157
+ {step === "code" && (
158
+ <>
159
+ <text fg={colors.text}>
160
+ Paste the code from the browser:
161
+ </text>
162
+ <text>{" "}</text>
163
+ <box
164
+ style={{
165
+ height: 3,
166
+ width: 40,
167
+ border: true,
168
+ borderColor: colors.primary,
169
+ paddingLeft: 1,
170
+ paddingRight: 1,
171
+ }}
172
+ title="Code"
173
+ >
174
+ <input
175
+ placeholder="XXXX-XXXX"
176
+ focused={state.activeView === "login" && step === "code" && !state.showHelp}
177
+ onSubmit={handleCodeSubmit}
178
+ />
179
+ </box>
180
+ <text>{" "}</text>
181
+ <text fg={colors.textDim}>
182
+ Press Enter to submit, Esc to cancel
183
+ </text>
184
+ </>
185
+ )}
186
+
187
+ {step === "exchanging" && (
188
+ <text fg={colors.primary}>
189
+ Verifying code...
190
+ </text>
191
+ )}
192
+
193
+ {/* Error message */}
194
+ {error && (
195
+ <>
196
+ <text>{" "}</text>
197
+ <text fg={colors.error}>{error}</text>
198
+ </>
199
+ )}
200
+ </box>
201
+ )
202
+ }
@@ -0,0 +1,269 @@
1
+ import { useState, useEffect } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useDb } from "../db/context.js"
5
+ import { colors } from "../utils/colors.js"
6
+ import type { RemoteSkill } from "../db/skills.js"
7
+
8
+ interface ServerSkillsViewProps {
9
+ serverId: string
10
+ }
11
+
12
+ /**
13
+ * Browse cached remote skills from a selected server.
14
+ * Two-column layout: skill list (left) | skill detail (right).
15
+ * 'i' to install a remote skill locally (placeholder), Esc to go back.
16
+ */
17
+ export function ServerSkillsView({ serverId }: ServerSkillsViewProps) {
18
+ const state = useStore()
19
+ const dispatch = useDispatch()
20
+ const { servers, skills } = useDb()
21
+
22
+ const server = servers.get(serverId)
23
+ const [skillList, setSkillList] = useState<RemoteSkill[]>([])
24
+ const [selectedIndex, setSelectedIndex] = useState(0)
25
+
26
+ useEffect(() => {
27
+ setSkillList(skills.listByServer(serverId))
28
+ }, [serverId])
29
+
30
+ const selectedSkill = skillList[selectedIndex] ?? null
31
+
32
+ useKeyboard((key) => {
33
+ if (state.activeView !== "server-skills") return
34
+ if (state.showHelp) return
35
+
36
+ // j/k or arrow keys
37
+ if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
38
+ setSelectedIndex((i) => Math.max(0, i - 1))
39
+ }
40
+ if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
41
+ setSelectedIndex((i) => Math.min(skillList.length - 1, i + 1))
42
+ }
43
+
44
+ // g = first, G = last
45
+ if (key.name === "g" && !key.shift) {
46
+ setSelectedIndex(0)
47
+ }
48
+ if (key.name === "g" && key.shift) {
49
+ setSelectedIndex(Math.max(0, skillList.length - 1))
50
+ }
51
+
52
+ // i = install locally (placeholder)
53
+ if (key.name === "i" && selectedSkill) {
54
+ dispatch({
55
+ type: "SHOW_NOTIFICATION",
56
+ notification: {
57
+ type: "info",
58
+ message: `Install from remote is not yet implemented. Skill: ${selectedSkill.name}`,
59
+ },
60
+ })
61
+ return
62
+ }
63
+
64
+ // Esc = go back (handled by layout)
65
+ })
66
+
67
+ if (!server) {
68
+ return (
69
+ <box style={{ padding: 1 }}>
70
+ <text fg={colors.error}>Server not found</text>
71
+ </box>
72
+ )
73
+ }
74
+
75
+ return (
76
+ <box style={{ flexDirection: "column", width: "100%", flexGrow: 1 }}>
77
+ {/* Header bar */}
78
+ <box
79
+ style={{
80
+ height: 1,
81
+ width: "100%",
82
+ paddingLeft: 1,
83
+ backgroundColor: colors.bgAlt,
84
+ flexDirection: "row",
85
+ }}
86
+ >
87
+ <text fg={colors.primary}>{server.label}</text>
88
+ <text fg={colors.textDim}>
89
+ {" "}{server.username}@{server.host}{" "}
90
+ {skillList.length} skill{skillList.length !== 1 ? "s" : ""}
91
+ </text>
92
+ </box>
93
+
94
+ {/* Two-column content */}
95
+ <box style={{ flexDirection: "row", flexGrow: 1, width: "100%" }}>
96
+ {/* LEFT: Skill list */}
97
+ <box
98
+ style={{
99
+ width: "40%",
100
+ borderRight: true,
101
+ borderColor: colors.border,
102
+ flexDirection: "column",
103
+ }}
104
+ >
105
+ <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
106
+ <text fg={colors.textDim}>REMOTE SKILLS</text>
107
+ </box>
108
+
109
+ {skillList.length === 0 ? (
110
+ <box style={{ padding: 1 }}>
111
+ <text fg={colors.textDim}>
112
+ No cached skills. Go back and sync the server (S).
113
+ </text>
114
+ </box>
115
+ ) : (
116
+ <scrollbox
117
+ focused={state.activeView === "server-skills" && !state.showHelp}
118
+ style={{
119
+ width: "100%",
120
+ flexGrow: 1,
121
+ rootOptions: { backgroundColor: colors.bg },
122
+ viewportOptions: { backgroundColor: colors.bg },
123
+ contentOptions: { backgroundColor: colors.bg },
124
+ scrollbarOptions: {
125
+ trackOptions: {
126
+ foregroundColor: colors.primary,
127
+ backgroundColor: colors.border,
128
+ },
129
+ },
130
+ }}
131
+ >
132
+ {skillList.map((skill, i) => (
133
+ <box
134
+ key={skill.id}
135
+ style={{
136
+ width: "100%",
137
+ paddingLeft: 1,
138
+ paddingRight: 1,
139
+ flexDirection: "row",
140
+ backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
141
+ }}
142
+ >
143
+ <text fg={i === selectedIndex ? colors.primary : colors.text}>
144
+ {skill.name}
145
+ </text>
146
+ </box>
147
+ ))}
148
+ </scrollbox>
149
+ )}
150
+
151
+ {/* Bottom hints */}
152
+ <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
153
+ <text fg={colors.textDim}>i=install locally Esc=back</text>
154
+ </box>
155
+ </box>
156
+
157
+ {/* RIGHT: Skill detail */}
158
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
159
+ {selectedSkill ? (
160
+ <RemoteSkillDetail skill={selectedSkill} />
161
+ ) : (
162
+ <box style={{ padding: 1 }}>
163
+ <text fg={colors.textDim}>Select a skill to view details</text>
164
+ </box>
165
+ )}
166
+ </box>
167
+ </box>
168
+ </box>
169
+ )
170
+ }
171
+
172
+ // ---------- Remote Skill Detail ----------
173
+
174
+ function stripFrontmatter(content: string): string {
175
+ const lines = content.split("\n")
176
+ if (lines[0]?.trim() !== "---") return content
177
+ let endIndex = -1
178
+ for (let i = 1; i < lines.length; i++) {
179
+ if (lines[i].trim() === "---") {
180
+ endIndex = i
181
+ break
182
+ }
183
+ }
184
+ if (endIndex === -1) return content
185
+ return lines.slice(endIndex + 1).join("\n").trimStart()
186
+ }
187
+
188
+ interface RemoteSkillDetailProps {
189
+ skill: RemoteSkill
190
+ }
191
+
192
+ function RemoteSkillDetail({ skill }: RemoteSkillDetailProps) {
193
+ const content = skill.content ? stripFrontmatter(skill.content) : ""
194
+
195
+ return (
196
+ <scrollbox
197
+ focused={false}
198
+ style={{
199
+ width: "100%",
200
+ flexGrow: 1,
201
+ rootOptions: { backgroundColor: colors.bg },
202
+ viewportOptions: { backgroundColor: colors.bg },
203
+ contentOptions: { backgroundColor: colors.bg },
204
+ scrollbarOptions: {
205
+ trackOptions: {
206
+ foregroundColor: colors.primary,
207
+ backgroundColor: colors.border,
208
+ },
209
+ },
210
+ }}
211
+ >
212
+ <box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "column" }}>
213
+ {/* Name */}
214
+ <text fg={colors.primary}>
215
+ <strong>{skill.name}</strong>
216
+ </text>
217
+
218
+ {/* Description */}
219
+ {skill.description ? (
220
+ <text fg={colors.text}>{skill.description}</text>
221
+ ) : null}
222
+ <text>{" "}</text>
223
+
224
+ {/* Remote path */}
225
+ <box style={{ flexDirection: "row", height: 1 }}>
226
+ <text fg={colors.textDim}>Remote path: </text>
227
+ <text fg={colors.secondary}>{skill.remotePath}</text>
228
+ </box>
229
+
230
+ {/* Synced at */}
231
+ <box style={{ flexDirection: "row", height: 1 }}>
232
+ <text fg={colors.textDim}>Last synced: </text>
233
+ <text fg={colors.secondary}>{skill.syncedAt}</text>
234
+ </box>
235
+
236
+ <text>{" "}</text>
237
+ <text fg={colors.textDim}>i=install locally Esc=back to server list</text>
238
+ <text fg={colors.border}>---</text>
239
+
240
+ {/* Skill content */}
241
+ {content ? (
242
+ content.split("\n").map((line, i) => {
243
+ if (line.startsWith("### ")) {
244
+ return <text key={i} fg={colors.primary}>{line}</text>
245
+ }
246
+ if (line.startsWith("## ")) {
247
+ return <text key={i} fg={colors.primary}><strong>{line}</strong></text>
248
+ }
249
+ if (line.startsWith("# ")) {
250
+ return <text key={i} fg={colors.primary}><strong>{line}</strong></text>
251
+ }
252
+ if (line.startsWith("```")) {
253
+ return <text key={i} fg={colors.textDim}>{line}</text>
254
+ }
255
+ if (line.trimStart().startsWith("- ") || line.trimStart().startsWith("* ")) {
256
+ return <text key={i} fg={colors.text}>{line}</text>
257
+ }
258
+ if (!line.trim()) {
259
+ return <text key={i}>{" "}</text>
260
+ }
261
+ return <text key={i} fg={colors.text}>{line}</text>
262
+ })
263
+ ) : (
264
+ <text fg={colors.textDim}>(No skill content cached)</text>
265
+ )}
266
+ </box>
267
+ </scrollbox>
268
+ )
269
+ }