@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
package/src/db/skills.ts
ADDED
|
@@ -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
|
+
}
|