@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,138 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import crypto from "node:crypto"
3
+
4
+ export interface RemoteSkill {
5
+ id: string
6
+ serverId: string
7
+ name: string
8
+ description: string | null
9
+ remotePath: string
10
+ content: string | null
11
+ contentHash: string | null
12
+ syncedAt: string
13
+ }
14
+
15
+ export interface RemoteSkillWithServer extends RemoteSkill {
16
+ serverLabel: string
17
+ }
18
+
19
+ interface SkillRow {
20
+ id: string
21
+ server_id: string
22
+ name: string
23
+ description: string | null
24
+ remote_path: string
25
+ content: string | null
26
+ content_hash: string | null
27
+ synced_at: string
28
+ }
29
+
30
+ interface SkillRowWithServer extends SkillRow {
31
+ server_label: string
32
+ }
33
+
34
+ function rowToSkill(row: SkillRow): RemoteSkill {
35
+ return {
36
+ id: row.id,
37
+ serverId: row.server_id,
38
+ name: row.name,
39
+ description: row.description,
40
+ remotePath: row.remote_path,
41
+ content: row.content,
42
+ contentHash: row.content_hash,
43
+ syncedAt: row.synced_at,
44
+ }
45
+ }
46
+
47
+ export class RemoteSkillStore {
48
+ private db: Database
49
+
50
+ constructor(db: Database) {
51
+ this.db = db
52
+ }
53
+
54
+ listByServer(serverId: string): RemoteSkill[] {
55
+ const rows = this.db
56
+ .query("SELECT * FROM remote_skills WHERE server_id = ? ORDER BY name ASC")
57
+ .all(serverId) as SkillRow[]
58
+ return rows.map(rowToSkill)
59
+ }
60
+
61
+ listAll(): RemoteSkillWithServer[] {
62
+ const rows = this.db
63
+ .query(
64
+ `SELECT rs.*, s.label as server_label
65
+ FROM remote_skills rs
66
+ JOIN remote_servers s ON s.id = rs.server_id
67
+ ORDER BY rs.name ASC`
68
+ )
69
+ .all() as SkillRowWithServer[]
70
+ return rows.map((row) => ({
71
+ ...rowToSkill(row),
72
+ serverLabel: row.server_label,
73
+ }))
74
+ }
75
+
76
+ upsert(skill: {
77
+ serverId: string
78
+ name: string
79
+ description: string | null
80
+ remotePath: string
81
+ content: string | null
82
+ contentHash: string | null
83
+ }): void {
84
+ const id = crypto.randomUUID()
85
+ this.db
86
+ .query(
87
+ `INSERT INTO remote_skills (id, server_id, name, description, remote_path, content, content_hash, synced_at)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
89
+ ON CONFLICT(server_id, remote_path)
90
+ DO UPDATE SET
91
+ name = excluded.name,
92
+ description = excluded.description,
93
+ content = excluded.content,
94
+ content_hash = excluded.content_hash,
95
+ synced_at = excluded.synced_at`
96
+ )
97
+ .run(
98
+ id,
99
+ skill.serverId,
100
+ skill.name,
101
+ skill.description,
102
+ skill.remotePath,
103
+ skill.content,
104
+ skill.contentHash,
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Remove remote skills that no longer exist on the remote server.
110
+ * Returns the number of removed rows.
111
+ */
112
+ removeStale(serverId: string, currentPaths: string[]): number {
113
+ if (currentPaths.length === 0) {
114
+ // All skills were removed from remote; delete everything for this server
115
+ const info = this.db
116
+ .query("DELETE FROM remote_skills WHERE server_id = ?")
117
+ .run(serverId)
118
+ return info.changes
119
+ }
120
+
121
+ // Build a parameterized IN clause
122
+ const placeholders = currentPaths.map(() => "?").join(", ")
123
+ const info = this.db
124
+ .query(
125
+ `DELETE FROM remote_skills
126
+ WHERE server_id = ? AND remote_path NOT IN (${placeholders})`
127
+ )
128
+ .run(serverId, ...currentPaths)
129
+ return info.changes
130
+ }
131
+
132
+ getContent(id: string): string | null {
133
+ const row = this.db
134
+ .query("SELECT content FROM remote_skills WHERE id = ?")
135
+ .get(id) as { content: string | null } | null
136
+ return row?.content ?? null
137
+ }
138
+ }
package/src/db/ssh.ts ADDED
@@ -0,0 +1,319 @@
1
+ import { spawn } from "node:child_process"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import fs from "node:fs"
5
+ import crypto from "node:crypto"
6
+ import type { RemoteServer } from "./servers.js"
7
+ import type { RemoteServerStore } from "./servers.js"
8
+ import type { RemoteSkillStore } from "./skills.js"
9
+
10
+ // ---------- SSH Execution ----------
11
+
12
+ export interface SshResult {
13
+ stdout: string
14
+ stderr: string
15
+ exitCode: number
16
+ }
17
+
18
+ export function buildSshArgs(server: RemoteServer): string[] {
19
+ const home = os.homedir()
20
+ const args = [
21
+ "-p",
22
+ String(server.port),
23
+ "-o",
24
+ "ConnectTimeout=10",
25
+ "-o",
26
+ "BatchMode=yes",
27
+ "-o",
28
+ "StrictHostKeyChecking=accept-new",
29
+ ]
30
+
31
+ if (server.sshKeyPath) {
32
+ const resolved = server.sshKeyPath.startsWith("~/")
33
+ ? path.join(home, server.sshKeyPath.slice(2))
34
+ : server.sshKeyPath
35
+ args.push("-i", resolved)
36
+ } else {
37
+ // Auto-discover first existing default key
38
+ for (const name of ["id_ed25519", "id_rsa", "id_ecdsa"]) {
39
+ const keyPath = path.join(home, ".ssh", name)
40
+ if (fs.existsSync(keyPath)) {
41
+ args.push("-i", keyPath)
42
+ break
43
+ }
44
+ }
45
+ }
46
+
47
+ args.push(`${server.username}@${server.host}`)
48
+ return args
49
+ }
50
+
51
+ export function sshExec(server: RemoteServer, command: string): Promise<SshResult> {
52
+ return new Promise((resolve, reject) => {
53
+ const args = [...buildSshArgs(server), command]
54
+ const proc = spawn("ssh", args, {
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ })
57
+
58
+ let stdout = ""
59
+ let stderr = ""
60
+
61
+ proc.stdout.on("data", (data: Buffer) => {
62
+ stdout += data.toString()
63
+ })
64
+
65
+ proc.stderr.on("data", (data: Buffer) => {
66
+ stderr += data.toString()
67
+ })
68
+
69
+ proc.on("error", (err) => {
70
+ reject(new Error(`Failed to spawn ssh: ${err.message}`))
71
+ })
72
+
73
+ proc.on("close", (exitCode) => {
74
+ resolve({
75
+ stdout,
76
+ stderr,
77
+ exitCode: exitCode ?? 1,
78
+ })
79
+ })
80
+ })
81
+ }
82
+
83
+ export async function testConnection(
84
+ server: RemoteServer
85
+ ): Promise<{ ok: boolean; error?: string }> {
86
+ try {
87
+ const result = await sshExec(server, "echo ok")
88
+ if (result.exitCode === 0 && result.stdout.trim() === "ok") {
89
+ return { ok: true }
90
+ }
91
+ return { ok: false, error: result.stderr.trim() || `Exit code ${result.exitCode}` }
92
+ } catch (err) {
93
+ const msg = err instanceof Error ? err.message : String(err)
94
+ return { ok: false, error: msg }
95
+ }
96
+ }
97
+
98
+ // ---------- Remote Skill Scanner ----------
99
+
100
+ function shellQuotePath(remotePath: string): string {
101
+ let expanded = remotePath
102
+ if (expanded.startsWith("~/")) {
103
+ expanded = "$HOME/" + expanded.slice(2)
104
+ } else if (expanded === "~") {
105
+ expanded = "$HOME"
106
+ }
107
+ return `"${expanded.replace(/"/g, '\\"')}"`
108
+ }
109
+
110
+ const DELIMITER_PREFIX = "---SKILLSGATE_DELIM:"
111
+ const DELIMITER_SUFFIX = "---"
112
+
113
+ interface ScannedRemoteSkill {
114
+ name: string
115
+ description: string | null
116
+ remotePath: string
117
+ content: string
118
+ contentHash: string
119
+ }
120
+
121
+ /**
122
+ * Parse simple frontmatter from SKILL.md content to extract name and description.
123
+ */
124
+ function parseFrontmatter(content: string): { name: string; description: string | null } {
125
+ const lines = content.split("\n")
126
+ if (lines[0]?.trim() !== "---") {
127
+ // No frontmatter; derive name from first heading
128
+ for (const line of lines) {
129
+ if (line.startsWith("# ")) {
130
+ return { name: line.slice(2).trim(), description: null }
131
+ }
132
+ }
133
+ return { name: "unknown", description: null }
134
+ }
135
+
136
+ let name = "unknown"
137
+ let description: string | null = null
138
+
139
+ for (let i = 1; i < lines.length; i++) {
140
+ if (lines[i].trim() === "---") break
141
+ const match = lines[i].match(/^(\w+):\s*(.+)/)
142
+ if (match) {
143
+ const key = match[1].toLowerCase()
144
+ const value = match[2].trim().replace(/^["']|["']$/g, "")
145
+ if (key === "name") name = value
146
+ if (key === "description") description = value
147
+ }
148
+ }
149
+
150
+ return { name, description }
151
+ }
152
+
153
+ function sha256(content: string): string {
154
+ return crypto.createHash("sha256").update(content).digest("hex")
155
+ }
156
+
157
+ export async function scanRemoteSkills(
158
+ server: RemoteServer
159
+ ): Promise<ScannedRemoteSkill[]> {
160
+ const basePath = shellQuotePath(server.skillsBasePath)
161
+
162
+ // Round trip 1: Find all SKILL.md files
163
+ const findResult = await sshExec(
164
+ server,
165
+ `find ${basePath} -name 'SKILL.md' -type f 2>/dev/null`
166
+ )
167
+ if (findResult.exitCode !== 0 && findResult.stdout.trim() === "") {
168
+ throw new Error(`Find failed: ${findResult.stderr.trim()}`)
169
+ }
170
+
171
+ const paths = findResult.stdout.trim().split("\n").filter(Boolean)
172
+ if (paths.length === 0) return []
173
+
174
+ // Round trip 2: Batch read all files in a single SSH call
175
+ const catCommands = paths
176
+ .map((p) => {
177
+ const escaped = `'${p.replace(/'/g, "'\\''")}'`
178
+ return `echo '${DELIMITER_PREFIX}${p}${DELIMITER_SUFFIX}' && cat ${escaped}`
179
+ })
180
+ .join(" && ")
181
+
182
+ const catResult = await sshExec(server, catCommands)
183
+ if (catResult.exitCode !== 0) {
184
+ throw new Error(`Batch read failed: ${catResult.stderr.trim()}`)
185
+ }
186
+
187
+ return parseDelimitedOutput(catResult.stdout)
188
+ }
189
+
190
+ function parseDelimitedOutput(output: string): ScannedRemoteSkill[] {
191
+ const skills: ScannedRemoteSkill[] = []
192
+ let currentPath: string | null = null
193
+ let currentLines: string[] = []
194
+
195
+ for (const line of output.split("\n")) {
196
+ if (line.startsWith(DELIMITER_PREFIX) && line.endsWith(DELIMITER_SUFFIX)) {
197
+ // Flush previous skill
198
+ if (currentPath) {
199
+ const content = currentLines.join("\n").trim()
200
+ const { name, description } = parseFrontmatter(content)
201
+ skills.push({
202
+ name,
203
+ description,
204
+ remotePath: currentPath,
205
+ content,
206
+ contentHash: sha256(content),
207
+ })
208
+ }
209
+ // Start next skill
210
+ currentPath = line.slice(DELIMITER_PREFIX.length, -DELIMITER_SUFFIX.length)
211
+ currentLines = []
212
+ } else {
213
+ currentLines.push(line)
214
+ }
215
+ }
216
+
217
+ // Flush last skill
218
+ if (currentPath) {
219
+ const content = currentLines.join("\n").trim()
220
+ const { name, description } = parseFrontmatter(content)
221
+ skills.push({
222
+ name,
223
+ description,
224
+ remotePath: currentPath,
225
+ content,
226
+ contentHash: sha256(content),
227
+ })
228
+ }
229
+
230
+ return skills
231
+ }
232
+
233
+ // ---------- Sync Orchestrator ----------
234
+
235
+ export interface SyncResult {
236
+ added: number
237
+ updated: number
238
+ removed: number
239
+ unchanged: number
240
+ total: number
241
+ log: string[]
242
+ }
243
+
244
+ export async function syncRemoteServer(
245
+ server: RemoteServer,
246
+ serverStore: RemoteServerStore,
247
+ skillStore: RemoteSkillStore
248
+ ): Promise<SyncResult> {
249
+ const log: string[] = []
250
+ log.push(`Connecting to ${server.username}@${server.host}...`)
251
+
252
+ try {
253
+ const scanned = await scanRemoteSkills(server)
254
+
255
+ log.push(`Found ${scanned.length} skill(s):`)
256
+ for (const s of scanned) {
257
+ log.push(` ${s.remotePath}`)
258
+ }
259
+
260
+ // Get existing skills for comparison
261
+ const existing = skillStore.listByServer(server.id)
262
+ const existingByPath = new Map(existing.map((s) => [s.remotePath, s]))
263
+
264
+ let added = 0
265
+ let updated = 0
266
+ let unchanged = 0
267
+
268
+ for (const scannedSkill of scanned) {
269
+ const prev = existingByPath.get(scannedSkill.remotePath)
270
+ if (!prev) {
271
+ added++
272
+ } else if (prev.contentHash !== scannedSkill.contentHash) {
273
+ updated++
274
+ } else {
275
+ unchanged++
276
+ }
277
+
278
+ skillStore.upsert({
279
+ serverId: server.id,
280
+ name: scannedSkill.name,
281
+ description: scannedSkill.description,
282
+ remotePath: scannedSkill.remotePath,
283
+ content: scannedSkill.content,
284
+ contentHash: scannedSkill.contentHash,
285
+ })
286
+ }
287
+
288
+ // Remove stale skills
289
+ const currentPaths = scanned.map((s) => s.remotePath)
290
+ const removed = skillStore.removeStale(server.id, currentPaths)
291
+
292
+ // Update sync status on success
293
+ serverStore.updateSyncStatus(server.id, null)
294
+
295
+ const summary = `Synced: ${added} new, ${updated} updated, ${removed} removed, ${unchanged} unchanged`
296
+ log.push(summary)
297
+
298
+ return {
299
+ added,
300
+ updated,
301
+ removed,
302
+ unchanged,
303
+ total: scanned.length,
304
+ log,
305
+ }
306
+ } catch (err) {
307
+ const errorMsg = err instanceof Error ? err.message : String(err)
308
+ log.push(`SSH connection failed: ${errorMsg}`)
309
+ serverStore.updateSyncStatus(server.id, errorMsg)
310
+ return {
311
+ added: 0,
312
+ updated: 0,
313
+ removed: 0,
314
+ unchanged: 0,
315
+ total: 0,
316
+ log,
317
+ }
318
+ }
319
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,37 @@
1
+ import { createCliRenderer } from "@opentui/core"
2
+ import { createRoot } from "@opentui/react"
3
+ import { openDb } from "./db/index.js"
4
+ import { App } from "./app.js"
5
+
6
+ // Open the local SQLite database at ~/.skillsgate/skillsgate.db
7
+ const db = openDb()
8
+
9
+ const renderer = await createCliRenderer({
10
+ exitOnCtrlC: false,
11
+ useAlternateScreen: true,
12
+ })
13
+
14
+ const root = createRoot(renderer)
15
+ root.render(<App db={db} />)
16
+
17
+ // Make cleanExit available globally so layout.tsx can call it
18
+ ;(globalThis as any).__skillsgateTuiCleanExit = function cleanExit() {
19
+ try {
20
+ db.close()
21
+ } catch {
22
+ // ignore close errors
23
+ }
24
+ try {
25
+ renderer.destroy()
26
+ } catch {
27
+ // ignore destroy errors
28
+ }
29
+ // Ensure terminal is fully restored
30
+ process.stdout.write("\x1B[?1049l") // switch to main screen
31
+ process.stdout.write("\x1B[?25h") // show cursor
32
+ process.stdout.write("\x1Bc") // full reset (RIS)
33
+ process.exit(0)
34
+ }
35
+
36
+ process.on("SIGINT", (globalThis as any).__skillsgateTuiCleanExit)
37
+ process.on("SIGTERM", (globalThis as any).__skillsgateTuiCleanExit)
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from "react"
2
+ import type { AppState, Action } from "./types.js"
3
+ import { appReducer, initialState } from "./reducers.js"
4
+
5
+ const StoreContext = createContext<AppState>(initialState)
6
+ const DispatchContext = createContext<Dispatch<Action>>(() => {})
7
+
8
+ export function StoreProvider({ children }: { children: ReactNode }) {
9
+ const [state, dispatch] = useReducer(appReducer, initialState)
10
+
11
+ return (
12
+ <StoreContext.Provider value={state}>
13
+ <DispatchContext.Provider value={dispatch}>
14
+ {children}
15
+ </DispatchContext.Provider>
16
+ </StoreContext.Provider>
17
+ )
18
+ }
19
+
20
+ export function useStore(): AppState {
21
+ return useContext(StoreContext)
22
+ }
23
+
24
+ export function useDispatch(): Dispatch<Action> {
25
+ return useContext(DispatchContext)
26
+ }
@@ -0,0 +1,126 @@
1
+ import type { AppState, Action, FocusedPane } from "./types.js"
2
+
3
+ const FOCUS_ORDER: FocusedPane[] = ["agents", "search", "list"]
4
+
5
+ export const initialState: AppState = {
6
+ activeView: "home",
7
+ previousView: null,
8
+ auth: null,
9
+ detectedAgents: [],
10
+ selectedAgentFilter: "all",
11
+ installedSkills: [],
12
+ installedLoading: true,
13
+ installedFilter: "",
14
+ searchQuery: "",
15
+ searchResults: [],
16
+ searchLoading: false,
17
+ favorites: [],
18
+ favoritesLoading: false,
19
+ selectedSkill: null,
20
+ selectedServerId: null,
21
+ showHelp: false,
22
+ focusedPane: "list",
23
+ notification: null,
24
+ }
25
+
26
+ export function appReducer(state: AppState, action: Action): AppState {
27
+ switch (action.type) {
28
+ case "NAVIGATE":
29
+ return {
30
+ ...state,
31
+ previousView: state.activeView,
32
+ activeView: action.view,
33
+ }
34
+
35
+ case "GO_BACK":
36
+ if (!state.previousView) return state
37
+ return {
38
+ ...state,
39
+ activeView: state.previousView,
40
+ previousView: null,
41
+ }
42
+
43
+ case "SET_AUTH":
44
+ return { ...state, auth: action.auth }
45
+
46
+ case "SET_DETECTED_AGENTS":
47
+ return { ...state, detectedAgents: action.agents }
48
+
49
+ case "UPDATE_AGENT_COUNTS":
50
+ return {
51
+ ...state,
52
+ detectedAgents: state.detectedAgents.map(a => ({
53
+ ...a,
54
+ skillCount: action.counts[a.name] ?? 0,
55
+ })),
56
+ }
57
+
58
+ case "SET_AGENT_FILTER":
59
+ return { ...state, selectedAgentFilter: action.filter }
60
+
61
+ case "SET_INSTALLED_SKILLS":
62
+ return { ...state, installedSkills: action.skills, installedLoading: false }
63
+
64
+ case "SET_INSTALLED_LOADING":
65
+ return { ...state, installedLoading: action.loading }
66
+
67
+ case "SET_INSTALLED_FILTER":
68
+ return { ...state, installedFilter: action.filter }
69
+
70
+ case "SET_SEARCH_QUERY":
71
+ return { ...state, searchQuery: action.query }
72
+
73
+ case "SET_SEARCH_RESULTS":
74
+ return { ...state, searchResults: action.results, searchLoading: false }
75
+
76
+ case "SET_SEARCH_LOADING":
77
+ return { ...state, searchLoading: action.loading }
78
+
79
+ case "SET_FAVORITES":
80
+ return { ...state, favorites: action.favorites, favoritesLoading: false }
81
+
82
+ case "SET_FAVORITES_LOADING":
83
+ return { ...state, favoritesLoading: action.loading }
84
+
85
+ case "SELECT_SKILL":
86
+ return {
87
+ ...state,
88
+ selectedSkill: action.skill,
89
+ previousView: state.activeView,
90
+ activeView: "detail",
91
+ }
92
+
93
+ case "PREVIEW_SKILL":
94
+ return { ...state, selectedSkill: action.skill }
95
+
96
+ case "CLEAR_SKILL":
97
+ return { ...state, selectedSkill: null }
98
+
99
+ case "SHOW_NOTIFICATION":
100
+ return { ...state, notification: action.notification }
101
+
102
+ case "CLEAR_NOTIFICATION":
103
+ return { ...state, notification: null }
104
+
105
+ case "TOGGLE_HELP":
106
+ return { ...state, showHelp: !state.showHelp }
107
+
108
+ case "SET_FOCUSED_PANE":
109
+ return { ...state, focusedPane: action.pane }
110
+
111
+ case "CYCLE_FOCUS": {
112
+ const currentIdx = FOCUS_ORDER.indexOf(state.focusedPane)
113
+ const nextIdx = (currentIdx + 1) % FOCUS_ORDER.length
114
+ return { ...state, focusedPane: FOCUS_ORDER[nextIdx] }
115
+ }
116
+
117
+ case "REFRESH_SKILLS":
118
+ return { ...state, installedLoading: true }
119
+
120
+ case "SET_SELECTED_SERVER":
121
+ return { ...state, selectedServerId: action.serverId }
122
+
123
+ default:
124
+ return state
125
+ }
126
+ }