@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.
- package/bin/skillsgate-tui +28 -0
- package/bunfig.toml +3 -0
- package/package.json +24 -0
- package/src/app.tsx +18 -0
- package/src/components/agent-filter.tsx +162 -0
- package/src/components/confirm-dialog.tsx +56 -0
- package/src/components/help-overlay.tsx +101 -0
- package/src/components/layout.tsx +272 -0
- package/src/components/search-input.tsx +48 -0
- package/src/components/skill-list-item.tsx +45 -0
- package/src/components/skill-list.tsx +245 -0
- package/src/components/status-bar.tsx +34 -0
- package/src/data/api-client.ts +151 -0
- package/src/data/use-agents.ts +41 -0
- package/src/data/use-auth.ts +136 -0
- package/src/data/use-favorites.ts +147 -0
- package/src/data/use-installed-skills.ts +128 -0
- package/src/data/use-search.ts +118 -0
- package/src/data/use-skill-actions.ts +333 -0
- package/src/db/context.tsx +38 -0
- package/src/db/index.ts +19 -0
- package/src/db/migrations.ts +72 -0
- package/src/db/servers.ts +154 -0
- package/src/db/settings.ts +43 -0
- package/src/db/skills.ts +138 -0
- package/src/db/ssh.ts +319 -0
- package/src/index.tsx +37 -0
- package/src/store/context.tsx +26 -0
- package/src/store/reducers.ts +126 -0
- package/src/store/types.ts +124 -0
- package/src/utils/colors.ts +42 -0
- package/src/views/add-server.tsx +240 -0
- package/src/views/discover.tsx +419 -0
- package/src/views/favorites.tsx +358 -0
- package/src/views/home.tsx +218 -0
- package/src/views/login.tsx +202 -0
- package/src/views/server-skills.tsx +269 -0
- package/src/views/servers.tsx +449 -0
- package/src/views/settings.tsx +185 -0
- package/src/views/skill-detail.tsx +497 -0
- 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
|
+
}
|