@skillsgate/tui 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillsgate/tui",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillsgate-tui": "bin/skillsgate-tui"
@@ -1,4 +1,3 @@
1
- const SKILLSGATE_API_BASE = process.env.SKILLSGATE_SEARCH_API_URL ?? "https://api.skillsgate.ai"
2
1
  const SKILLS_SH_BASE = "https://skills.sh"
3
2
  const GITHUB_API_BASE = "https://api.github.com"
4
3
  const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
@@ -115,6 +114,3 @@ export async function fetchSkillContent(
115
114
  return null
116
115
  }
117
116
 
118
- // ---------- SkillsGate API (auth, favorites, private skills) ----------
119
-
120
- export { SKILLSGATE_API_BASE }
@@ -1,4 +1,4 @@
1
- import { useEffect } from "react"
1
+ import { useEffect, useRef } from "react"
2
2
  import fs from "node:fs/promises"
3
3
  import path from "node:path"
4
4
  import os from "node:os"
@@ -8,6 +8,7 @@ import { useDb } from "../db/context.js"
8
8
  import { agents } from "../../../cli/src/core/agents.js"
9
9
  import { readSkillLock } from "../../../cli/src/core/skill-lock.js"
10
10
  import { SKILL_MD } from "../../../cli/src/constants.js"
11
+ import { loadCachedSkills, saveCachedSkills, type CachedSkill } from "../db/skills-cache.js"
11
12
  import type { EnrichedSkill } from "../store/types.js"
12
13
  import type { AgentType, SkillLockFile } from "../../../cli/src/types.js"
13
14
 
@@ -92,111 +93,121 @@ async function listSupportingFiles(skillDir: string): Promise<Array<{ relativePa
92
93
  return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
93
94
  }
94
95
 
96
+ /**
97
+ * Convert cached skill rows into lightweight EnrichedSkill objects
98
+ * suitable for rendering the list immediately on startup.
99
+ */
100
+ function cachedToEnriched(cached: CachedSkill[], lock: SkillLockFile): EnrichedSkill[] {
101
+ return cached.map((c) => ({
102
+ name: c.name,
103
+ description: c.description,
104
+ filePath: path.join(c.canonicalPath, SKILL_MD),
105
+ canonicalPath: c.canonicalPath,
106
+ agents: c.agents,
107
+ scope: c.scope,
108
+ projectName: c.scope === "project" ? getProjectNameForPath(c.canonicalPath) : null,
109
+ hasSupportingFiles: false,
110
+ supportingFiles: [],
111
+ metadata: {} as Record<string, unknown>,
112
+ lock: lock.skills[c.folderName],
113
+ }))
114
+ }
115
+
116
+ /**
117
+ * Convert EnrichedSkill objects from a full scan into CachedSkill rows
118
+ * for persistence in the SQLite database.
119
+ */
120
+ function enrichedToCached(skills: EnrichedSkill[]): CachedSkill[] {
121
+ const now = new Date().toISOString()
122
+ return skills.map((s) => ({
123
+ canonicalPath: s.canonicalPath,
124
+ folderName: path.basename(s.canonicalPath),
125
+ name: s.name,
126
+ description: s.description,
127
+ agents: s.agents,
128
+ agentShortCodes: [],
129
+ scope: s.scope,
130
+ source: s.lock?.source ?? null,
131
+ sourceType: s.lock?.sourceType ?? null,
132
+ fileModTime: now,
133
+ scannedAt: now,
134
+ }))
135
+ }
136
+
137
+ /**
138
+ * Dispatch agent counts derived from the given skills array.
139
+ */
140
+ function dispatchAgentCounts(
141
+ skills: EnrichedSkill[],
142
+ dispatch: ReturnType<typeof useDispatch>,
143
+ ): void {
144
+ const agentCounts = new Map<AgentType, number>()
145
+ for (const skill of skills) {
146
+ for (const agentName of skill.agents) {
147
+ agentCounts.set(agentName, (agentCounts.get(agentName) ?? 0) + 1)
148
+ }
149
+ }
150
+ dispatch({ type: "UPDATE_AGENT_COUNTS", counts: Object.fromEntries(agentCounts) })
151
+ }
152
+
95
153
  /**
96
154
  * Scans all detected agent globalSkillsDir paths for SKILL.md files,
97
155
  * parses them with gray-matter, enriches with lock file data, and
98
156
  * populates the store.
157
+ *
158
+ * On first mount, cached results are served instantly while a background
159
+ * rescan runs. Subsequent tab switches reuse the cache. Press `r` to
160
+ * force a full rescan.
99
161
  */
