@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 +1 -1
- package/src/data/api-client.ts +0 -4
- package/src/data/use-installed-skills.ts +173 -85
- package/src/data/use-skill-actions.ts +2 -14
- package/src/db/migrations.ts +20 -0
- package/src/db/skills-cache.ts +89 -0
- package/src/db/ssh.ts +39 -12
- package/src/store/reducers.ts +0 -12
- package/src/store/types.ts +0 -19
- package/src/utils/colors.ts +2 -0
- package/src/views/servers.tsx +8 -5
- package/src/data/use-auth.ts +0 -136
- package/src/data/use-favorites.ts +0 -161
- package/src/views/favorites.tsx +0 -19
- package/src/views/login.tsx +0 -202
package/package.json
CHANGED
package/src/data/api-client.ts
CHANGED
|
@@ -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
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
try
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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) {
|
package/src/db/migrations.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(`
|
|
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
|
}
|
package/src/store/reducers.ts
CHANGED
|
@@ -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,
|
package/src/store/types.ts
CHANGED
|
@@ -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" }
|
package/src/utils/colors.ts
CHANGED
|
@@ -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
|
package/src/views/servers.tsx
CHANGED
|
@@ -169,18 +169,21 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
169
169
|
setSyncing(false)
|
|
170
170
|
refreshList()
|
|
171
171
|
|
|
172
|
-
if (result.
|
|
172
|
+
if (result.error) {
|
|
173
173
|
dispatch({
|
|
174
174
|
type: "SHOW_NOTIFICATION",
|
|
175
175
|
notification: {
|
|
176
|
-
type: "
|
|
177
|
-
message: `
|
|
176
|
+
type: "error",
|
|
177
|
+
message: `Sync failed for ${server.label}: ${result.error}`,
|
|
178
178
|
},
|
|
179
179
|
})
|
|
180
|
-
} else if (result.
|
|
180
|
+
} else if (result.total > 0 || result.removed > 0) {
|
|
181
181
|
dispatch({
|
|
182
182
|
type: "SHOW_NOTIFICATION",
|
|
183
|
-
notification: {
|
|
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({
|
package/src/data/use-auth.ts
DELETED
|
@@ -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
|
-
}
|
package/src/views/favorites.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/views/login.tsx
DELETED
|
@@ -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
|
-
}
|