@skillsgate/tui 0.1.14 → 0.2.0

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.
@@ -0,0 +1,251 @@
1
+ // packages/tui/src/views/push-dialog.tsx
2
+ import { useState } from "react"
3
+ import { useKeyboard } from "@opentui/react"
4
+ import { colors } from "../utils/colors.js"
5
+ import type { RemoteServer } from "../db/servers.js"
6
+ import { planPush, applyPush } from "../db/push.js"
7
+ import type { PushPreview, PushResult } from "../db/push.js"
8
+
9
+ interface PushDialogProps {
10
+ server: RemoteServer
11
+ onClose: (result?: PushResult) => void
12
+ }
13
+
14
+ type Stage = "choose" | "previewing" | "review" | "applying" | "done" | "error"
15
+
16
+ /**
17
+ * TUI push dialog. State machine: choose -> previewing -> review -> applying -> done | error.
18
+ * Keybindings:
19
+ * m toggle Mirror mode (in choose)
20
+ * Enter advance (choose->preview, review->apply if changes exist)
21
+ * Esc cancel / go back
22
+ */
23
+ export function PushDialog({ server, onClose }: PushDialogProps) {
24
+ const [mirror, setMirror] = useState(false)
25
+ const [stage, setStage] = useState<Stage>("choose")
26
+ const [preview, setPreview] = useState<PushPreview | null>(null)
27
+ const [result, setResult] = useState<PushResult | null>(null)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ const totalChanges =
31
+ (preview?.toAdd.length ?? 0) +
32
+ (preview?.toUpdate.length ?? 0) +
33
+ (preview?.toDelete.length ?? 0)
34
+
35
+ useKeyboard((key) => {
36
+ if (stage === "previewing" || stage === "applying") return // no input while running
37
+
38
+ if (key.name === "escape") {
39
+ if (stage === "review") {
40
+ // Go back to choose
41
+ setStage("choose")
42
+ setPreview(null)
43
+ return
44
+ }
45
+ onClose()
46
+ return
47
+ }
48
+
49
+ if (stage === "choose") {
50
+ if (key.name === "m") {
51
+ setMirror((v) => !v)
52
+ return
53
+ }
54
+ if (key.name === "return") {
55
+ runPreview()
56
+ return
57
+ }
58
+ }
59
+
60
+ if (stage === "review") {
61
+ if (key.name === "return" && totalChanges > 0) {
62
+ runApply()
63
+ return
64
+ }
65
+ }
66
+
67
+ if (stage === "done" || stage === "error") {
68
+ if (key.name === "return" || key.name === "escape") {
69
+ onClose(result ?? undefined)
70
+ return
71
+ }
72
+ }
73
+ })
74
+
75
+ async function runPreview() {
76
+ setStage("previewing")
77
+ setError(null)
78
+ try {
79
+ const p = await planPush(server, { mirror })
80
+ setPreview(p)
81
+ setStage("review")
82
+ } catch (err) {
83
+ setError(err instanceof Error ? err.message : String(err))
84
+ setStage("error")
85
+ }
86
+ }
87
+
88
+ async function runApply() {
89
+ if (!preview) return
90
+ setStage("applying")
91
+ setError(null)
92
+ try {
93
+ const r = await applyPush(server, preview)
94
+ setResult(r)
95
+ setStage("done")
96
+ } catch (err) {
97
+ setError(err instanceof Error ? err.message : String(err))
98
+ setStage("error")
99
+ }
100
+ }
101
+
102
+ return (
103
+ <box
104
+ style={{
105
+ width: "100%",
106
+ height: "100%",
107
+ justifyContent: "center",
108
+ alignItems: "center",
109
+ backgroundColor: colors.bg,
110
+ }}
111
+ >
112
+ <box
113
+ style={{
114
+ width: 72,
115
+ border: true,
116
+ borderColor: colors.primary,
117
+ backgroundColor: "#1a1a2e",
118
+ paddingLeft: 2,
119
+ paddingRight: 2,
120
+ paddingTop: 1,
121
+ paddingBottom: 1,
122
+ flexDirection: "column",
123
+ }}
124
+ title={`Push to ${server.label}`}
125
+ >
126
+ {stage === "choose" && (
127
+ <>
128
+ <text fg={colors.primary}>
129
+ <strong>Push to {server.label}</strong>
130
+ </text>
131
+ <text>{" "}</text>
132
+ <text fg={mirror ? colors.textDim : colors.success}>
133
+ {mirror ? " " : "> "}[Push] additive — adds/updates only, never deletes
134
+ </text>
135
+ <text fg={mirror ? colors.warning : colors.textDim}>
136
+ {mirror ? "> " : " "}[Mirror] one-to-one — also deletes remote-only skills
137
+ </text>
138
+ <text>{" "}</text>
139
+ <text fg={colors.textDim}>
140
+ m=toggle mode Enter=preview Esc=cancel
141
+ </text>
142
+ </>
143
+ )}
144
+
145
+ {stage === "previewing" && (
146
+ <>
147
+ <text fg={colors.textDim}>Computing diff...</text>
148
+ </>
149
+ )}
150
+
151
+ {stage === "review" && preview && (
152
+ <>
153
+ <text fg={colors.primary}>
154
+ <strong>Preview</strong>
155
+ {preview.mirror ? (
156
+ <span fg={colors.warning}> (Mirror mode)</span>
157
+ ) : null}
158
+ </text>
159
+ <text>{" "}</text>
160
+ <text fg={colors.text}>
161
+ <span fg={colors.success}>{preview.toAdd.length} to add</span>
162
+ {" "}
163
+ <span fg={colors.warning}>{preview.toUpdate.length} to update</span>
164
+ {preview.mirror ? (
165
+ <>
166
+ {" "}
167
+ <span fg={colors.error}>{preview.toDelete.length} to delete</span>
168
+ </>
169
+ ) : null}
170
+ {" "}
171
+ <span fg={colors.textDim}>{preview.unchanged.length} unchanged</span>
172
+ </text>
173
+ <text>{" "}</text>
174
+ {totalChanges === 0 ? (
175
+ <text fg={colors.textDim}>Nothing to push — remote already matches local.</text>
176
+ ) : (
177
+ <>
178
+ {preview.toAdd.map((e, i) => (
179
+ <text key={`add-${i}`} fg={colors.success}>+ {e.folderName}</text>
180
+ ))}
181
+ {preview.toUpdate.map((e, i) => (
182
+ <text key={`upd-${i}`} fg={colors.warning}>~ {e.folderName}</text>
183
+ ))}
184
+ {preview.toDelete.map((e, i) => (
185
+ <text key={`del-${i}`} fg={colors.error}>- {e.folderName}</text>
186
+ ))}
187
+ </>
188
+ )}
189
+ {preview.mirror && preview.toDelete.length > 0 && (
190
+ <>
191
+ <text>{" "}</text>
192
+ <text fg={colors.error}>
193
+ Mirror will delete {preview.toDelete.length} skill{preview.toDelete.length === 1 ? "" : "s"} from remote.
194
+ </text>
195
+ </>
196
+ )}
197
+ <text>{" "}</text>
198
+ <text fg={colors.textDim}>
199
+ {totalChanges > 0
200
+ ? preview.mirror && preview.toDelete.length > 0
201
+ ? "Enter=confirm mirror (destructive) Esc=back"
202
+ : "Enter=apply Esc=back"
203
+ : "Esc=close"}
204
+ </text>
205
+ </>
206
+ )}
207
+
208
+ {stage === "applying" && (
209
+ <text fg={colors.textDim}>Pushing to remote...</text>
210
+ )}
211
+
212
+ {stage === "done" && result && (
213
+ <>
214
+ <text fg={colors.success}><strong>Push complete</strong></text>
215
+ <text>{" "}</text>
216
+ <text fg={colors.text}>
217
+ <span fg={colors.success}>{result.added} added</span>
218
+ {" "}
219
+ <span fg={colors.warning}>{result.updated} updated</span>
220
+ {" "}
221
+ <span fg={result.deleted > 0 ? colors.error : colors.textDim}>{result.deleted} deleted</span>
222
+ {" "}
223
+ <span fg={colors.textDim}>{result.unchanged} unchanged</span>
224
+ </text>
225
+ {result.errors.length > 0 && (
226
+ <>
227
+ <text>{" "}</text>
228
+ <text fg={colors.error}>Errors ({result.errors.length}):</text>
229
+ {result.errors.map((e, i) => (
230
+ <text key={i} fg={colors.error}> {e.folderName}: {e.message}</text>
231
+ ))}
232
+ </>
233
+ )}
234
+ <text>{" "}</text>
235
+ <text fg={colors.textDim}>Enter or Esc to close</text>
236
+ </>
237
+ )}
238
+
239
+ {stage === "error" && (
240
+ <>
241
+ <text fg={colors.error}><strong>Push failed</strong></text>
242
+ <text>{" "}</text>
243
+ <text fg={colors.error}>{error || "Unknown error"}</text>
244
+ <text>{" "}</text>
245
+ <text fg={colors.textDim}>Enter or Esc to close</text>
246
+ </>
247
+ )}
248
+ </box>
249
+ </box>
250
+ )
251
+ }
@@ -6,6 +6,7 @@ import { ConfirmDialog } from "../components/confirm-dialog.js"
6
6
  import { testConnection, syncRemoteServer } from "../db/ssh.js"
