@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,124 @@
1
+ import type { AgentType, SkillLockEntry, SourceType } from "../../../cli/src/types.js"
2
+
3
+ // ---------- View Names ----------
4
+
5
+ export type ViewName =
6
+ | "home"
7
+ | "discover"
8
+ | "favorites"
9
+ | "servers"
10
+ | "server-skills"
11
+ | "add-server"
12
+ | "edit-server"
13
+ | "settings"
14
+ | "detail"
15
+ | "login"
16
+
17
+ // ---------- Enriched Skill ----------
18
+
19
+ export interface EnrichedSkill {
20
+ name: string
21
+ description: string
22
+ filePath: string
23
+ /** Which agents have this skill installed (by agent name) */
24
+ agents: AgentType[]
25
+ /** Frontmatter metadata from the SKILL.md */
26
+ metadata: Record<string, unknown>
27
+ /** Lock file entry if tracked */
28
+ lock?: SkillLockEntry
29
+ }
30
+
31
+ // ---------- Detected Agent ----------
32
+
33
+ export interface DetectedAgent {
34
+ name: AgentType
35
+ displayName: string
36
+ skillCount: number
37
+ }
38
+
39
+ // ---------- Auth ----------
40
+
41
+ export interface AuthState {
42
+ token: string
43
+ user: { id: string; name: string; email: string }
44
+ }
45
+
46
+ // ---------- Notification ----------
47
+
48
+ export interface Notification {
49
+ type: "success" | "error" | "info"
50
+ message: string
51
+ }
52
+
53
+ // ---------- Focus Pane ----------
54
+
55
+ export type FocusedPane = "agents" | "search" | "list"
56
+
57
+ // ---------- App State ----------
58
+
59
+ export interface AppState {
60
+ activeView: ViewName
61
+ previousView: ViewName | null
62
+
63
+ // Auth
64
+ auth: AuthState | null
65
+
66
+ // Agent detection
67
+ detectedAgents: DetectedAgent[]
68
+
69
+ // Installed skills (home view)
70
+ selectedAgentFilter: string // agent name or "all"
71
+ installedSkills: EnrichedSkill[]
72
+ installedLoading: boolean
73
+ installedFilter: string
74
+
75
+ // Search / discover
76
+ searchQuery: string
77
+ searchResults: unknown[]
78
+ searchLoading: boolean
79
+
80
+ // Favorites
81
+ favorites: unknown[]
82
+ favoritesLoading: boolean
83
+
84
+ // Detail
85
+ selectedSkill: EnrichedSkill | null
86
+
87
+ // Servers
88
+ selectedServerId: string | null
89
+
90
+ // UI state
91
+ showHelp: boolean
92
+ focusedPane: FocusedPane
93
+
94
+ // Notification toast
95
+ notification: Notification | null
96
+ }
97
+
98
+ // ---------- Actions ----------
99
+
100
+ export type Action =
101
+ | { type: "NAVIGATE"; view: ViewName }
102
+ | { type: "GO_BACK" }
103
+ | { type: "SET_AUTH"; auth: AuthState | null }
104
+ | { type: "SET_DETECTED_AGENTS"; agents: DetectedAgent[] }
105
+ | { type: "UPDATE_AGENT_COUNTS"; counts: Record<string, number> }
106
+ | { type: "SET_AGENT_FILTER"; filter: string }
107
+ | { type: "SET_INSTALLED_SKILLS"; skills: EnrichedSkill[] }
108
+ | { type: "SET_INSTALLED_LOADING"; loading: boolean }
109
+ | { type: "SET_INSTALLED_FILTER"; filter: string }
110
+ | { type: "SET_SEARCH_QUERY"; query: string }
111
+ | { type: "SET_SEARCH_RESULTS"; results: unknown[] }
112
+ | { type: "SET_SEARCH_LOADING"; loading: boolean }
113
+ | { type: "SET_FAVORITES"; favorites: unknown[] }
114
+ | { type: "SET_FAVORITES_LOADING"; loading: boolean }
115
+ | { type: "SELECT_SKILL"; skill: EnrichedSkill }
116
+ | { type: "PREVIEW_SKILL"; skill: EnrichedSkill }
117
+ | { type: "CLEAR_SKILL" }
118
+ | { type: "SHOW_NOTIFICATION"; notification: Notification }
119
+ | { type: "CLEAR_NOTIFICATION" }
120
+ | { type: "TOGGLE_HELP" }
121
+ | { type: "SET_FOCUSED_PANE"; pane: FocusedPane }
122
+ | { type: "CYCLE_FOCUS" }
123
+ | { type: "REFRESH_SKILLS" }
124
+ | { type: "SET_SELECTED_SERVER"; serverId: string | null }
@@ -0,0 +1,42 @@
1
+ export const colors = {
2
+ primary: "#00BFFF", // cyan - skill names, highlights
3
+ secondary: "#888888", // dim gray - descriptions, secondary text
4
+ success: "#00FF00", // green - success messages
5
+ error: "#FF4444", // red - errors
6
+ warning: "#FFAA00", // amber - warnings
7
+ agent: "#FF00FF", // magenta - agent badges (fallback)
8
+ bg: "#1a1a1a", // dark background
9
+ bgAlt: "#2a2a2a", // slightly lighter background
10
+ border: "#444444", // border color
11
+ text: "#FFFFFF", // primary text
12
+ textDim: "#888888", // dimmed text
13
+ header: "#1e1e2e", // header background
14
+ statusBar: "#1e1e2e", // status bar background
15
+ tabActive: "#334455", // active tab background
16
+ tabText: "#FFFF00", // active tab text (yellow)
17
+ } as const
18
+
19
+ /**
20
+ * Compact single/two-letter agent badge with a unique color per agent.
21
+ * Used in list items and detail views for a tighter layout than full names.
22
+ */
23
+ export const agentBadges: Record<string, { label: string; color: string }> = {
24
+ "claude-code": { label: "C", color: "#FFAA00" }, // amber
25
+ cursor: { label: "Cu", color: "#5599FF" }, // blue
26
+ windsurf: { label: "W", color: "#00CED1" }, // cyan
27
+ "codex-cli": { label: "Cx", color: "#FF4444" }, // red
28
+ opencode: { label: "O", color: "#2ECCAA" }, // teal
29
+ zed: { label: "Z", color: "#FFFF00" }, // yellow
30
+ "github-copilot": { label: "Gh", color: "#8B5CF6" }, // purple
31
+ cline: { label: "Cl", color: "#F472B6" }, // pink
32
+ continue: { label: "Cn", color: "#34D399" }, // emerald
33
+ amp: { label: "A", color: "#F97316" }, // orange
34
+ goose: { label: "G", color: "#A3E635" }, // lime
35
+ junie: { label: "J", color: "#E879F9" }, // fuchsia
36
+ "kilo-code": { label: "K", color: "#67E8F9" }, // light cyan
37
+ openclaw: { label: "Oc", color: "#FB923C" }, // light orange
38
+ "pear-ai": { label: "P", color: "#86EFAC" }, // light green
39
+ "roo-code": { label: "R", color: "#FCA5A5" }, // light red
40
+ trae: { label: "T", color: "#C4B5FD" }, // lavender
41
+ universal: { label: "U", color: "#888888" }, // dim
42
+ }
@@ -0,0 +1,240 @@
1
+ import { useState, useCallback } 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 { testConnection, syncRemoteServer } from "../db/ssh.js"
6
+ import { colors } from "../utils/colors.js"
7
+
8
+ interface AddServerViewProps {
9
+ editServerId: string | null
10
+ onServerCountChange: (count: number) => void
11
+ }
12
+
13
+ type FieldName = "label" | "host" | "port" | "username" | "skillsBasePath" | "sshKeyPath"
14
+
15
+ const FIELDS: { name: FieldName; label: string; placeholder: string; defaultValue: string }[] = [
16
+ { name: "label", label: "Label", placeholder: "e.g. prod-box", defaultValue: "" },
17
+ { name: "host", label: "Host", placeholder: "e.g. 192.168.1.100 or dev.example.com", defaultValue: "" },
18
+ { name: "port", label: "Port", placeholder: "22", defaultValue: "22" },
19
+ { name: "username", label: "Username", placeholder: "e.g. sultan", defaultValue: "" },
20
+ { name: "skillsBasePath", label: "Skills Base Path", placeholder: "~/.agents/skills", defaultValue: "~/.agents/skills" },
21
+ { name: "sshKeyPath", label: "SSH Key Path (optional)", placeholder: "(auto-discover)", defaultValue: "" },
22
+ ]
23
+
24
+ export function AddServerView({ editServerId, onServerCountChange }: AddServerViewProps) {
25
+ const state = useStore()
26
+ const dispatch = useDispatch()
27
+ const { servers, skills } = useDb()
28
+
29
+ // Load existing server data if editing
30
+ const existingServer = editServerId ? servers.get(editServerId) : null
31
+ const isEdit = !!existingServer
32
+
33
+ const [values, setValues] = useState<Record<FieldName, string>>(() => {
34
+ if (existingServer) {
35
+ return {
36
+ label: existingServer.label,
37
+ host: existingServer.host,
38
+ port: String(existingServer.port),
39
+ username: existingServer.username,
40
+ skillsBasePath: existingServer.skillsBasePath,
41
+ sshKeyPath: existingServer.sshKeyPath ?? "",
42
+ }
43
+ }
44
+ const initial: Record<FieldName, string> = {} as any
45
+ for (const field of FIELDS) {
46
+ initial[field.name] = field.defaultValue
47
+ }
48
+ return initial
49
+ })
50
+
51
+ const [focusedFieldIndex, setFocusedFieldIndex] = useState(0)
52
+ const [saving, setSaving] = useState(false)
53
+ const [error, setError] = useState<string | null>(null)
54
+
55
+ const handleFieldChange = useCallback((fieldName: FieldName, value: string) => {
56
+ setValues((prev) => ({ ...prev, [fieldName]: value }))
57
+ }, [])
58
+
59
+ const handleSave = useCallback(async () => {
60
+ setError(null)
61
+
62
+ // Validate required fields
63
+ if (!values.label.trim()) {
64
+ setError("Label is required")
65
+ return
66
+ }
67
+ if (!values.host.trim()) {
68
+ setError("Host is required")
69
+ return
70
+ }
71
+ if (!values.username.trim()) {
72
+ setError("Username is required")
73
+ return
74
+ }
75
+
76
+ const port = parseInt(values.port || "22", 10)
77
+ if (isNaN(port) || port < 1 || port > 65535) {
78
+ setError("Port must be between 1 and 65535")
79
+ return
80
+ }
81
+
82
+ setSaving(true)
83
+
84
+ try {
85
+ if (isEdit && editServerId) {
86
+ servers.update(editServerId, {
87
+ label: values.label.trim(),
88
+ host: values.host.trim(),
89
+ port,
90
+ username: values.username.trim(),
91
+ skillsBasePath: values.skillsBasePath.trim() || "~/.agents/skills",
92
+ sshKeyPath: values.sshKeyPath.trim() || null,
93
+ })
94
+
95
+ dispatch({
96
+ type: "SHOW_NOTIFICATION",
97
+ notification: { type: "success", message: `Updated "${values.label}"` },
98
+ })
99
+ } else {
100
+ const newServer = servers.create({
101
+ label: values.label.trim(),
102
+ host: values.host.trim(),
103
+ port,
104
+ username: values.username.trim(),
105
+ skillsBasePath: values.skillsBasePath.trim() || "~/.agents/skills",
106
+ sshKeyPath: values.sshKeyPath.trim() || null,
107
+ })
108
+
109
+ dispatch({
110
+ type: "SHOW_NOTIFICATION",
111
+ notification: { type: "info", message: `Testing connection to ${values.host}...` },
112
+ })
113
+
114
+ // Auto-test and auto-sync after creating
115
+ const testResult = await testConnection(newServer)
116
+ if (testResult.ok) {
117
+ dispatch({
118
+ type: "SHOW_NOTIFICATION",
119
+ notification: { type: "info", message: `Connected. Syncing skills from ${values.label}...` },
120
+ })
121
+ await syncRemoteServer(newServer, servers, skills)
122
+ dispatch({
123
+ type: "SHOW_NOTIFICATION",
124
+ notification: { type: "success", message: `Added and synced "${values.label}"` },
125
+ })
126
+ } else {
127
+ dispatch({
128
+ type: "SHOW_NOTIFICATION",
129
+ notification: {
130
+ type: "error",
131
+ message: `Added "${values.label}" but connection failed: ${testResult.error}`,
132
+ },
133
+ })
134
+ }
135
+ }
136
+
137
+ onServerCountChange(servers.list().length)
138
+ setSaving(false)
139
+ dispatch({ type: "GO_BACK" })
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : String(err)
142
+ setSaving(false)
143
+ if (msg.includes("UNIQUE constraint")) {
144
+ setError("A server with that host/port/username already exists")
145
+ } else {
146
+ setError(msg)
147
+ }
148
+ }
149
+ }, [values, isEdit, editServerId, servers, skills, dispatch, onServerCountChange])
150
+
151
+ // Navigate between fields with Tab / Shift+Tab, save with Ctrl+S
152
+ useKeyboard((key) => {
153
+ if (state.activeView !== "add-server" && state.activeView !== "edit-server") return
154
+ if (state.showHelp) return
155
+
156
+ // Esc to cancel handled by the layout
157
+
158
+ // Tab to next field
159
+ if (key.name === "tab" && !key.shift) {
160
+ setFocusedFieldIndex((i) => Math.min(FIELDS.length - 1, i + 1))
161
+ return
162
+ }
163
+
164
+ // Shift+Tab to previous field
165
+ if (key.name === "tab" && key.shift) {
166
+ setFocusedFieldIndex((i) => Math.max(0, i - 1))
167
+ return
168
+ }
169
+
170
+ // Ctrl+S to save
171
+ if (key.name === "s" && key.ctrl) {
172
+ handleSave()
173
+ return
174
+ }
175
+ })
176
+
177
+ return (
178
+ <box style={{ flexDirection: "column", padding: 2, flexGrow: 1 }}>
179
+ {/* Title */}
180
+ <text fg={colors.primary}>
181
+ <strong>{isEdit ? "Edit Server" : "Add Remote Server"}</strong>
182
+ </text>
183
+ <text>{" "}</text>
184
+
185
+ {/* Form fields */}
186
+ {FIELDS.map((field, i) => (
187
+ <box key={field.name} style={{ flexDirection: "column", marginBottom: 0 }}>
188
+ <text fg={i === focusedFieldIndex ? colors.primary : colors.textDim}>
189
+ {field.label}
190
+ </text>
191
+ <box
192
+ style={{
193
+ height: 3,
194
+ width: 60,
195
+ border: true,
196
+ borderColor: i === focusedFieldIndex ? colors.primary : colors.border,
197
+ paddingLeft: 1,
198
+ paddingRight: 1,
199
+ }}
200
+ >
201
+ <input
202
+ placeholder={field.placeholder}
203
+ focused={i === focusedFieldIndex && !state.showHelp && !saving}
204
+ defaultValue={values[field.name]}
205
+ onInput={(value: string) => handleFieldChange(field.name, value)}
206
+ onSubmit={() => {
207
+ // When pressing Enter on the last field, save
208
+ if (i === FIELDS.length - 1) {
209
+ handleSave()
210
+ } else {
211
+ setFocusedFieldIndex(i + 1)
212
+ }
213
+ }}
214
+ />
215
+ </box>
216
+ </box>
217
+ ))}
218
+
219
+ {/* Error message */}
220
+ {error ? (
221
+ <>
222
+ <text>{" "}</text>
223
+ <text fg={colors.error}>{error}</text>
224
+ </>
225
+ ) : null}
226
+
227
+ {/* Status / hints */}
228
+ <text>{" "}</text>
229
+ {saving ? (
230
+ <text fg={colors.primary}>
231
+ {isEdit ? "Saving..." : "Saving and testing connection..."}
232
+ </text>
233
+ ) : (
234
+ <text fg={colors.textDim}>
235
+ Tab=next field Shift+Tab=prev Ctrl+S=save Esc=cancel
236
+ </text>
237
+ )}
238
+ </box>
239
+ )
240
+ }