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