7
7
  import { colors } from "../utils/colors.js"
8
8
  import type { RemoteServer } from "../db/servers.js"
9
+ import { PushDialog } from "./push-dialog.js"
9
10
 
10
11
  interface ServersViewProps {
11
12
  onServerCountChange: (count: number) => void
@@ -40,6 +41,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
40
41
  const [serverList, setServerList] = useState<RemoteServer[]>(() => servers.list())
41
42
  const [selectedIndex, setSelectedIndex] = useState(0)
42
43
  const [pendingAction, setPendingAction] = useState<PendingAction>(null)
44
+ const [pushTarget, setPushTarget] = useState<RemoteServer | null>(null)
43
45
  const [syncing, setSyncing] = useState(false)
44
46
  const [syncLog, setSyncLog] = useState<string[] | null>(null)
45
47
  const [testing, setTesting] = useState(false)
@@ -67,6 +69,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
67
69
  if (state.activeView !== "servers") return
68
70
  if (state.showHelp) return
69
71
  if (pendingAction) return
72
+ if (pushTarget) return
70
73
  if (syncing || testing) return
71
74
 
72
75
  // j/k or arrow keys
@@ -116,6 +119,12 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
116
119
  return
117
120
  }
118
121
 
122
+ // P = push to selected server
123
+ if (key.name === "P" && selectedServer) {
124
+ setPushTarget(selectedServer)
125
+ return
126
+ }
127
+
119
128
  // Enter = browse server's skills
