@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,147 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react"
|
|
2
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
3
|
+
import type { CatalogSkill } from "./api-client.js"
|
|
4
|
+
|
|
5
|
+
const API_BASE = process.env.SKILLSGATE_SEARCH_API_URL ?? "https://api.skillsgate.ai"
|
|
6
|
+
|
|
7
|
+
interface FavoriteSkill extends CatalogSkill {
|
|
8
|
+
favoriteId?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UseFavoritesResult {
|
|
12
|
+
favorites: FavoriteSkill[]
|
|
13
|
+
loading: boolean
|
|
14
|
+
error: string | null
|
|
15
|
+
toggle: (skillId: string) => Promise<void>
|
|
16
|
+
refresh: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook that manages the user's favorited skills.
|
|
21
|
+
* Requires authentication -- returns empty list if not logged in.
|
|
22
|
+
*/
|
|
23
|
+
export function useFavorites(): UseFavoritesResult {
|
|
24
|
+
const state = useStore()
|
|
25
|
+
const dispatch = useDispatch()
|
|
26
|
+
const [favorites, setFavorites] = useState<FavoriteSkill[]>([])
|
|
27
|
+
const [loading, setLoading] = useState(false)
|
|
28
|
+
const [error, setError] = useState<string | null>(null)
|
|
29
|
+
const [refreshToken, setRefreshToken] = useState(0)
|
|
30
|
+
|
|
31
|
+
const token = state.auth?.token
|
|
32
|
+
|
|
33
|
+
// Fetch favorites when authenticated
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!token) {
|
|
36
|
+
setFavorites([])
|
|
37
|
+
setLoading(false)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cancelled = false
|
|
42
|
+
setLoading(true)
|
|
43
|
+
setError(null)
|
|
44
|
+
|
|
45
|
+
async function fetchFavorites() {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${API_BASE}/api/favorites`, {
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
Authorization: `Bearer ${token}`,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new Error(`Failed to fetch favorites (HTTP ${res.status})`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const data = (await res.json()) as { favorites: FavoriteSkill[] }
|
|
59
|
+
if (!cancelled) {
|
|
60
|
+
const items = data.favorites ?? []
|
|
61
|
+
setFavorites(items)
|
|
62
|
+
dispatch({ type: "SET_FAVORITES", favorites: items })
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!cancelled) {
|
|
66
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
67
|
+
setError(msg)
|
|
68
|
+
setFavorites([])
|
|
69
|
+
dispatch({ type: "SET_FAVORITES", favorites: [] })
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
if (!cancelled) {
|
|
73
|
+
setLoading(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fetchFavorites()
|
|
79
|
+
return () => { cancelled = true }
|
|
80
|
+
}, [token, refreshToken])
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Toggle a skill's favorite status.
|
|
84
|
+
* If already favorited, removes it. Otherwise, adds it.
|
|
85
|
+
*/
|
|
86
|
+
const toggle = useCallback(async (skillId: string) => {
|
|
87
|
+
if (!token) return
|
|
88
|
+
|
|
89
|
+
const isFavorited = favorites.some((f) => f.id === skillId)
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (isFavorited) {
|
|
93
|
+
// Remove favorite
|
|
94
|
+
const res = await fetch(`${API_BASE}/api/favorites/${skillId}`, {
|
|
95
|
+
method: "DELETE",
|
|
96
|
+
headers: {
|
|
97
|
+
Authorization: `Bearer ${token}`,
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
throw new Error(`Failed to remove favorite (HTTP ${res.status})`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Optimistic update: remove from local list
|
|
105
|
+
setFavorites((prev) => {
|
|
106
|
+
const updated = prev.filter((f) => f.id !== skillId)
|
|
107
|
+
dispatch({ type: "SET_FAVORITES", favorites: updated })
|
|
108
|
+
return updated
|
|
109
|
+
})
|
|
110
|
+
} else {
|
|
111
|
+
// Add favorite
|
|
112
|
+
const res = await fetch(`${API_BASE}/api/favorites`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
Authorization: `Bearer ${token}`,
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ skillId }),
|
|
119
|
+
})
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
throw new Error(`Failed to add favorite (HTTP ${res.status})`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Refresh the full list to get the complete skill data
|
|
125
|
+
setRefreshToken((t) => t + 1)
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
129
|
+
dispatch({
|
|
130
|
+
type: "SHOW_NOTIFICATION",
|
|
131
|
+
notification: { type: "error", message: msg },
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}, [token, favorites, dispatch])
|
|
135
|
+
|
|
136
|
+
const refresh = useCallback(() => {
|
|
137
|
+
setRefreshToken((t) => t + 1)
|
|
138
|
+
}, [])
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
favorites,
|
|
142
|
+
loading,
|
|
143
|
+
error,
|
|
144
|
+
toggle,
|
|
145
|
+
refresh,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect } from "react"
|
|
2
|
+
import fs from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import matter from "gray-matter"
|
|
5
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
6
|
+
import { agents } from "../../../cli/src/core/agents.js"
|
|
7
|
+
import { readSkillLock } from "../../../cli/src/core/skill-lock.js"
|
|
8
|
+
import { SKILL_MD } from "../../../cli/src/constants.js"
|
|
9
|
+
import type { EnrichedSkill } from "../store/types.js"
|
|
10
|
+
import type { AgentType, SkillLockFile } from "../../../cli/src/types.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scans all detected agent globalSkillsDir paths for SKILL.md files,
|
|
14
|
+
* parses them with gray-matter, enriches with lock file data, and
|
|
15
|
+
* populates the store.
|
|
16
|
+
*/
|
|
17
|
+
export function useInstalledSkills() {
|
|
18
|
+
const dispatch = useDispatch()
|
|
19
|
+
const { installedLoading } = useStore()
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
// Only scan when installedLoading is true (initial mount or refresh triggered)
|
|
23
|
+
if (!installedLoading) return
|
|
24
|
+
|
|
25
|
+
let cancelled = false
|
|
26
|
+
|
|
27
|
+
async function scan() {
|
|
28
|
+
dispatch({ type: "SET_INSTALLED_LOADING", loading: true })
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const lock = await readSkillLock()
|
|
32
|
+
// Map: skillName -> EnrichedSkill (deduplicating across agents)
|
|
33
|
+
const skillMap = new Map<string, EnrichedSkill>()
|
|
34
|
+
|
|
35
|
+
// Scan each agent's global skills directory
|
|
36
|
+
for (const agent of Object.values(agents)) {
|
|
37
|
+
const skillsDir = agent.globalSkillsDir
|
|
38
|
+
try {
|
|
39
|
+
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
// Include both real directories and symlinks (skills are often symlinked)
|
|
42
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
43
|
+
|
|
44
|
+
const skillMdPath = path.join(skillsDir, entry.name, SKILL_MD)
|
|
45
|
+
try {
|
|
46
|
+
const raw = await fs.readFile(skillMdPath, "utf-8")
|
|
47
|
+
const { data: frontmatter } = matter(raw)
|
|
48
|
+
const skillName = entry.name
|
|
49
|
+
|
|
50
|
+
const existing = skillMap.get(skillName)
|
|
51
|
+
if (existing) {
|
|
52
|
+
// Skill already seen from another agent - add this agent
|
|
53
|
+
if (!existing.agents.includes(agent.name)) {
|
|
54
|
+
existing.agents.push(agent.name)
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
skillMap.set(skillName, {
|
|
58
|
+
name: skillName,
|
|
59
|
+
description:
|
|
60
|
+
(frontmatter.description as string) ??
|
|
61
|
+
extractFirstLine(raw),
|
|
62
|
+
filePath: skillMdPath,
|
|
63
|
+
agents: [agent.name],
|
|
64
|
+
metadata: frontmatter as Record<string, unknown>,
|
|
65
|
+
lock: lock.skills[skillName],
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// SKILL.md not found or unreadable in this directory - skip
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Agent skills directory doesn't exist - skip
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (cancelled) return
|
|
78
|
+
|
|
79
|
+
const skills = Array.from(skillMap.values()).sort((a, b) =>
|
|
80
|
+
a.name.localeCompare(b.name)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
dispatch({ type: "SET_INSTALLED_SKILLS", skills })
|
|
84
|
+
|
|
85
|
+
// Update agent skill counts (without removing agents that have 0 skills)
|
|
86
|
+
const agentCounts = new Map<AgentType, number>()
|
|
87
|
+
for (const skill of skills) {
|
|
88
|
+
for (const agentName of skill.agents) {
|
|
89
|
+
agentCounts.set(agentName, (agentCounts.get(agentName) ?? 0) + 1)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
dispatch({ type: "UPDATE_AGENT_COUNTS", counts: Object.fromEntries(agentCounts) })
|
|
94
|
+
} catch {
|
|
95
|
+
if (!cancelled) {
|
|
96
|
+
dispatch({ type: "SET_INSTALLED_SKILLS", skills: [] })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
scan()
|
|
102
|
+
return () => { cancelled = true }
|
|
103
|
+
}, [installedLoading])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Extracts the first non-empty, non-heading line from markdown content. */
|
|
107
|
+
function extractFirstLine(content: string): string {
|
|
108
|
+
const lines = content.split("\n")
|
|
109
|
+
// Skip frontmatter delimiter and heading lines
|
|
110
|
+
let pastFrontmatter = false
|
|
111
|
+
let frontmatterCount = 0
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.trim() === "---") {
|
|
114
|
+
frontmatterCount++
|
|
115
|
+
if (frontmatterCount >= 2) {
|
|
116
|
+
pastFrontmatter = true
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (!pastFrontmatter) continue
|
|
122
|
+
const trimmed = line.trim()
|
|
123
|
+
if (!trimmed) continue
|
|
124
|
+
if (trimmed.startsWith("#")) continue
|
|
125
|
+
return trimmed.slice(0, 120)
|
|
126
|
+
}
|
|
127
|
+
return ""
|
|
128
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "react"
|
|
2
|
+
import { fetchCatalog, searchSkills, type CatalogSkill, type SearchMode } from "./api-client.js"
|
|
3
|
+
|
|
4
|
+
const DEBOUNCE_MS = 300
|
|
5
|
+
const PAGE_SIZE = 20
|
|
6
|
+
|
|
7
|
+
interface UseSearchResult {
|
|
8
|
+
results: CatalogSkill[]
|
|
9
|
+
loading: boolean
|
|
10
|
+
error: string | null
|
|
11
|
+
total: number
|
|
12
|
+
hasMore: boolean
|
|
13
|
+
loadMore: () => void
|
|
14
|
+
remainingSearches: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hook that manages search state with debounce and pagination.
|
|
19
|
+
* - When query is empty, loads the catalog with pagination
|
|
20
|
+
* - When query is provided, searches after 300ms debounce
|
|
21
|
+
* - Supports keyword and semantic search modes
|
|
22
|
+
*/
|
|
23
|
+
export function useSearch(
|
|
24
|
+
query: string,
|
|
25
|
+
mode: SearchMode,
|
|
26
|
+
token?: string | null
|
|
27
|
+
): UseSearchResult {
|
|
28
|
+
const [results, setResults] = useState<CatalogSkill[]>([])
|
|
29
|
+
const [loading, setLoading] = useState(false)
|
|
30
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
|
+
const [total, setTotal] = useState(0)
|
|
32
|
+
const [offset, setOffset] = useState(0)
|
|
33
|
+
const [remainingSearches, setRemainingSearches] = useState<number | null>(null)
|
|
34
|
+
|
|
35
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
|
+
|
|
37
|
+
// Reset pagination when query or mode changes
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setResults([])
|
|
40
|
+
setOffset(0)
|
|
41
|
+
setError(null)
|
|
42
|
+
}, [query, mode])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (timerRef.current) {
|
|
46
|
+
clearTimeout(timerRef.current)
|
|
47
|
+
timerRef.current = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const doFetch = async () => {
|
|
51
|
+
setLoading(true)
|
|
52
|
+
setError(null)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
if (query.trim()) {
|
|
56
|
+
const data = await searchSkills(query, mode, token)
|
|
57
|
+
setResults(data.skills)
|
|
58
|
+
setTotal(data.total)
|
|
59
|
+
if (data.remainingSearches !== undefined) {
|
|
60
|
+
setRemainingSearches(data.remainingSearches)
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
const data = await fetchCatalog(PAGE_SIZE, 0)
|
|
64
|
+
setResults(data.skills)
|
|
65
|
+
setTotal(data.total)
|
|
66
|
+
setOffset(PAGE_SIZE)
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
70
|
+
if (!msg.includes("abort")) {
|
|
71
|
+
setError(msg)
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
setLoading(false)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (query.trim()) {
|
|
79
|
+
timerRef.current = setTimeout(doFetch, DEBOUNCE_MS)
|
|
80
|
+
} else {
|
|
81
|
+
doFetch()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
if (timerRef.current) {
|
|
86
|
+
clearTimeout(timerRef.current)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}, [query, mode, token])
|
|
90
|
+
|
|
91
|
+
const loadMore = useCallback(async () => {
|
|
92
|
+
if (query.trim() || loading) return
|
|
93
|
+
if (results.length >= total) return
|
|
94
|
+
|
|
95
|
+
setLoading(true)
|
|
96
|
+
try {
|
|
97
|
+
const data = await fetchCatalog(PAGE_SIZE, offset)
|
|
98
|
+
setResults((prev) => [...prev, ...data.skills])
|
|
99
|
+
setTotal(data.total)
|
|
100
|
+
setOffset((prev) => prev + PAGE_SIZE)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
103
|
+
setError(msg)
|
|
104
|
+
} finally {
|
|
105
|
+
setLoading(false)
|
|
106
|
+
}
|
|
107
|
+
}, [query, loading, results.length, total, offset])
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
results,
|
|
111
|
+
loading,
|
|
112
|
+
error,
|
|
113
|
+
total,
|
|
114
|
+
hasMore: results.length < total,
|
|
115
|
+
loadMore,
|
|
116
|
+
remainingSearches,
|
|
117
|
+
}
|
|
118
|
+
}
|