@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,48 @@
1
+ import { useStore, useDispatch } from "../store/context.js"
2
+ import { colors } from "../utils/colors.js"
3
+
4
+ interface SearchInputProps {
5
+ /** Override focus (if not provided, uses store's focusedPane) */
6
+ focused?: boolean
7
+ /** Action type to dispatch on input change */
8
+ filterAction?: "SET_INSTALLED_FILTER" | "SET_SEARCH_QUERY"
9
+ /** Placeholder text */
10
+ placeholder?: string
11
+ }
12
+
13
+ export function SearchInput({
14
+ focused,
15
+ filterAction = "SET_INSTALLED_FILTER",
16
+ placeholder = "Type to filter...",
17
+ }: SearchInputProps) {
18
+ const state = useStore()
19
+ const dispatch = useDispatch()
20
+
21
+ const isFocused = focused ?? (state.focusedPane === "search")
22
+
23
+ return (
24
+ <box
25
+ style={{
26
+ height: 3,
27
+ width: "100%",
28
+ border: true,
29
+ borderColor: isFocused ? colors.primary : colors.border,
30
+ paddingLeft: 1,
31
+ paddingRight: 1,
32
+ }}
33
+ title={isFocused ? "Filter skills" : "/ to search"}
34
+ >
35
+ {isFocused ? (
36
+ <input
37
+ placeholder={placeholder}
38
+ focused={!state.showHelp}
39
+ onInput={(value: string) => {
40
+ dispatch({ type: filterAction, [filterAction === "SET_INSTALLED_FILTER" ? "filter" : "query"]: value } as any)
41
+ }}
42
+ />
43
+ ) : (
44
+ <text fg={colors.textDim}>/ to search, Tab to cycle panes</text>
45
+ )}
46
+ </box>
47
+ )
48
+ }
@@ -0,0 +1,45 @@
1
+ import type { EnrichedSkill } from "../store/types.js"
2
+ import { colors, agentBadges as badgeMap } from "../utils/colors.js"
3
+
4
+ interface SkillListItemProps {
5
+ skill: EnrichedSkill
6
+ selected?: boolean
7
+ }
8
+
9
+ /**
10
+ * Compact skill list item for the middle panel.
11
+ * Shows just the name and small agent dot indicators.
12
+ */
13
+ export function SkillListItem({ skill, selected }: SkillListItemProps) {
14
+ // Build compact agent dots (single-char badges)
15
+ const agentDots = skill.agents.slice(0, 3).map((a) => {
16
+ const badge = badgeMap[a]
17
+ return { char: badge?.label?.[0] ?? "?", color: badge?.color ?? colors.agent }
18
+ })
19
+
20
+ return (
21
+ <box
22
+ style={{
23
+ width: "100%",
24
+ flexDirection: "row",
25
+ paddingLeft: 1,
26
+ paddingRight: 1,
27
+ backgroundColor: selected ? colors.bgAlt : "transparent",
28
+ }}
29
+ >
30
+ {/* Skill name */}
31
+ <text fg={selected ? colors.primary : colors.text} style={{ flexGrow: 1 }}>
32
+ {skill.name}
33
+ </text>
34
+
35
+ {/* Small agent dots on the right */}
36
+ <box style={{ flexDirection: "row" }}>
37
+ {agentDots.map((dot, i) => (
38
+ <text key={i} fg={dot.color}>
39
+ {dot.char}
40
+ </text>
41
+ ))}
42
+ </box>
43
+ </box>
44
+ )
45
+ }
@@ -0,0 +1,245 @@
1
+ import { useState, useEffect } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useSkillActions } from "../data/use-skill-actions.js"
5
+ import { SkillListItem } from "./skill-list-item.js"
6
+ import { ConfirmDialog } from "./confirm-dialog.js"
7
+ import { colors, agentBadges as badgeMap } from "../utils/colors.js"
8
+ import { agents } from "../../../cli/src/core/agents.js"
9
+ import type { EnrichedSkill } from "../store/types.js"
10
+
11
+ interface SkillListProps {
12
+ skills: EnrichedSkill[]
13
+ }
14
+
15
+ type PendingAction = {
16
+ type: "remove" | "update"
17
+ skill: EnrichedSkill
18
+ } | null
19
+
20
+ type RemoveMode = null | "select-agent"
21
+
22
+ /**
23
+ * Returns the display name for an agent key.
24
+ */
25
+ function agentDisplayName(agentName: string): string {
26
+ return agents[agentName]?.displayName ?? agentName
27
+ }
28
+
29
+ export function SkillList({ skills }: SkillListProps) {
30
+ const dispatch = useDispatch()
31
+ const state = useStore()
32
+ const { removeSkill, removeSkillFromOneAgent, updateSkill } = useSkillActions()
33
+ const [selectedIndex, setSelectedIndex] = useState(0)
34
+ const [pendingAction, setPendingAction] = useState<PendingAction>(null)
35
+ const [removeMode, setRemoveMode] = useState<RemoveMode>(null)
36
+ const [removeTarget, setRemoveTarget] = useState<EnrichedSkill | null>(null)
37
+
38
+ // Preview the selected skill in the right panel whenever selection changes
39
+ useEffect(() => {
40
+ if (skills[selectedIndex]) {
41
+ dispatch({ type: "PREVIEW_SKILL", skill: skills[selectedIndex] })
42
+ }
43
+ }, [selectedIndex, skills])
44
+
45
+ // Only handle navigation when on a list-bearing view and list is focused
46
+ useKeyboard((key) => {
47
+ if (state.showHelp) return
48
+ if (state.activeView !== "home") return
49
+ if (state.focusedPane !== "list") return
50
+
51
+ // Handle agent selection menu for per-agent delete
52
+ if (removeMode === "select-agent" && removeTarget) {
53
+ if (key.name === "n" || key.name === "escape") {
54
+ setRemoveMode(null)
55
+ setRemoveTarget(null)
56
+ return
57
+ }
58
+ if (key.name === "a") {
59
+ // Remove from all agents
60
+ setRemoveMode(null)
61
+ setRemoveTarget(null)
62
+ setPendingAction({ type: "remove", skill: removeTarget })
63
+ return
64
+ }
65
+ // Number keys 1-9 to select a specific agent
66
+ const num = parseInt(key.raw ?? "", 10)
67
+ if (num >= 1 && num <= removeTarget.agents.length) {
68
+ const agentName = removeTarget.agents[num - 1]
69
+ setRemoveMode(null)
70
+ setRemoveTarget(null)
71
+ removeSkillFromOneAgent(removeTarget, agentName)
72
+ return
73
+ }
74
+ return
75
+ }
76
+
77
+ if (pendingAction) return // Block navigation during confirm dialog
78
+
79
+ // j/k or arrow keys for navigation
80
+ if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
81
+ setSelectedIndex((i) => Math.max(0, i - 1))
82
+ }
83
+ if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
84
+ setSelectedIndex((i) => Math.min(skills.length - 1, i + 1))
85
+ }
86
+
87
+ // g = jump to first, G (shift+g) = jump to last
88
+ if (key.name === "g" && !key.shift) {
89
+ setSelectedIndex(0)
90
+ }
91
+ if (key.name === "g" && key.shift) {
92
+ setSelectedIndex(Math.max(0, skills.length - 1))
93
+ }
94
+
95
+ // v to open full skill detail view (navigates away)
96
+ if (key.name === "v" && skills[selectedIndex]) {
97
+ dispatch({ type: "SELECT_SKILL", skill: skills[selectedIndex] })
98
+ }
99
+
100
+ // d to remove selected skill (with per-agent support)
101
+ if (key.name === "d" && skills[selectedIndex]) {
102
+ const skill = skills[selectedIndex]
103
+ if (skill.agents.length > 1) {
104
+ // Multiple agents: show selection menu
105
+ setRemoveTarget(skill)
106
+ setRemoveMode("select-agent")
107
+ } else {
108
+ // Single agent or catalog: simple confirm
109
+ setPendingAction({ type: "remove", skill })
110
+ }
111
+ }
112
+
113
+ // u to update selected skill
114
+ if (key.name === "u" && skills[selectedIndex]) {
115
+ setPendingAction({ type: "update", skill: skills[selectedIndex] })
116
+ }
117
+ })
118
+
119
+ // Handle confirm/cancel for pending actions
120
+ const handleConfirm = async () => {
121
+ if (!pendingAction) return
122
+ const { type, skill } = pendingAction
123
+ setPendingAction(null)
124
+
125
+ if (type === "remove") {
126
+ await removeSkill(skill)
127
+ } else if (type === "update") {
128
+ await updateSkill(skill)
129
+ }
130
+ }
131
+
132
+ const handleCancel = () => {
133
+ setPendingAction(null)
134
+ }
135
+
136
+ // Agent selection menu for per-agent delete
137
+ if (removeMode === "select-agent" && removeTarget) {
138
+ return (
139
+ <box
140
+ style={{
141
+ width: "100%",
142
+ height: "100%",
143
+ justifyContent: "center",
144
+ alignItems: "center",
145
+ backgroundColor: colors.bg,
146
+ }}
147
+ >
148
+ <box
149
+ style={{
150
+ width: 60,
151
+ border: true,
152
+ borderColor: colors.primary,
153
+ backgroundColor: "#1a1a2e",
154
+ paddingLeft: 2,
155
+ paddingRight: 2,
156
+ paddingTop: 1,
157
+ paddingBottom: 1,
158
+ flexDirection: "column",
159
+ }}
160
+ title="Remove"
161
+ >
162
+ <text fg={colors.text}>
163
+ Remove "<span fg={colors.primary}>{removeTarget.name}</span>" from:
164
+ </text>
165
+ <text>{" "}</text>
166
+ {removeTarget.agents.map((agentName, i) => {
167
+ const badge = badgeMap[agentName]
168
+ return (
169
+ <text key={agentName} fg={colors.text}>
170
+ {" "}<span fg={colors.primary}>{i + 1}</span>{" "}<span fg={badge?.color ?? colors.agent}>{agentDisplayName(agentName)}</span>
171
+ </text>
172
+ )
173
+ })}
174
+ <text>{" "}</text>
175
+ <text fg={colors.text}>
176
+ {" "}<span fg={colors.error}>a</span>{" "}All agents (removes completely)
177
+ </text>
178
+ <text fg={colors.text}>
179
+ {" "}<span fg={colors.textDim}>n</span>{" "}Cancel
180
+ </text>
181
+ </box>
182
+ </box>
183
+ )
184
+ }
185
+
186
+ // Show confirm dialog if there's a pending action
187
+ if (pendingAction) {
188
+ const actionLabel = pendingAction.type === "remove" ? "Remove" : "Update"
189
+ return (
190
+ <ConfirmDialog
191
+ message={`${actionLabel} "${pendingAction.skill.name}"?`}
192
+ onConfirm={handleConfirm}
193
+ onCancel={handleCancel}
194
+ />
195
+ )
196
+ }
197
+
198
+ if (skills.length === 0) {
199
+ return (
200
+ <box style={{ padding: 1 }}>
201
+ <text fg={colors.textDim}>
202
+ {state.installedLoading
203
+ ? "Scanning for installed skills..."
204
+ : "No skills found. Install skills with: skillsgate install <source>"}
205
+ </text>
206
+ </box>
207
+ )
208
+ }
209
+
210
+ const isFocused = state.activeView === "home" && state.focusedPane === "list" && !state.showHelp
211
+
212
+ return (
213
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
214
+ {/* List header */}
215
+ <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
216
+ <text fg={colors.textDim}>SKILLS ({skills.length})</text>
217
+ </box>
218
+
219
+ <scrollbox
220
+ focused={isFocused}
221
+ style={{
222
+ width: "100%",
223
+ flexGrow: 1,
224
+ rootOptions: { backgroundColor: colors.bg },
225
+ viewportOptions: { backgroundColor: colors.bg },
226
+ contentOptions: { backgroundColor: colors.bg },
227
+ scrollbarOptions: {
228
+ trackOptions: {
229
+ foregroundColor: colors.primary,
230
+ backgroundColor: colors.border,
231
+ },
232
+ },
233
+ }}
234
+ >
235
+ {skills.map((skill, i) => (
236
+ <SkillListItem
237
+ key={skill.name}
238
+ skill={skill}
239
+ selected={i === selectedIndex}
240
+ />
241
+ ))}
242
+ </scrollbox>
243
+ </box>
244
+ )
245
+ }
@@ -0,0 +1,34 @@
1
+ import { useStore } from "../store/context.js"
2
+ import { colors } from "../utils/colors.js"
3
+
4
+ export function StatusBar() {
5
+ const state = useStore()
6
+
7
+ const skillCount = state.installedSkills.length
8
+ const agentCount = state.detectedAgents.length
9
+ const user = state.auth?.user?.name ?? "not logged in (l=login)"
10
+ const focusHint = state.activeView === "detail"
11
+ ? "q=back"
12
+ : state.activeView === "login"
13
+ ? "Esc=back"
14
+ : state.focusedPane === "search"
15
+ ? "Tab=results Esc=exit search"
16
+ : "/=search Tab=switch pane"
17
+
18
+ const statusText = `Skills: ${skillCount} | Agents: ${agentCount} | ${user} | ${focusHint} | ?=help 1/2/3/4=tabs`
19
+
20
+ return (
21
+ <box
22
+ style={{
23
+ height: 1,
24
+ width: "100%",
25
+ backgroundColor: colors.statusBar,
26
+ flexDirection: "row",
27
+ paddingLeft: 1,
28
+ paddingRight: 1,
29
+ }}
30
+ >
31
+ <text fg={colors.textDim}>{statusText}</text>
32
+ </box>
33
+ )
34
+ }
@@ -0,0 +1,151 @@
1
+ const API_BASE = process.env.SKILLSGATE_SEARCH_API_URL ?? "https://api.skillsgate.ai"
2
+
3
+ // ---------- Types ----------
4
+
5
+ export interface CatalogSkill {
6
+ id: string
7
+ slug: string
8
+ name: string
9
+ description: string
10
+ summary?: string
11
+ categories: string[]
12
+ capabilities?: string[]
13
+ keywords?: string[]
14
+ githubUrl?: string
15
+ githubStars?: number | null
16
+ installCommand?: string | null
17
+ score?: number
18
+ username?: string
19
+ urlPath?: string
20
+ }
21
+
22
+ interface CatalogResponse {
23
+ skills: CatalogSkill[]
24
+ meta: {
25
+ total: number
26
+ limit: number
27
+ offset: number
28
+ hasMore?: boolean
29
+ }
30
+ }
31
+
32
+ interface KeywordSearchResponse {
33
+ skills: CatalogSkill[]
34
+ meta: {
35
+ total: number
36
+ limit: number
37
+ offset: number
38
+ hasMore?: boolean
39
+ }
40
+ }
41
+
42
+ interface SemanticSearchResponse {
43
+ results: CatalogSkill[]
44
+ meta: {
45
+ query: string
46
+ total: number
47
+ limit: number
48
+ remainingSearches: number
49
+ }
50
+ }
51
+
52
+ export type SearchMode = "keyword" | "semantic"
53
+
54
+ export interface SearchResult {
55
+ skills: CatalogSkill[]
56
+ total: number
57
+ remainingSearches?: number
58
+ }
59
+
60
+ // ---------- Catalog ----------
61
+
62
+ export async function fetchCatalog(
63
+ limit: number = 20,
64
+ offset: number = 0
65
+ ): Promise<{ skills: CatalogSkill[]; total: number }> {
66
+ const url = `${API_BASE}/api/v1/skills?limit=${limit}&offset=${offset}`
67
+
68
+ const response = await fetch(url, {
69
+ headers: { "Content-Type": "application/json" },
70
+ })
71
+
72
+ if (!response.ok) {
73
+ throw new Error(`Catalog fetch failed (HTTP ${response.status})`)
74
+ }
75
+
76
+ const data = (await response.json()) as CatalogResponse
77
+ return { skills: data.skills ?? [], total: data.meta?.total ?? 0 }
78
+ }
79
+
80
+ // ---------- Keyword Search (public, no auth) ----------
81
+
82
+ export async function keywordSearch(
83
+ query: string,
84
+ limit: number = 20,
85
+ offset: number = 0
86
+ ): Promise<SearchResult> {
87
+ const url = `${API_BASE}/api/v1/skills/search?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`
88
+
89
+ const response = await fetch(url, {
90
+ headers: { "Content-Type": "application/json" },
91
+ })
92
+
93
+ if (!response.ok) {
94
+ throw new Error(`Keyword search failed (HTTP ${response.status})`)
95
+ }
96
+
97
+ const data = (await response.json()) as KeywordSearchResponse
98
+ return {
99
+ skills: data.skills ?? [],
100
+ total: data.meta?.total ?? 0,
101
+ }
102
+ }
103
+
104
+ // ---------- Semantic Search (authenticated, rate limited) ----------
105
+
106
+ export async function semanticSearch(
107
+ query: string,
108
+ token: string,
109
+ limit: number = 5
110
+ ): Promise<SearchResult> {
111
+ const url = `${API_BASE}/api/v1/search`
112
+
113
+ const response = await fetch(url, {
114
+ method: "POST",
115
+ headers: {
116
+ "Content-Type": "application/json",
117
+ Authorization: `Bearer ${token}`,
118
+ },
119
+ body: JSON.stringify({ query, limit }),
120
+ })
121
+
122
+ if (!response.ok) {
123
+ if (response.status === 429) {
124
+ throw new Error("RATE_LIMIT")
125
+ }
126
+ if (response.status === 401) {
127
+ throw new Error("AUTH_EXPIRED")
128
+ }
129
+ throw new Error(`Semantic search failed (HTTP ${response.status})`)
130
+ }
131
+
132
+ const data = (await response.json()) as SemanticSearchResponse
133
+ return {
134
+ skills: data.results ?? [],
135
+ total: data.meta?.total ?? 0,
136
+ remainingSearches: data.meta?.remainingSearches,
137
+ }
138
+ }
139
+
140
+ // ---------- Unified search ----------
141
+
142
+ export async function searchSkills(
143
+ query: string,
144
+ mode: SearchMode,
145
+ token?: string | null
146
+ ): Promise<SearchResult> {
147
+ if (mode === "semantic" && token) {
148
+ return semanticSearch(query, token)
149
+ }
150
+ return keywordSearch(query)
151
+ }
@@ -0,0 +1,41 @@
1
+ import { useEffect } from "react"
2
+ import { useDispatch } from "../store/context.js"
3
+ import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
4
+ import type { DetectedAgent } from "../store/types.js"
5
+ import type { AgentConfig } from "../../../cli/src/types.js"
6
+
7
+ /**
8
+ * Detects which AI agents are installed on the system and populates the store.
9
+ * Runs once on mount.
10
+ */
11
+ export function useDetectedAgents() {
12
+ const dispatch = useDispatch()
13
+
14
+ useEffect(() => {
15
+ let cancelled = false
16
+
17
+ async function detect() {
18
+ try {
19
+ const installed: AgentConfig[] = await detectInstalledAgents()
20
+
21
+ if (cancelled) return
22
+
23
+ const detected: DetectedAgent[] = installed.map((agent) => ({
24
+ name: agent.name,
25
+ displayName: agent.displayName,
26
+ skillCount: 0, // will be updated after skill scan
27
+ }))
28
+
29
+ dispatch({ type: "SET_DETECTED_AGENTS", agents: detected })
30
+ } catch (err) {
31
+ // Silently handle detection errors - agents will show as empty
32
+ if (!cancelled) {
33
+ dispatch({ type: "SET_DETECTED_AGENTS", agents: [] })
34
+ }
35
+ }
36
+ }
37
+
38
+ detect()
39
+ return () => { cancelled = true }
40
+ }, [])
41
+ }
@@ -0,0 +1,136 @@
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
+ }