120
129
  if (key.name === "return" && selectedServer) {
121
130
  const count = skillCounts.get(selectedServer.id) ?? 0
@@ -169,18 +178,21 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
169
178
  setSyncing(false)
170
179
  refreshList()
171
180
 
172
- if (result.total > 0 || result.removed > 0) {
181
+ if (result.error) {
173
182
  dispatch({
174
183
  type: "SHOW_NOTIFICATION",
175
184
  notification: {
176
- type: "success",
177
- message: `Synced ${server.label}: ${result.added} new, ${result.updated} updated, ${result.removed} removed`,
185
+ type: "error",
186
+ message: `Sync failed for ${server.label}: ${result.error}`,
178
187
  },
179
188
  })
180
- } else if (result.log.some((l) => l.includes("failed"))) {
189
+ } else if (result.total > 0 || result.removed > 0) {
181
190
  dispatch({
182
191
  type: "SHOW_NOTIFICATION",
183
- notification: { type: "error", message: `Sync failed for ${server.label}` },
192
+ notification: {
193
+ type: "success",
194
+ message: `Synced ${server.label}: ${result.added} new, ${result.updated} updated, ${result.removed} removed`,
195
+ },
184
196
  })
185
197
  } else {
186
198
  dispatch({
@@ -190,6 +202,19 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
190
202
  }
191
203
  }, [servers, skills, dispatch, refreshList])
192
204
 
205
+ // Push dialog
206
+ if (pushTarget) {
207
+ return (
208
+ <PushDialog
209
+ server={pushTarget}
210
+ onClose={() => {
211
+ setPushTarget(null)
212
+ refreshList()
213
+ }}
214
+ />
215
+ )
216
+ }
217
+
193
218
  // Confirm dialog for delete
194
219
  if (pendingAction?.type === "delete") {
195
220
  return (
@@ -324,7 +349,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
324
349
  {/* Bottom shortcut hints */}
325
350
  <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
326
351
  <text fg={colors.textDim}>
327
- S=sync Enter=browse a=add e=edit d=delete t=test
352
+ S=sync P=push Enter=browse a=add e=edit d=delete t=test
328
353
  </text>
329
354
  </box>
330
355
  </box>
@@ -397,7 +422,7 @@ function ServerDetailPanel({ server, skillCount }: ServerDetailPanelProps) {
397
422
  ) : null}
398
423
 
399
424
  <text fg={colors.border}>---</text>
400
- <text fg={colors.textDim}>S=sync t=test e=edit d=delete Enter=browse skills</text>
425
+ <text fg={colors.textDim}>S=sync P=push t=test e=edit d=delete Enter=browse skills</text>
401
426
  </box>
402
427
  )
403
428
  }
@@ -1,136 +0,0 @@
1
- import { useEffect, useCallback } from "react"
2
- import { useStore, useDispatch } from "../store/context.js"
3
- import { useDb } from "../db/context.js"
4
-
5
- const API_BASE_URL = process.env.SKILLSGATE_API_URL ?? "https://skillsgate.ai"
6
-
7
- // SQLite settings keys for auth
8
- const AUTH_TOKEN_KEY = "auth.token"
9
- const AUTH_USER_KEY = "auth.user"
10
-
11
- interface AuthUser {
12
- id: string
13
- name: string
14
- email: string
15
- image?: string
16
- }
17
-
18
- interface ExchangeResponse {
19
- access_token: string
20
- user: AuthUser
21
- }
22
-
23
- /**
24
- * Auth hook using the shared SQLite database.
25
- * Both TUI and Electron read/write auth to the same DB at ~/.skillsgate/skillsgate.db
26
- * No keyring dependency -- works in both Bun and Node.
27
- */
28
- export function useAuth() {
29
- const state = useStore()
30
- const dispatch = useDispatch()
31
- const { settings } = useDb()
32
-
33
- // On mount, load auth from SQLite
34
- useEffect(() => {
35
- let cancelled = false
36
-
37
- try {
38
- const token = settings.get<string | null>(AUTH_TOKEN_KEY, null)
39
- const user = settings.get<AuthUser | null>(AUTH_USER_KEY, null)
40
-
41
- if (token && user) {
42
- dispatch({
43
- type: "SET_AUTH",
44
- auth: { token, user },
45
- })
46
- } else {
47
- // Try legacy file-based auth as fallback
48
- loadLegacyAuth().then((legacy) => {
49
- if (cancelled) return
50
- if (legacy) {
51
- settings.set(AUTH_TOKEN_KEY, legacy.token)
52
- settings.set(AUTH_USER_KEY, legacy.user)
53
- dispatch({
54
- type: "SET_AUTH",
55
- auth: { token: legacy.token, user: legacy.user },
56
- })
57
- } else {
58
- dispatch({ type: "SET_AUTH", auth: null })
59
- }
60
- }).catch(() => {
61
- if (!cancelled) dispatch({ type: "SET_AUTH", auth: null })
62
- })
63
- }
64
- } catch {
65
- dispatch({ type: "SET_AUTH", auth: null })
66
- }
67
-
68
- return () => { cancelled = true }
69
- }, [])
70
-
71
- const login = useCallback(async (code: string): Promise<string | null> => {
72
- try {
73
- const res = await fetch(`${API_BASE_URL}/api/auth/device/exchange`, {
74
- method: "POST",
75
- headers: { "Content-Type": "application/json" },
76
- body: JSON.stringify({ code }),
77
- })
78
-
79
- if (res.ok) {
80
- const result = (await res.json()) as ExchangeResponse
81
-
82
- // Save to SQLite (shared with Electron)
83
- settings.set(AUTH_TOKEN_KEY, result.access_token)
84
- settings.set(AUTH_USER_KEY, result.user)
85
-
86
- dispatch({
87
- type: "SET_AUTH",
88
- auth: { token: result.access_token, user: result.user },
89
- })
90
- return null
91
- }
92
-
93
- const data = (await res.json().catch(() => ({}))) as { error?: string }
94
-
95
- if (data?.error === "rate_limited") {
96
- return "Too many attempts. Please wait a minute and try again."
97
- } else if (data?.error === "invalid_code") {
98
- return "Invalid code. Please check and try again."
99
- } else if (data?.error === "expired") {
100
- return "Code has expired. Get a new one from the browser."
101
- }
102
- return "Something went wrong. Please try again."
103
- } catch {
104
- return "Network error. Please check your connection and try again."
105
- }
106
- }, [dispatch, settings])
107
-
108
- const logout = useCallback(async () => {
109
- settings.set(AUTH_TOKEN_KEY, null)
110
- settings.set(AUTH_USER_KEY, null)
111
- dispatch({ type: "SET_AUTH", auth: null })
112
- }, [dispatch, settings])
113
-
114
- return {
115
- auth: state.auth,
116
- login,
117
- logout,
118
- }
119
- }
120
-
121
- /**
122
- * Try to load auth from the legacy CLI file (~/.skillsgate/auth.json + keyring).
123
- * Used as a one-time migration to SQLite.
124
- */
125
- async function loadLegacyAuth(): Promise<{ token: string; user: AuthUser } | null> {
126
- try {
127
- const { loadAuth } = await import("../../../cli/src/utils/auth-store.js")
128
- const stored = await loadAuth()
129
- if (stored?.token && stored?.user) {
130
- return { token: stored.token, user: stored.user }
131
- }
132
- } catch {
133
- // CLI auth-store not available or keyring failed
134
- }
135
- return null
136
- }
@@ -1,161 +0,0 @@
1
- import { useState, useEffect, useCallback } from "react"
2
- import { useStore, useDispatch } from "../store/context.js"
3
- import { SKILLSGATE_API_BASE } from "./api-client.js"
4
-
5
- /**
6
- * Shape returned by the favorites API endpoint.
7
- */
8
- export interface FavoriteSkill {
9
- id: string
10
- name: string
11
- description: string
12
- summary?: string
13
- categories: string[]
14
- capabilities?: string[]
15
- keywords?: string[]
16
- githubUrl?: string
17
- githubStars?: number | null
18
- installCommand?: string | null
19
- slug?: string
20
- favoriteId?: string
21
- }
22
-
23
- const API_BASE = SKILLSGATE_API_BASE
24
-
25
- interface UseFavoritesResult {
26
- favorites: FavoriteSkill[]
27
- loading: boolean
28
- error: string | null
29
- toggle: (skillId: string) => Promise<void>
30
- refresh: () => void
31
- }
32
-
33
- /**
34
- * Hook that manages the user's favorited skills.
35
- * Requires authentication -- returns empty list if not logged in.
36
- */
37
- export function useFavorites(): UseFavoritesResult {
38
- const state = useStore()
39
- const dispatch = useDispatch()
40
- const [favorites, setFavorites] = useState<FavoriteSkill[]>([])
41
- const [loading, setLoading] = useState(false)
42
- const [error, setError] = useState<string | null>(null)
43
- const [refreshToken, setRefreshToken] = useState(0)
44
-
45
- const token = state.auth?.token
46
-
47
- // Fetch favorites when authenticated
48
- useEffect(() => {
49
- if (!token) {
50
- setFavorites([])
51
- setLoading(false)
52
- return
53
- }
54
-
55
- let cancelled = false
56
- setLoading(true)
57
- setError(null)
58
-
59
- async function fetchFavorites() {
60
- try {
61
- const res = await fetch(`${API_BASE}/api/favorites`, {
62
- headers: {
63
- "Content-Type": "application/json",
64
- Authorization: `Bearer ${token}`,
65
- },
66
- })
67
-
68
- if (!res.ok) {
69
- throw new Error(`Failed to fetch favorites (HTTP ${res.status})`)
70
- }
71
-
72
- const data = (await res.json()) as { favorites: FavoriteSkill[] }
73
- if (!cancelled) {
74
- const items = data.favorites ?? []
75
- setFavorites(items)
76
- dispatch({ type: "SET_FAVORITES", favorites: items })
77
- }
78
- } catch (err) {
79
- if (!cancelled) {
80
- const msg = err instanceof Error ? err.message : String(err)
81
- setError(msg)
82
- setFavorites([])
83
- dispatch({ type: "SET_FAVORITES", favorites: [] })
84
- }
85
- } finally {
86
- if (!cancelled) {
87
- setLoading(false)
88
- }
89
- }
90
- }
91
-
92
- fetchFavorites()
93
- return () => { cancelled = true }
94
- }, [token, refreshToken])
95
-
96
- /**
97
- * Toggle a skill's favorite status.
98
- * If already favorited, removes it. Otherwise, adds it.
99
- */
100
- const toggle = useCallback(async (skillId: string) => {
101
- if (!token) return
102
-
103
- const isFavorited = favorites.some((f) => f.id === skillId)
104
-
105
- try {
106
- if (isFavorited) {
107
- // Remove favorite
108
- const res = await fetch(`${API_BASE}/api/favorites/${skillId}`, {
109
- method: "DELETE",
110
- headers: {
111
- Authorization: `Bearer ${token}`,
112
- },
113
- })
114
- if (!res.ok) {
115
- throw new Error(`Failed to remove favorite (HTTP ${res.status})`)
116
- }
117
-
118
- // Optimistic update: remove from local list
119
- setFavorites((prev) => {
120
- const updated = prev.filter((f) => f.id !== skillId)
121
- dispatch({ type: "SET_FAVORITES", favorites: updated })
122
- return updated
123
- })
124
- } else {
125
- // Add favorite
126
- const res = await fetch(`${API_BASE}/api/favorites`, {
127
- method: "POST",
128
- headers: {
129
- "Content-Type": "application/json",
130
- Authorization: `Bearer ${token}`,
131
- },
132
- body: JSON.stringify({ skillId }),
133
- })
134
- if (!res.ok) {
135
- throw new Error(`Failed to add favorite (HTTP ${res.status})`)
136
- }
137
-
138
- // Refresh the full list to get the complete skill data
139
- setRefreshToken((t) => t + 1)
140
- }
141
- } catch (err) {
142
- const msg = err instanceof Error ? err.message : String(err)
143
- dispatch({
144
- type: "SHOW_NOTIFICATION",
145
- notification: { type: "error", message: msg },
146
- })
147
- }
148
- }, [token, favorites, dispatch])
149
-
150
- const refresh = useCallback(() => {
151
- setRefreshToken((t) => t + 1)
152
- }, [])
153
-
154
- return {
155
- favorites,
156
- loading,
157
- error,
158
- toggle,
159
- refresh,
160
- }
161
- }
@@ -1,19 +0,0 @@
1
- import { colors } from "../utils/colors.js"
2
-
3
- /**
4
- * Favorites view: Coming soon placeholder.
5
- * Favorites require authentication which is not yet available in the public TUI.
6
- */
7
- export function FavoritesView() {
8
- return (
9
- <box style={{ flexDirection: "column", padding: 2 }}>
10
- <text fg={colors.primary}>
11
- <strong>Favorites</strong>
12
- </text>
13
- <text>{" "}</text>
14
- <text fg={colors.text}>
15
- Coming soon. Favorites will be available once accounts are launched.
16
- </text>
17
- </box>
18
- )
19
- }