@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,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
|
+
}
|