@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.
@@ -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,
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react"
2
- import { fetchCatalog, searchSkills, type CatalogSkill, type SearchMode } from "./api-client.js"
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 the catalog with pagination
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 or mode changes
30
+ // Reset pagination when query changes
38
31
  useEffect(() => {
39
32
  setResults([])
40
33
  setOffset(0)
41
34
  setError(null)
42
- }, [query, mode])
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
- if (query.trim()) {
56
- const data = await searchSkills(query, mode, token)
57
- setResults(data.skills)
58
- setTotal(data.total)
59
- if (data.remainingSearches !== undefined) {
60
- setRemainingSearches(data.remainingSearches)
61
- }
62
- } else {
63
- const data = await fetchCatalog(PAGE_SIZE, 0)
64
- setResults(data.skills)
65
- setTotal(data.total)
66
- setOffset(PAGE_SIZE)
67
- }
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, mode, token])
73
+ }, [query])
90
74
 
91
75
  const loadMore = useCallback(async () => {
92
- if (query.trim() || loading) return
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 fetchCatalog(PAGE_SIZE, offset)
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 { cloneRepo, cleanupTempDir, fetchTreeSha } from "../../../cli/src/core/git.js"
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, ParsedSource } from "../../../cli/src/types.js"
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 (GitHub URL, SkillsGate slug, or install command).
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
- // Detect installed agents to install to
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 SkillsGate API
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 cloned/downloaded directory
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. "skillsgate add @user/slug")
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
- // Extract the source from "skillsgate add <source>" or "skillsgate install <source>"
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
  }
@@ -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
+ }