100
162
  export function useInstalledSkills() {
101
163
  const dispatch = useDispatch()
102
164
  const { installedLoading } = useStore()
103
- const { settings } = useDb()
165
+ const { db, settings } = useDb()
166
+ const hasLoadedCache = useRef(false)
104
167
 
105
168
  useEffect(() => {
106
- // Only scan when installedLoading is true (initial mount or refresh triggered)
169
+ // Only act when installedLoading is true (initial mount or refresh triggered)
107
170
  if (!installedLoading) return
108
171
 
109
172
  let cancelled = false
110
173
 
111
- async function scan() {
112
- dispatch({ type: "SET_INSTALLED_LOADING", loading: true })
113
-
114
- try {
115
- const lock = await readSkillLock()
116
- // Map: skillName -> EnrichedSkill (deduplicating across agents)
117
- const skillMap = new Map<string, EnrichedSkill>()
118
-
119
- // Scan each agent's global skills directory
120
- for (const agent of Object.values(agents)) {
121
- const skillsDir = agent.globalSkillsDir
122
- try {
123
- const entries = await fs.readdir(skillsDir, { withFileTypes: true })
124
- for (const entry of entries) {
125
- // Include both real directories and symlinks (skills are often symlinked)
126
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
127
-
128
- const skillDirPath = path.join(skillsDir, entry.name)
129
- const canonicalPath = await fs.realpath(skillDirPath).catch(() => skillDirPath)
130
- const skillMdPath = path.join(skillDirPath, SKILL_MD)
131
- try {
132
- const raw = await fs.readFile(skillMdPath, "utf-8")
133
- const { data: frontmatter } = matter(raw)
134
- const skillName = entry.name
135
- const canonicalPath = await fs.realpath(skillDirPath).catch(() => skillDirPath)
136
- const scope = getScopeForPath(canonicalPath)
137
- const supportingFiles = await listSupportingFiles(canonicalPath)
138
-
139
- const existing = skillMap.get(canonicalPath)
140
- if (existing) {
141
- // Skill already seen from another agent - add this agent
142
- if (!existing.agents.includes(agent.name)) {
143
- existing.agents.push(agent.name)
144
- }
145
- } else {
146
- skillMap.set(canonicalPath, {
147
- name: skillName,
148
- description:
149
- (frontmatter.description as string) ??
150
- extractFirstLine(raw),
151
- filePath: skillMdPath,
152
- canonicalPath,
153
- agents: [agent.name],
154
- scope,
155
- projectName:
156
- scope === "project" ? getProjectNameForPath(canonicalPath) : null,
157
- hasSupportingFiles: supportingFiles.length > 0,
158
- supportingFiles,
159
- metadata: frontmatter as Record<string, unknown>,
160
- lock: lock.skills[skillName],
161
- })
162
- }
163
- } catch {
164
- // SKILL.md not found or unreadable in this directory - skip
165
- }
166
- }
167
- } catch {
168
- // Agent skills directory doesn't exist - skip
169
- }
170
- }
171
-
172
- const customScanPaths = settings.get<string[]>("scan.customPaths", [])
173
- for (const customPath of customScanPaths) {
174
- const resolvedRoot = path.resolve(customPath.replace(/^~(?=$|\/|\\)/, home))
175
- const collected = await collectCustomSkills(resolvedRoot, lock)
176
- for (const skill of collected) {
177
- if (!skillMap.has(skill.canonicalPath)) {
178
- skillMap.set(skill.canonicalPath, skill)
174
+ async function loadFromCacheAndScan() {
175
+ const isInitialLoad = !hasLoadedCache.current
176
+
177
+ // On initial load, try serving from cache first for instant startup
178
+ if (isInitialLoad) {
179
+ try {
180
+ const cached = loadCachedSkills(db)
181
+ if (cached.length > 0) {
182
+ const lock = await readSkillLock()
183
+ const skills = cachedToEnriched(cached, lock)
184
+ if (!cancelled) {
185
+ dispatch({ type: "SET_INSTALLED_SKILLS", skills })
186
+ dispatchAgentCounts(skills, dispatch)
187
+ hasLoadedCache.current = true
179
188
  }
180
189
  }
190
+ } catch {
191
+ // Cache read failed; fall through to full scan
181
192
  }
193
+ }
182
194
 
195
+ // Run full filesystem scan (in background after cache, or blocking on refresh)
196
+ try {
197
+ const scannedSkills = await fullScan(settings)
183
198
  if (cancelled) return
184
199
 
185
- const skills = Array.from(skillMap.values()).sort((a, b) =>
186
- a.name.localeCompare(b.name)
187
- )
188
-
189
- dispatch({ type: "SET_INSTALLED_SKILLS", skills })
190
-
191
- // Update agent skill counts (without removing agents that have 0 skills)
192
- const agentCounts = new Map<AgentType, number>()
193
- for (const skill of skills) {
194
- for (const agentName of skill.agents) {
195
- agentCounts.set(agentName, (agentCounts.get(agentName) ?? 0) + 1)
196
- }
200
+ // Persist to cache
201
+ try {
202
+ saveCachedSkills(db, enrichedToCached(scannedSkills))
203
+ } catch {
204
+ // Cache write failed; non-critical
197
205
  }
198
206
 
199
- dispatch({ type: "UPDATE_AGENT_COUNTS", counts: Object.fromEntries(agentCounts) })
207
+ hasLoadedCache.current = true
208
+
209
+ dispatch({ type: "SET_INSTALLED_SKILLS", skills: scannedSkills })
210
+ dispatchAgentCounts(scannedSkills, dispatch)
200
211
  } catch {
201
212
  if (!cancelled) {
202
213
  dispatch({ type: "SET_INSTALLED_SKILLS", skills: [] })
@@ -204,11 +215,88 @@ export function useInstalledSkills() {
204
215
  }
205
216
  }
206
217
 
207
- scan()
218
+ loadFromCacheAndScan()
208
219
  return () => { cancelled = true }
209
220
  }, [installedLoading])
210
221
  }
211
222
 
223
+ /**
224
+ * Performs a full filesystem scan across all agent directories and custom paths.
225
+ */
226
+ async function fullScan(
227
+ settings: ReturnType<typeof useDb>["settings"],
228
+ ): Promise<EnrichedSkill[]> {
229
+ const lock = await readSkillLock()
230
+ const skillMap = new Map<string, EnrichedSkill>()
231
+
232
+ // Scan each agent's global skills directory
233
+ for (const agent of Object.values(agents)) {
234
+ const skillsDir = agent.globalSkillsDir
235
+ try {
236
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true })
237
+ for (const entry of entries) {
238
+ // Include both real directories and symlinks (skills are often symlinked)
239
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
240
+
241
+ const skillDirPath = path.join(skillsDir, entry.name)
242
+ const skillMdPath = path.join(skillDirPath, SKILL_MD)
243
+ try {
244
+ const raw = await fs.readFile(skillMdPath, "utf-8")
245
+ const { data: frontmatter } = matter(raw)
246
+ const skillName = entry.name
247
+ const canonicalPath = await fs.realpath(skillDirPath).catch(() => skillDirPath)
248
+ const scope = getScopeForPath(canonicalPath)
249
+ const supportingFiles = await listSupportingFiles(canonicalPath)
250
+
251
+ const existing = skillMap.get(canonicalPath)
252
+ if (existing) {
253
+ // Skill already seen from another agent - add this agent
254
+ if (!existing.agents.includes(agent.name)) {
255
+ existing.agents.push(agent.name)
256
+ }
257
+ } else {
258
+ skillMap.set(canonicalPath, {
259
+ name: skillName,
260
+ description:
261
+ (frontmatter.description as string) ??
262
+ extractFirstLine(raw),
263
+ filePath: skillMdPath,
264
+ canonicalPath,
265
+ agents: [agent.name],
266
+ scope,
267
+ projectName:
268
+ scope === "project" ? getProjectNameForPath(canonicalPath) : null,
269
+ hasSupportingFiles: supportingFiles.length > 0,
270
+ supportingFiles,
271
+ metadata: frontmatter as Record<string, unknown>,
272
+ lock: lock.skills[skillName],
273
+ })
274
+ }
275
+ } catch {
276
+ // SKILL.md not found or unreadable in this directory - skip
277
+ }
278
+ }
279
+ } catch {
280
+ // Agent skills directory doesn't exist - skip
281
+ }
282
+ }
283
+
284
+ const customScanPaths = settings.get<string[]>("scan.customPaths", [])
285
+ for (const customPath of customScanPaths) {
286
+ const resolvedRoot = path.resolve(customPath.replace(/^~(?=$|\/|\\)/, home))
287
+ const collected = await collectCustomSkills(resolvedRoot, lock)
288
+ for (const skill of collected) {
289
+ if (!skillMap.has(skill.canonicalPath)) {
290
+ skillMap.set(skill.canonicalPath, skill)
291
+ }
292
+ }
293
+ }
294
+
295
+ return Array.from(skillMap.values()).sort((a, b) =>
296
+ a.name.localeCompare(b.name)
297
+ )
298
+ }
299
+
212
300
  async function collectCustomSkills(
213
301
  rootPath: string,
214
302
  lock: SkillLockFile,
@@ -20,8 +20,6 @@ import {
20
20
  removeSkillFromLock,
21
21
  } from "../../../cli/src/core/skill-lock.js"
22
22
  import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
23
- import { downloadSkill } from "../../../cli/src/core/skillsgate-client.js"
24
- import { getToken } from "../../../cli/src/utils/auth-store.js"
25
23
  import type { Skill, AgentConfig } from "../../../cli/src/types.js"
26
24
 
27
25
  interface UseSkillActionsResult {
@@ -43,7 +41,6 @@ export function useSkillActions(): UseSkillActionsResult {
43
41
  /**
44
42
  * Install a skill from its source.
45
43
  * For public skills (with a source in owner/repo format), runs `npx skills add`.
46
- * For private skills, uses the existing download flow.
47
44
  */
48
45
  const installSkill = useCallback(async (skill: EnrichedSkill) => {
49
46
  dispatch({
@@ -73,7 +70,6 @@ export function useSkillActions(): UseSkillActionsResult {
73
70
  return
74
71
  }
75
72
 
76
- // Private skills: use the existing download flow
77
73
  const installedAgents = await detectInstalledAgents()
78
74
  if (installedAgents.length === 0) {
79
75
  dispatch({
@@ -98,15 +94,8 @@ export function useSkillActions(): UseSkillActionsResult {
98
94
  preferredNames.includes(agent.name),
99
95
  )
100
96
 
101
- let tmpDir: string
102
- if (source.type === "skillsgate") {
103
- // Download from private API
104
- const token = await getToken()
105
- tmpDir = await downloadSkill(source.username!, source.slug!, token)
106
- } else {
107
- // Local path -- use directly
108
- tmpDir = source.localPath!
109
- }
97
+ // Local path source uses resolved path directly
98
+ const tmpDir = source.localPath!
110
99
 
111
100
  try {
112
101
  // Discover skills in the downloaded directory
@@ -277,7 +266,6 @@ export function useSkillActions(): UseSkillActionsResult {
277
266
  /**
278
267
  * Update a skill by re-fetching from its source.
279
268
  * For GitHub skills: checks tree SHA for changes before re-installing.
280
- * For SkillsGate skills: always re-downloads.
281
269
  */
282
270
  const updateSkill = useCallback(async (skill: EnrichedSkill) => {
283
271
  if (!skill.lock) {
@@ -50,6 +50,26 @@ const MIGRATIONS: Migration[] = [
50
50
  INSERT OR IGNORE INTO schema_version VALUES (1);
51
51
  `,
52
52
  },
53
+ {
54
+ version: 2,
55
+ up: `
56
+ CREATE TABLE IF NOT EXISTS cached_skills (
57
+ canonical_path TEXT PRIMARY KEY,
58
+ folder_name TEXT NOT NULL,
59
+ name TEXT NOT NULL,
60
+ description TEXT NOT NULL DEFAULT '',
61
+ agents TEXT NOT NULL DEFAULT '[]',
62
+ agent_short_codes TEXT NOT NULL DEFAULT '[]',
63
+ scope TEXT NOT NULL DEFAULT 'global',
64
+ source TEXT,
65
+ source_type TEXT,
66
+ file_mod_time TEXT NOT NULL,
67
+ scanned_at TEXT NOT NULL
68
+ );
69
+
70
+ INSERT OR IGNORE INTO schema_version VALUES (2);
71
+ `,
72
+ },
53
73
  ]
54
74
 
55
75
  function getCurrentVersion(db: Database): number {
@@ -0,0 +1,89 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import type { AgentType, SourceType } from "../../../cli/src/types.js"
3
+
4
+ export interface CachedSkill {
5
+ canonicalPath: string
6
+ folderName: string
7
+ name: string
8
+ description: string
9
+ agents: AgentType[]
10
+ agentShortCodes: string[]
11
+ scope: "global" | "project" | "custom"
12
+ source: string | null
13
+ sourceType: SourceType | null
14
+ fileModTime: string
15
+ scannedAt: string
16
+ }
17
+
18
+ interface CachedSkillRow {
19
+ canonical_path: string
20
+ folder_name: string
21
+ name: string
22
+ description: string
23
+ agents: string
24
+ agent_short_codes: string
25
+ scope: string
26
+ source: string | null
27
+ source_type: string | null
28
+ file_mod_time: string
29
+ scanned_at: string
30
+ }
31
+
32
+ function rowToSkill(row: CachedSkillRow): CachedSkill {
33
+ return {
34
+ canonicalPath: row.canonical_path,
35
+ folderName: row.folder_name,
36
+ name: row.name,
37
+ description: row.description,
38
+ agents: JSON.parse(row.agents) as AgentType[],
39
+ agentShortCodes: JSON.parse(row.agent_short_codes) as string[],
40
+ scope: row.scope as CachedSkill["scope"],
41
+ source: row.source,
42
+ sourceType: row.source_type as SourceType | null,
43
+ fileModTime: row.file_mod_time,
44
+ scannedAt: row.scanned_at,
45
+ }
46
+ }
47
+
48
+ export function loadCachedSkills(db: Database): CachedSkill[] {
49
+ const rows = db
50
+ .query("SELECT * FROM cached_skills ORDER BY name ASC")
51
+ .all() as CachedSkillRow[]
52
+ return rows.map(rowToSkill)
53
+ }
54
+
55
+ export function saveCachedSkills(db: Database, skills: CachedSkill[]): void {
56
+ const insert = db.query(
57
+ `INSERT OR REPLACE INTO cached_skills
58
+ (canonical_path, folder_name, name, description, agents, agent_short_codes, scope, source, source_type, file_mod_time, scanned_at)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
60
+ )
61
+
62
+ db.exec("BEGIN TRANSACTION")
63
+ try {
64
+ db.exec("DELETE FROM cached_skills")
65
+ for (const skill of skills) {
66
+ insert.run(
67
+ skill.canonicalPath,
68
+ skill.folderName,
69
+ skill.name,
70
+ skill.description,
71
+ JSON.stringify(skill.agents),
72
+ JSON.stringify(skill.agentShortCodes),
73
+ skill.scope,
74
+ skill.source,
75
+ skill.sourceType,
76
+ skill.fileModTime,
77
+ skill.scannedAt,
78
+ )
79
+ }
80
+ db.exec("COMMIT")
81
+ } catch (err) {
82
+ db.exec("ROLLBACK")
83
+ throw err
84
+ }
85
+ }
86
+
87
+ export function clearSkillsCache(db: Database): void {
88
+ db.exec("DELETE FROM cached_skills")
89
+ }
package/src/db/ssh.ts CHANGED
@@ -198,34 +198,59 @@ function sha256(content: string): string {
198
198
  return crypto.createHash("sha256").update(content).digest("hex")
199
199
  }
200
200
 
201
+ /**
202
+ * Detect "directory does not exist" errors from `find` stderr.
203
+ */
204
+ function isMissingDirError(stderr: string): boolean {
205
+ return /no such file or directory/i.test(stderr)
206
+ }
207
+
201
208
  export async function scanRemoteSkills(
202
209
  server: RemoteServer
203
210
  ): Promise<ScannedRemoteSkill[]> {
204
211
  const basePath = shellQuotePath(server.skillsBasePath)
205
212
 
206
- // Round trip 1: Find all SKILL.md files
213
+ // Round trip 1: Find all SKILL.md files. Capture stderr (do NOT redirect to
214
+ // /dev/null) so we can distinguish "directory doesn't exist" from real errors.
207
215
  const findResult = await sshExec(
208
216
  server,
209
- `find ${basePath} -name 'SKILL.md' -type f 2>/dev/null`
217
+ `find ${basePath} -name 'SKILL.md' -type f`
210
218
  )
211
- if (findResult.exitCode !== 0 && findResult.stdout.trim() === "") {
212
- throw new Error(`Find failed: ${findResult.stderr.trim()}`)
213
- }
214
219
 
215
- const paths = findResult.stdout.trim().split("\n").filter(Boolean)
220
+ const paths = findResult.stdout
221
+ .split("\n")
222
+ .map((p) => p.trim())
223
+ .filter(Boolean)
224
+
225
+ if (findResult.exitCode !== 0 && paths.length === 0) {
226
+ const stderr = findResult.stderr.trim()
227
+ // Missing skills directory on a fresh remote is a normal state.
228
+ if (isMissingDirError(stderr)) return []
229
+ throw new Error(
230
+ stderr || `find exited with code ${findResult.exitCode} on ${server.skillsBasePath}`
231
+ )
232
+ }
216
233
  if (paths.length === 0) return []
217
234
 
218
- // Round trip 2: Batch read all files in a single SSH call
219
- const catCommands = paths
235
+ // Round trip 2: Batch read all files in a single SSH call.
236
+ // Single-quote escaping handles arbitrary characters; only reject paths with
237
+ // chars that would break our delimiter parser.
238
+ const safePaths = paths.filter((p) => !/[\x00\n\r]/.test(p))
239
+ if (safePaths.length === 0) return []
240
+
241
+ const catCommands = safePaths
220
242
  .map((p) => {
221
243
  const escaped = `'${p.replace(/'/g, "'\\''")}'`
222
- return `echo '${DELIMITER_PREFIX}${p}${DELIMITER_SUFFIX}' && cat ${escaped}`
244
+ const delimPath = p.replace(/'/g, "'\\''")
245
+ return `printf '%s\\n' '${DELIMITER_PREFIX}${delimPath}${DELIMITER_SUFFIX}' && cat ${escaped}`
223
246
  })
224
247
  .join(" && ")
225
248
 
226
249
  const catResult = await sshExec(server, catCommands)
227
- if (catResult.exitCode !== 0) {
228
- throw new Error(`Batch read failed: ${catResult.stderr.trim()}`)
250
+ if (catResult.exitCode !== 0 && catResult.stdout.trim() === "") {
251
+ throw new Error(
252
+ catResult.stderr.trim() || `cat exited with code ${catResult.exitCode}`
253
+ )
229
254
  }
230
255
 
231
256
  return parseDelimitedOutput(catResult.stdout)
@@ -283,6 +308,7 @@ export interface SyncResult {
283
308
  unchanged: number
284
309
  total: number
285
310
  log: string[]
311
+ error?: string
286
312
  }
287
313
 
288
314
  export async function syncRemoteServer(
@@ -349,7 +375,7 @@ export async function syncRemoteServer(
349
375
  }
350
376
  } catch (err) {
351
377
  const errorMsg = err instanceof Error ? err.message : String(err)
352
- log.push(`SSH connection failed: ${errorMsg}`)
378
+ log.push(`Sync failed: ${errorMsg}`)
353
379
  serverStore.updateSyncStatus(server.id, errorMsg)
354
380
  return {
355
381
  added: 0,
@@ -358,6 +384,7 @@ export async function syncRemoteServer(
358
384
  unchanged: 0,
359
385
  total: 0,
360
386
  log,
387
+ error: errorMsg,
361
388
  }
362
389
  }
363
390
  }
@@ -5,7 +5,6 @@ const FOCUS_ORDER: FocusedPane[] = ["agents", "search", "list"]
5
5
  export const initialState: AppState = {
6
6
  activeView: "home",
7
7
  previousView: null,
8
- auth: null,
9
8
  detectedAgents: [],
10
9
  selectedAgentFilter: "all",
11
10
  installedSkills: [],
@@ -14,8 +13,6 @@ export const initialState: AppState = {
14
13
  searchQuery: "",
15
14
  searchResults: [],
16
15
  searchLoading: false,
17
- favorites: [],
18
- favoritesLoading: false,
19
16
  selectedSkill: null,
20
17
  selectedServerId: null,
21
18
  showHelp: false,
@@ -40,9 +37,6 @@ export function appReducer(state: AppState, action: Action): AppState {
40
37
  previousView: null,
41
38
  }
42
39
 
43
- case "SET_AUTH":
44
- return { ...state, auth: action.auth }
45
-
46
40
  case "SET_DETECTED_AGENTS":
47
41
  return { ...state, detectedAgents: action.agents }
48
42
 
@@ -76,12 +70,6 @@ export function appReducer(state: AppState, action: Action): AppState {
76
70
  case "SET_SEARCH_LOADING":
77
71
  return { ...state, searchLoading: action.loading }
78
72
 
79
- case "SET_FAVORITES":
80
- return { ...state, favorites: action.favorites, favoritesLoading: false }
81
-
82
- case "SET_FAVORITES_LOADING":
83
- return { ...state, favoritesLoading: action.loading }
84
-
85
73
  case "SELECT_SKILL":
86
74
  return {
87
75
  ...state,
@@ -5,14 +5,12 @@ import type { AgentType, SkillLockEntry, SourceType } from "../../../cli/src/typ
5
5
  export type ViewName =
6
6
  | "home"
7
7
  | "discover"
8
- | "favorites"
9
8
  | "servers"
10
9
  | "server-skills"
11
10
  | "add-server"
12
11
  | "edit-server"
13
12
  | "settings"
14
13
  | "detail"
15
- | "login"
16
14
 
17
15
  // ---------- Enriched Skill ----------
18
16
 
@@ -44,13 +42,6 @@ export interface DetectedAgent {
44
42
  skillCount: number
45
43
  }
46
44
 
47
- // ---------- Auth ----------
48
-
49
- export interface AuthState {
50
- token: string
51
- user: { id: string; name: string; email: string }
52
- }
53
-
54
45
  // ---------- Notification ----------
55
46
 
56
47
  export interface Notification {
@@ -68,9 +59,6 @@ export interface AppState {
68
59
  activeView: ViewName
69
60
  previousView: ViewName | null
70
61
 
71
- // Auth
72
- auth: AuthState | null
73
-
74
62
  // Agent detection
75
63
  detectedAgents: DetectedAgent[]
76
64
 
@@ -85,10 +73,6 @@ export interface AppState {
85
73
  searchResults: unknown[]
86
74
  searchLoading: boolean
87
75
 
88
- // Favorites
89
- favorites: unknown[]
90
- favoritesLoading: boolean
91
-
92
76
  // Detail
93
77
  selectedSkill: EnrichedSkill | null
94
78
 
@@ -108,7 +92,6 @@ export interface AppState {
108
92
  export type Action =
109
93
  | { type: "NAVIGATE"; view: ViewName }
110
94
  | { type: "GO_BACK" }
111
- | { type: "SET_AUTH"; auth: AuthState | null }
112
95
  | { type: "SET_DETECTED_AGENTS"; agents: DetectedAgent[] }
113
96
  | { type: "UPDATE_AGENT_COUNTS"; counts: Record<string, number> }
114
97
  | { type: "SET_AGENT_FILTER"; filter: string }
@@ -118,8 +101,6 @@ export type Action =
118
101
  | { type: "SET_SEARCH_QUERY"; query: string }
119
102
  | { type: "SET_SEARCH_RESULTS"; results: unknown[] }
120
103
  | { type: "SET_SEARCH_LOADING"; loading: boolean }
121
- | { type: "SET_FAVORITES"; favorites: unknown[] }
122
- | { type: "SET_FAVORITES_LOADING"; loading: boolean }
123
104
  | { type: "SELECT_SKILL"; skill: EnrichedSkill }
124
105
  | { type: "PREVIEW_SKILL"; skill: EnrichedSkill }
125
106
  | { type: "CLEAR_SKILL" }
@@ -25,6 +25,8 @@ export const agentBadges: Record<string, { label: string; color: string }> = {
25
25
  cursor: { label: "Cu", color: "#5599FF" }, // blue
26
26
  windsurf: { label: "W", color: "#00CED1" }, // cyan
27
27
  "codex-cli": { label: "Cx", color: "#FF4444" }, // red
28
+ "droid-cli": { label: "Dr", color: "#22D3EE" }, // cyan
29
+ "ob-1": { label: "OB", color: "#12B3DF" }, // openblock cyan
28
30
  opencode: { label: "O", color: "#2ECCAA" }, // teal
29
31
  zed: { label: "Z", color: "#FFFF00" }, // yellow
30
32
  "github-copilot": { label: "Gh", color: "#8B5CF6" }, // purple
@@ -169,18 +169,21 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
169
169
  setSyncing(false)
170
170
  refreshList()
171
171
 
172
- if (result.total > 0 || result.removed > 0) {
172
+ if (result.error) {
173
173
  dispatch({
174
174
  type: "SHOW_NOTIFICATION",
175
175
  notification: {
176
- type: "success",
177
- message: `Synced ${server.label}: ${result.added} new, ${result.updated} updated, ${result.removed} removed`,
176
+ type: "error",
177
+ message: `Sync failed for ${server.label}: ${result.error}`,
178
178
  },
179
179
  })
180
- } else if (result.log.some((l) => l.includes("failed"))) {
180
+ } else if (result.total > 0 || result.removed > 0) {
181
181
  dispatch({
182
182
  type: "SHOW_NOTIFICATION",
183
- notification: { type: "error", message: `Sync failed for ${server.label}` },
183
+ notification: {
184
+ type: "success",
185
+ message: `Synced ${server.label}: ${result.added} new, ${result.updated} updated, ${result.removed} removed`,
186
+ },
184
187
  })
185
188
  } else {
186
189
  dispatch({
@@ -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
- }
@@ -1,202 +0,0 @@
1
- import { useState, useCallback } from "react"
2
- import { useKeyboard } from "@opentui/react"
3
- import { useStore, useDispatch } from "../store/context.js"
4
- import { useAuth } from "../data/use-auth.js"
5
- import { API_BASE_URL } from "../../../cli/src/constants.js"
6
- import { colors } from "../utils/colors.js"
7
-
8
- type LoginStep = "prompt" | "code" | "exchanging"
9
-
10
- /**
11
- * Login view implementing the device code flow:
12
- * 1. Show instructions with the auth URL
13
- * 2. Prompt to open browser (y/n)
14
- * 3. Show input for device code (XXXX-XXXX)
15
- * 4. Exchange code for token, save auth, navigate back
16
- */
17
- export function LoginView() {
18
- const state = useStore()
19
- const dispatch = useDispatch()
20
- const { auth, login, logout } = useAuth()
21
-
22
- const [step, setStep] = useState<LoginStep>("prompt")
23
- const [error, setError] = useState<string | null>(null)
24
-
25
- const authUrl = `${API_BASE_URL}/cli/auth`
26
-
27
- function openBrowser() {
28
- try {
29
- const { exec } = require("node:child_process")
30
- const cmd = process.platform === "darwin" ? "open" : "xdg-open"
31
- exec(`${cmd} "${authUrl}"`)
32
- dispatch({
33
- type: "SHOW_NOTIFICATION",
34
- notification: { type: "info", message: "Opening browser..." },
35
- })
36
- } catch {
37
- // Best effort
38
- }
39
- }
40
-
41
- // Handle keyboard input
42
- useKeyboard((key) => {
43
- if (state.activeView !== "login") return
44
- if (state.showHelp) return
45
-
46
- // Esc to go back at any step
47
- if (key.name === "escape") {
48
- dispatch({ type: "GO_BACK" })
49
- return
50
- }
51
-
52
- if (step === "prompt") {
53
- // "r" to re-login (clear old auth, open browser, go to code step)
54
- if (key.name === "r") {
55
- logout()
56
- openBrowser()
57
- setStep("code")
58
- return
59
- }
60
-
61
- // "o" to logout only (no re-login)
62
- if (key.name === "o") {
63
- logout()
64
- dispatch({
65
- type: "SHOW_NOTIFICATION",
66
- notification: { type: "success", message: "Signed out" },
67
- })
68
- dispatch({ type: "GO_BACK" })
69
- return
70
- }
71
-
72
- // "y" to open browser and proceed to code input
73
- if (key.name === "y") {
74
- openBrowser()
75
- setStep("code")
76
- return
77
- }
78
-
79
- // "n" to skip browser, go straight to code input
80
- if (key.name === "n") {
81
- setStep("code")
82
- return
83
- }
84
- }
85
- })
86
-
87
- const handleCodeSubmit = useCallback(async (value: string) => {
88
- const code = value.trim()
89
- if (!code) return
90
-
91
- setStep("exchanging")
92
- setError(null)
93
-
94
- const errMsg = await login(code)
95
-
96
- if (errMsg) {
97
- setError(errMsg)
98
- setStep("code") // Let user retry
99
- } else {
100
- // Success - navigate back
101
- dispatch({
102
- type: "SHOW_NOTIFICATION",
103
- notification: { type: "success", message: "Logged in successfully!" },
104
- })
105
- dispatch({ type: "GO_BACK" })
106
- }
107
- }, [login, dispatch])
108
-
109
- // Already logged in -- offer re-login or logout
110
- if (auth && step === "prompt") {
111
- return (
112
- <box style={{ flexDirection: "column", padding: 2 }}>
113
- <text fg={colors.success}>
114
- Logged in as <strong>{auth.user.name}</strong> ({auth.user.email})
115
- </text>
116
- <text>{" "}</text>
117
- <text fg={colors.text}>
118
- If AI search isn't working, your session may have expired.
119
- </text>
120
- <text>{" "}</text>
121
- <text fg={colors.primary}>r</text>
122
- <text fg={colors.text}> Re-login with a fresh token</text>
123
- <text fg={colors.primary}>o</text>
124
- <text fg={colors.text}> Sign out</text>
125
- <text fg={colors.textDim}>Esc</text>
126
- <text fg={colors.text}> Go back</text>
127
- </box>
128
- )
129
- }
130
-
131
- return (
132
- <box style={{ flexDirection: "column", padding: 2 }}>
133
- {/* Title */}
134
- <text fg={colors.primary}>
135
- <strong>Sign in to SkillsGate</strong>
136
- </text>
137
- <text>{" "}</text>
138
-
139
- {/* Instructions */}
140
- <text fg={colors.text}>
141
- Visit the following URL in your browser to get a login code:
142
- </text>
143
- <text>{" "}</text>
144
- <text fg={colors.primary}>
145
- {authUrl}
146
- </text>
147
- <text>{" "}</text>
148
-
149
- {step === "prompt" && (
150
- <>
151
- <text fg={colors.text}>
152
- Open browser? <span fg={colors.textDim}>(y/n)</span>
153
- </text>
154
- </>
155
- )}
156
-
157
- {step === "code" && (
158
- <>
159
- <text fg={colors.text}>
160
- Paste the code from the browser:
161
- </text>
162
- <text>{" "}</text>
163
- <box
164
- style={{
165
- height: 3,
166
- width: 40,
167
- border: true,
168
- borderColor: colors.primary,
169
- paddingLeft: 1,
170
- paddingRight: 1,
171
- }}
172
- title="Code"
173
- >
174
- <input
175
- placeholder="XXXX-XXXX"
176
- focused={state.activeView === "login" && step === "code" && !state.showHelp}
177
- onSubmit={handleCodeSubmit as any}
178
- />
179
- </box>
180
- <text>{" "}</text>
181
- <text fg={colors.textDim}>
182
- Press Enter to submit, Esc to cancel
183
- </text>
184
- </>
185
- )}
186
-
187
- {step === "exchanging" && (
188
- <text fg={colors.primary}>
189
- Verifying code...
190
- </text>
191
- )}
192
-
193
- {/* Error message */}
194
- {error && (
195
- <>
196
- <text>{" "}</text>
197
- <text fg={colors.error}>{error}</text>
198
- </>
199
- )}
200
- </box>
201
- )
202
- }