@skillsgate/tui 0.1.12 → 0.1.14
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 -8
- package/src/components/help-overlay.tsx +2 -4
- package/src/components/layout.tsx +8 -34
- package/src/components/status-bar.tsx +4 -7
- package/src/data/api-client.ts +78 -109
- package/src/data/use-favorites.ts +18 -4
- package/src/data/use-installed-skills.ts +173 -85
- package/src/data/use-search.ts +14 -31
- package/src/data/use-skill-actions.ts +68 -16
- package/src/db/migrations.ts +20 -0
- package/src/db/skills-cache.ts +89 -0
- package/src/views/discover.tsx +33 -126
- package/src/views/favorites.tsx +10 -354
- package/src/views/settings.tsx +0 -6
- package/src/views/skill-detail.tsx +15 -14
- package/tmp.json +0 -0
|
@@ -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,
|
package/src/data/use-search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from "react"
|
|
2
|
-
import {
|
|
2
|
+
import { searchSkills, type CatalogSkill } from "./api-client.js"
|
|
3
3
|
|
|
4
4
|
const DEBOUNCE_MS = 300
|
|
5
5
|
const PAGE_SIZE = 20
|
|
@@ -11,35 +11,28 @@ interface UseSearchResult {
|
|
|
11
11
|
total: number
|
|
12
12
|
hasMore: boolean
|
|
13
13
|
loadMore: () => void
|
|
14
|
-
remainingSearches: number | null
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Hook that manages search state with debounce and pagination.
|
|
19
|
-
* - When query is empty, loads
|
|
18
|
+
* - When query is empty, loads popular skills
|
|
20
19
|
* - When query is provided, searches after 300ms debounce
|
|
21
|
-
* - Supports keyword and semantic search modes
|
|
22
20
|
*/
|
|
23
|
-
export function useSearch(
|
|
24
|
-
query: string,
|
|
25
|
-
mode: SearchMode,
|
|
26
|
-
token?: string | null
|
|
27
|
-
): UseSearchResult {
|
|
21
|
+
export function useSearch(query: string): UseSearchResult {
|
|
28
22
|
const [results, setResults] = useState<CatalogSkill[]>([])
|
|
29
23
|
const [loading, setLoading] = useState(false)
|
|
30
24
|
const [error, setError] = useState<string | null>(null)
|
|
31
25
|
const [total, setTotal] = useState(0)
|
|
32
26
|
const [offset, setOffset] = useState(0)
|
|
33
|
-
const [remainingSearches, setRemainingSearches] = useState<number | null>(null)
|
|
34
27
|
|
|
35
28
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
29
|
|
|
37
|
-
// Reset pagination when query
|
|
30
|
+
// Reset pagination when query changes
|
|
38
31
|
useEffect(() => {
|
|
39
32
|
setResults([])
|
|
40
33
|
setOffset(0)
|
|
41
34
|
setError(null)
|
|
42
|
-
}, [query
|
|
35
|
+
}, [query])
|
|
43
36
|
|
|
44
37
|
useEffect(() => {
|
|
45
38
|
if (timerRef.current) {
|
|
@@ -52,19 +45,10 @@ export function useSearch(
|
|
|
52
45
|
setError(null)
|
|
53
46
|
|
|
54
47
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
}
|
|
48
|
+
const data = await searchSkills(query, PAGE_SIZE)
|
|
49
|
+
setResults(data.skills)
|
|
50
|
+
setTotal(data.total)
|
|
51
|
+
setOffset(PAGE_SIZE)
|
|
68
52
|
} catch (err) {
|
|
69
53
|
const msg = err instanceof Error ? err.message : String(err)
|
|
70
54
|
if (!msg.includes("abort")) {
|
|
@@ -86,17 +70,17 @@ export function useSearch(
|
|
|
86
70
|
clearTimeout(timerRef.current)
|
|
87
71
|
}
|
|
88
72
|
}
|
|
89
|
-
}, [query
|
|
73
|
+
}, [query])
|
|
90
74
|
|
|
91
75
|
const loadMore = useCallback(async () => {
|
|
92
|
-
if (
|
|
93
|
-
if (results.length >= total) return
|
|
76
|
+
if (loading) return
|
|
77
|
+
if (results.length >= total && total > 0) return
|
|
94
78
|
|
|
95
79
|
setLoading(true)
|
|
96
80
|
try {
|
|
97
|
-
const data = await
|
|
81
|
+
const data = await searchSkills(query, PAGE_SIZE, offset)
|
|
98
82
|
setResults((prev) => [...prev, ...data.skills])
|
|
99
|
-
setTotal(data.total)
|
|
83
|
+
setTotal((prev) => prev + data.total)
|
|
100
84
|
setOffset((prev) => prev + PAGE_SIZE)
|
|
101
85
|
} catch (err) {
|
|
102
86
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -113,6 +97,5 @@ export function useSearch(
|
|
|
113
97
|
total,
|
|
114
98
|
hasMore: results.length < total,
|
|
115
99
|
loadMore,
|
|
116
|
-
remainingSearches,
|
|
117
100
|
}
|
|
118
101
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useCallback } from "react"
|
|
2
|
+
import { exec as execCb } from "node:child_process"
|
|
3
|
+
import { promisify } from "node:util"
|
|
2
4
|
import { useStore, useDispatch } from "../store/context.js"
|
|
3
5
|
import { useDb } from "../db/context.js"
|
|
4
|
-
import type { EnrichedSkill } from "../store/types.js"
|
|
6
|
+
import type { EnrichedSkill, Action } from "../store/types.js"
|
|
5
7
|
|
|
6
8
|
// CLI core imports -- these share the same Bun runtime
|
|
7
9
|
import { parseSource } from "../../../cli/src/core/source-parser.js"
|
|
8
|
-
import {
|
|
10
|
+
import { cleanupTempDir, fetchTreeSha } from "../../../cli/src/core/git.js"
|
|
9
11
|
import { discoverSkills } from "../../../cli/src/core/skill-discovery.js"
|
|
10
12
|
import {
|
|
11
13
|
installSkillForAgent,
|
|
@@ -16,12 +18,11 @@ import {
|
|
|
16
18
|
import {
|
|
17
19
|
addSkillToLock,
|
|
18
20
|
removeSkillFromLock,
|
|
19
|
-
readSkillLock,
|
|
20
21
|
} from "../../../cli/src/core/skill-lock.js"
|
|
21
22
|
import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
|
|
22
23
|
import { downloadSkill } from "../../../cli/src/core/skillsgate-client.js"
|
|
23
24
|
import { getToken } from "../../../cli/src/utils/auth-store.js"
|
|
24
|
-
import type { Skill, AgentConfig
|
|
25
|
+
import type { Skill, AgentConfig } from "../../../cli/src/types.js"
|
|
25
26
|
|
|
26
27
|
interface UseSkillActionsResult {
|
|
27
28
|
installSkill: (skill: EnrichedSkill) => Promise<void>
|
|
@@ -40,7 +41,9 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
40
41
|
const { settings } = useDb()
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Install a skill from its source
|
|
44
|
+
* Install a skill from its source.
|
|
45
|
+
* For public skills (with a source in owner/repo format), runs `npx skills add`.
|
|
46
|
+
* For private skills, uses the existing download flow.
|
|
44
47
|
*/
|
|
45
48
|
const installSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
46
49
|
dispatch({
|
|
@@ -61,7 +64,16 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
61
64
|
|
|
62
65
|
const source = parseSource(sourceStr)
|
|
63
66
|
|
|
64
|
-
//
|
|
67
|
+
// Public skills (owner/repo format): use `npx skills add`
|
|
68
|
+
if (source.type === "github" || isOwnerRepoFormat(sourceStr)) {
|
|
69
|
+
const repo = source.type === "github"
|
|
70
|
+
? `${source.owner}/${source.repo}`
|
|
71
|
+
: sourceStr
|
|
72
|
+
await runSkillsAdd(repo, dispatch)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Private skills: use the existing download flow
|
|
65
77
|
const installedAgents = await detectInstalledAgents()
|
|
66
78
|
if (installedAgents.length === 0) {
|
|
67
79
|
dispatch({
|
|
@@ -88,19 +100,16 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
88
100
|
|
|
89
101
|
let tmpDir: string
|
|
90
102
|
if (source.type === "skillsgate") {
|
|
91
|
-
// Download from
|
|
103
|
+
// Download from private API
|
|
92
104
|
const token = await getToken()
|
|
93
105
|
tmpDir = await downloadSkill(source.username!, source.slug!, token)
|
|
94
|
-
} else if (source.type === "github") {
|
|
95
|
-
// Clone from GitHub
|
|
96
|
-
tmpDir = await cloneRepo(source)
|
|
97
106
|
} else {
|
|
98
107
|
// Local path -- use directly
|
|
99
108
|
tmpDir = source.localPath!
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
try {
|
|
103
|
-
// Discover skills in the
|
|
112
|
+
// Discover skills in the downloaded directory
|
|
104
113
|
const skills = await discoverSkills(tmpDir, source.subpath)
|
|
105
114
|
|
|
106
115
|
if (skills.length === 0) {
|
|
@@ -320,9 +329,49 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
320
329
|
|
|
321
330
|
// ---------- Helpers ----------
|
|
322
331
|
|
|
332
|
+
const execAsync = promisify(execCb)
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Checks if a string matches the owner/repo format (e.g. "vercel/skills").
|
|
336
|
+
*/
|
|
337
|
+
function isOwnerRepoFormat(str: string): boolean {
|
|
338
|
+
return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(str)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Runs `npx skills add <source> --all -y` as a child process to install
|
|
343
|
+
* all skills from a public repository.
|
|
344
|
+
*/
|
|
345
|
+
async function runSkillsAdd(
|
|
346
|
+
source: string,
|
|
347
|
+
dispatch: (action: Action) => void
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
try {
|
|
350
|
+
await execAsync(
|
|
351
|
+
`npx skills add ${source} --all -y`,
|
|
352
|
+
{ timeout: 60_000 }
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
dispatch({ type: "REFRESH_SKILLS" })
|
|
356
|
+
dispatch({
|
|
357
|
+
type: "SHOW_NOTIFICATION",
|
|
358
|
+
notification: {
|
|
359
|
+
type: "success",
|
|
360
|
+
message: `Installed skills from ${source}`,
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
365
|
+
dispatch({
|
|
366
|
+
type: "SHOW_NOTIFICATION",
|
|
367
|
+
notification: { type: "error", message: `Install failed: ${msg}` },
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
323
372
|
/**
|
|
324
373
|
* Resolves the source string for a skill from its metadata.
|
|
325
|
-
* Checks: lock.source, metadata.githubUrl, metadata.installCommand
|
|
374
|
+
* Checks: lock.source, metadata.source, metadata.githubUrl, metadata.installCommand
|
|
326
375
|
*/
|
|
327
376
|
function resolveSource(skill: EnrichedSkill): string | null {
|
|
328
377
|
// From lock file entry
|
|
@@ -330,17 +379,20 @@ function resolveSource(skill: EnrichedSkill): string | null {
|
|
|
330
379
|
return skill.lock.source
|
|
331
380
|
}
|
|
332
381
|
|
|
333
|
-
// From metadata (catalog skills)
|
|
382
|
+
// From metadata (catalog skills -- owner/repo format)
|
|
334
383
|
const meta = skill.metadata
|
|
384
|
+
if (meta?.source && typeof meta.source === "string") {
|
|
385
|
+
return meta.source
|
|
386
|
+
}
|
|
387
|
+
|
|
335
388
|
if (meta?.githubUrl && typeof meta.githubUrl === "string") {
|
|
336
389
|
return meta.githubUrl
|
|
337
390
|
}
|
|
338
391
|
|
|
339
|
-
// From install command (e.g. "
|
|
392
|
+
// From install command (e.g. "skills add <source>")
|
|
340
393
|
if (meta?.installCommand && typeof meta.installCommand === "string") {
|
|
341
394
|
const cmd = meta.installCommand as string
|
|
342
|
-
|
|
343
|
-
const match = cmd.match(/skillsgate\s+(?:add|install)\s+(.+)/)
|
|
395
|
+
const match = cmd.match(/skills?\s+(?:add|install)\s+(.+)/)
|
|
344
396
|
if (match) {
|
|
345
397
|
return match[1].trim()
|
|
346
398
|
}
|
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
|
+
}
|