@skillsgate/tui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bin/skillsgate-tui +28 -0
  2. package/bunfig.toml +3 -0
  3. package/package.json +24 -0
  4. package/src/app.tsx +18 -0
  5. package/src/components/agent-filter.tsx +162 -0
  6. package/src/components/confirm-dialog.tsx +56 -0
  7. package/src/components/help-overlay.tsx +101 -0
  8. package/src/components/layout.tsx +272 -0
  9. package/src/components/search-input.tsx +48 -0
  10. package/src/components/skill-list-item.tsx +45 -0
  11. package/src/components/skill-list.tsx +245 -0
  12. package/src/components/status-bar.tsx +34 -0
  13. package/src/data/api-client.ts +151 -0
  14. package/src/data/use-agents.ts +41 -0
  15. package/src/data/use-auth.ts +136 -0
  16. package/src/data/use-favorites.ts +147 -0
  17. package/src/data/use-installed-skills.ts +128 -0
  18. package/src/data/use-search.ts +118 -0
  19. package/src/data/use-skill-actions.ts +333 -0
  20. package/src/db/context.tsx +38 -0
  21. package/src/db/index.ts +19 -0
  22. package/src/db/migrations.ts +72 -0
  23. package/src/db/servers.ts +154 -0
  24. package/src/db/settings.ts +43 -0
  25. package/src/db/skills.ts +138 -0
  26. package/src/db/ssh.ts +319 -0
  27. package/src/index.tsx +37 -0
  28. package/src/store/context.tsx +26 -0
  29. package/src/store/reducers.ts +126 -0
  30. package/src/store/types.ts +124 -0
  31. package/src/utils/colors.ts +42 -0
  32. package/src/views/add-server.tsx +240 -0
  33. package/src/views/discover.tsx +419 -0
  34. package/src/views/favorites.tsx +358 -0
  35. package/src/views/home.tsx +218 -0
  36. package/src/views/login.tsx +202 -0
  37. package/src/views/server-skills.tsx +269 -0
  38. package/src/views/servers.tsx +449 -0
  39. package/src/views/settings.tsx +185 -0
  40. package/src/views/skill-detail.tsx +497 -0
  41. package/tsconfig.json +18 -0
@@ -0,0 +1,333 @@
1
+ import { useCallback } from "react"
2
+ import { useStore, useDispatch } from "../store/context.js"
3
+ import type { EnrichedSkill } from "../store/types.js"
4
+
5
+ // CLI core imports -- these share the same Bun runtime
6
+ import { parseSource } from "../../../cli/src/core/source-parser.js"
7
+ import { cloneRepo, cleanupTempDir, fetchTreeSha } from "../../../cli/src/core/git.js"
8
+ import { discoverSkills } from "../../../cli/src/core/skill-discovery.js"
9
+ import {
10
+ installSkillForAgent,
11
+ removeSkillFromAgent,
12
+ removeCanonicalSkill,
13
+ sanitizeName,
14
+ } from "../../../cli/src/core/installer.js"
15
+ import {
16
+ addSkillToLock,
17
+ removeSkillFromLock,
18
+ readSkillLock,
19
+ } from "../../../cli/src/core/skill-lock.js"
20
+ import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
21
+ import { downloadSkill } from "../../../cli/src/core/skillsgate-client.js"
22
+ import { getToken } from "../../../cli/src/utils/auth-store.js"
23
+ import type { Skill, AgentConfig, ParsedSource } from "../../../cli/src/types.js"
24
+
25
+ interface UseSkillActionsResult {
26
+ installSkill: (skill: EnrichedSkill) => Promise<void>
27
+ removeSkill: (skill: EnrichedSkill) => Promise<void>
28
+ removeSkillFromOneAgent: (skill: EnrichedSkill, agentName: string) => Promise<void>
29
+ updateSkill: (skill: EnrichedSkill) => Promise<void>
30
+ }
31
+
32
+ /**
33
+ * Provides install, remove, and update actions for skills.
34
+ * Uses CLI core modules directly since they share the same Bun runtime.
35
+ */
36
+ export function useSkillActions(): UseSkillActionsResult {
37
+ const state = useStore()
38
+ const dispatch = useDispatch()
39
+
40
+ /**
41
+ * Install a skill from its source (GitHub URL, SkillsGate slug, or install command).
42
+ */
43
+ const installSkill = useCallback(async (skill: EnrichedSkill) => {
44
+ dispatch({
45
+ type: "SHOW_NOTIFICATION",
46
+ notification: { type: "info", message: `Installing "${skill.name}"...` },
47
+ })
48
+
49
+ try {
50
+ // Determine the source from metadata or lock entry
51
+ const sourceStr = resolveSource(skill)
52
+ if (!sourceStr) {
53
+ dispatch({
54
+ type: "SHOW_NOTIFICATION",
55
+ notification: { type: "error", message: `Cannot determine source for "${skill.name}"` },
56
+ })
57
+ return
58
+ }
59
+
60
+ const source = parseSource(sourceStr)
61
+
62
+ // Detect installed agents to install to
63
+ const installedAgents = await detectInstalledAgents()
64
+ if (installedAgents.length === 0) {
65
+ dispatch({
66
+ type: "SHOW_NOTIFICATION",
67
+ notification: { type: "error", message: "No AI agents detected on this system" },
68
+ })
69
+ return
70
+ }
71
+
72
+ let tmpDir: string
73
+ if (source.type === "skillsgate") {
74
+ // Download from SkillsGate API
75
+ const token = await getToken()
76
+ tmpDir = await downloadSkill(source.username!, source.slug!, token)
77
+ } else if (source.type === "github") {
78
+ // Clone from GitHub
79
+ tmpDir = await cloneRepo(source)
80
+ } else {
81
+ // Local path -- use directly
82
+ tmpDir = source.localPath!
83
+ }
84
+
85
+ try {
86
+ // Discover skills in the cloned/downloaded directory
87
+ const skills = await discoverSkills(tmpDir, source.subpath)
88
+
89
+ if (skills.length === 0) {
90
+ dispatch({
91
+ type: "SHOW_NOTIFICATION",
92
+ notification: { type: "error", message: `No skills found in "${sourceStr}"` },
93
+ })
94
+ return
95
+ }
96
+
97
+ // Filter if a specific skill was requested
98
+ let targetSkills = skills
99
+ if (source.skillFilter) {
100
+ const filter = source.skillFilter.toLowerCase()
101
+ targetSkills = skills.filter((s) => s.name.toLowerCase() === filter)
102
+ if (targetSkills.length === 0) {
103
+ dispatch({
104
+ type: "SHOW_NOTIFICATION",
105
+ notification: { type: "error", message: `Skill "${source.skillFilter}" not found in "${sourceStr}"` },
106
+ })
107
+ return
108
+ }
109
+ }
110
+
111
+ let installedCount = 0
112
+
113
+ for (const skillToInstall of targetSkills) {
114
+ // Install to all detected agents
115
+ for (const agent of installedAgents) {
116
+ const result = await installSkillForAgent(
117
+ skillToInstall,
118
+ agent,
119
+ "global",
120
+ "symlink",
121
+ )
122
+ if (result.success) {
123
+ installedCount++
124
+ }
125
+ }
126
+
127
+ // Update lock file
128
+ await addSkillToLock(sanitizeName(skillToInstall.name), {
129
+ source: sourceStr,
130
+ sourceType: source.type,
131
+ originalUrl: source.url,
132
+ skillFolderHash: "",
133
+ })
134
+ }
135
+
136
+ // Trigger refresh
137
+ dispatch({ type: "REFRESH_SKILLS" })
138
+
139
+ const skillNames = targetSkills.map((s) => s.name).join(", ")
140
+ dispatch({
141
+ type: "SHOW_NOTIFICATION",
142
+ notification: {
143
+ type: "success",
144
+ message: `Installed ${targetSkills.length} skill(s): ${skillNames} to ${installedAgents.length} agent(s)`,
145
+ },
146
+ })
147
+ } finally {
148
+ // Clean up temp directory (only if it was a temp clone/download)
149
+ if (source.type !== "local") {
150
+ await cleanupTempDir(tmpDir)
151
+ }
152
+ }
153
+ } catch (err) {
154
+ const msg = err instanceof Error ? err.message : String(err)
155
+ dispatch({
156
+ type: "SHOW_NOTIFICATION",
157
+ notification: { type: "error", message: `Install failed: ${msg}` },
158
+ })
159
+ }
160
+ }, [dispatch])
161
+
162
+ /**
163
+ * Remove a skill from all agents and the lock file.
164
+ */
165
+ const removeSkill = useCallback(async (skill: EnrichedSkill) => {
166
+ dispatch({
167
+ type: "SHOW_NOTIFICATION",
168
+ notification: { type: "info", message: `Removing "${skill.name}"...` },
169
+ })
170
+
171
+ try {
172
+ const safeName = sanitizeName(skill.name)
173
+
174
+ // Remove from all agent directories
175
+ const installedAgents = await detectInstalledAgents()
176
+ for (const agent of installedAgents) {
177
+ await removeSkillFromAgent(safeName, agent, "global")
178
+ }
179
+
180
+ // Remove canonical copy
181
+ await removeCanonicalSkill(skill.name)
182
+
183
+ // Remove from lock file
184
+ await removeSkillFromLock(safeName)
185
+
186
+ // Trigger refresh
187
+ dispatch({ type: "REFRESH_SKILLS" })
188
+
189
+ dispatch({
190
+ type: "SHOW_NOTIFICATION",
191
+ notification: { type: "success", message: `Removed "${skill.name}"` },
192
+ })
193
+ } catch (err) {
194
+ const msg = err instanceof Error ? err.message : String(err)
195
+ dispatch({
196
+ type: "SHOW_NOTIFICATION",
197
+ notification: { type: "error", message: `Remove failed: ${msg}` },
198
+ })
199
+ }
200
+ }, [dispatch])
201
+
202
+ /**
203
+ * Remove a skill from a single agent only (delete its symlink).
204
+ * If this was the last agent, also removes the canonical copy and lock entry.
205
+ */
206
+ const removeSkillFromOneAgent = useCallback(async (skill: EnrichedSkill, agentName: string) => {
207
+ const agent = agents[agentName]
208
+ if (!agent) {
209
+ dispatch({
210
+ type: "SHOW_NOTIFICATION",
211
+ notification: { type: "error", message: `Unknown agent: ${agentName}` },
212
+ })
213
+ return
214
+ }
215
+
216
+ dispatch({
217
+ type: "SHOW_NOTIFICATION",
218
+ notification: { type: "info", message: `Removing "${skill.name}" from ${agent.displayName}...` },
219
+ })
220
+
221
+ try {
222
+ const safeName = sanitizeName(skill.name)
223
+ await removeSkillFromAgent(safeName, agent, "global")
224
+
225
+ // If this was the last agent, also remove canonical + lock
226
+ const remainingAgents = skill.agents.filter(a => a !== agentName)
227
+ if (remainingAgents.length === 0) {
228
+ await removeCanonicalSkill(skill.name)
229
+ await removeSkillFromLock(safeName)
230
+ }
231
+
232
+ dispatch({ type: "REFRESH_SKILLS" })
233
+ dispatch({
234
+ type: "SHOW_NOTIFICATION",
235
+ notification: {
236
+ type: "success",
237
+ message: remainingAgents.length > 0
238
+ ? `Removed "${skill.name}" from ${agent.displayName}`
239
+ : `Removed "${skill.name}" completely`,
240
+ },
241
+ })
242
+ } catch (err) {
243
+ const msg = err instanceof Error ? err.message : String(err)
244
+ dispatch({
245
+ type: "SHOW_NOTIFICATION",
246
+ notification: { type: "error", message: `Remove failed: ${msg}` },
247
+ })
248
+ }
249
+ }, [dispatch])
250
+
251
+ /**
252
+ * Update a skill by re-fetching from its source.
253
+ * For GitHub skills: checks tree SHA for changes before re-installing.
254
+ * For SkillsGate skills: always re-downloads.
255
+ */
256
+ const updateSkill = useCallback(async (skill: EnrichedSkill) => {
257
+ if (!skill.lock) {
258
+ dispatch({
259
+ type: "SHOW_NOTIFICATION",
260
+ notification: { type: "error", message: `No source information for "${skill.name}"` },
261
+ })
262
+ return
263
+ }
264
+
265
+ dispatch({
266
+ type: "SHOW_NOTIFICATION",
267
+ notification: { type: "info", message: `Checking for updates to "${skill.name}"...` },
268
+ })
269
+
270
+ try {
271
+ const source = parseSource(skill.lock.source)
272
+
273
+ if (source.type === "github") {
274
+ // Check if the tree SHA has changed
275
+ const newSha = await fetchTreeSha(source.owner, source.repo, "")
276
+ if (newSha && newSha === skill.lock.skillFolderHash) {
277
+ dispatch({
278
+ type: "SHOW_NOTIFICATION",
279
+ notification: { type: "info", message: `"${skill.name}" is already up to date` },
280
+ })
281
+ return
282
+ }
283
+ }
284
+
285
+ // Re-install (reuses the install flow)
286
+ await installSkill(skill)
287
+
288
+ dispatch({
289
+ type: "SHOW_NOTIFICATION",
290
+ notification: { type: "success", message: `Updated "${skill.name}"` },
291
+ })
292
+ } catch (err) {
293
+ const msg = err instanceof Error ? err.message : String(err)
294
+ dispatch({
295
+ type: "SHOW_NOTIFICATION",
296
+ notification: { type: "error", message: `Update failed: ${msg}` },
297
+ })
298
+ }
299
+ }, [dispatch, installSkill])
300
+
301
+ return { installSkill, removeSkill, removeSkillFromOneAgent, updateSkill }
302
+ }
303
+
304
+ // ---------- Helpers ----------
305
+
306
+ /**
307
+ * Resolves the source string for a skill from its metadata.
308
+ * Checks: lock.source, metadata.githubUrl, metadata.installCommand
309
+ */
310
+ function resolveSource(skill: EnrichedSkill): string | null {
311
+ // From lock file entry
312
+ if (skill.lock?.source) {
313
+ return skill.lock.source
314
+ }
315
+
316
+ // From metadata (catalog skills)
317
+ const meta = skill.metadata
318
+ if (meta?.githubUrl && typeof meta.githubUrl === "string") {
319
+ return meta.githubUrl
320
+ }
321
+
322
+ // From install command (e.g. "skillsgate add @user/slug")
323
+ if (meta?.installCommand && typeof meta.installCommand === "string") {
324
+ const cmd = meta.installCommand as string
325
+ // Extract the source from "skillsgate add <source>" or "skillsgate install <source>"
326
+ const match = cmd.match(/skillsgate\s+(?:add|install)\s+(.+)/)
327
+ if (match) {
328
+ return match[1].trim()
329
+ }
330
+ }
331
+
332
+ return null
333
+ }
@@ -0,0 +1,38 @@
1
+ import { createContext, useContext, type ReactNode } from "react"
2
+ import type { Database } from "bun:sqlite"
3
+ import { SettingsStore } from "./settings.js"
4
+ import { RemoteServerStore } from "./servers.js"
5
+ import { RemoteSkillStore } from "./skills.js"
6
+
7
+ export interface DbContext {
8
+ db: Database
9
+ settings: SettingsStore
10
+ servers: RemoteServerStore
11
+ skills: RemoteSkillStore
12
+ }
13
+
14
+ const DbCtx = createContext<DbContext | null>(null)
15
+
16
+ interface DbProviderProps {
17
+ db: Database
18
+ children: ReactNode
19
+ }
20
+
21
+ export function DbProvider({ db, children }: DbProviderProps) {
22
+ const ctx: DbContext = {
23
+ db,
24
+ settings: new SettingsStore(db),
25
+ servers: new RemoteServerStore(db),
26
+ skills: new RemoteSkillStore(db),
27
+ }
28
+
29
+ return <DbCtx.Provider value={ctx}>{children}</DbCtx.Provider>
30
+ }
31
+
32
+ export function useDb(): DbContext {
33
+ const ctx = useContext(DbCtx)
34
+ if (!ctx) {
35
+ throw new Error("useDb must be used within a DbProvider")
36
+ }
37
+ return ctx
38
+ }
@@ -0,0 +1,19 @@
1
+ import { Database } from "bun:sqlite"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import fs from "node:fs"
5
+ import { runMigrations } from "./migrations.js"
6
+
7
+ const DB_DIR = path.join(os.homedir(), ".skillsgate")
8
+ const DB_PATH = path.join(DB_DIR, "skillsgate.db")
9
+
10
+ export function openDb(): Database {
11
+ fs.mkdirSync(DB_DIR, { recursive: true })
12
+ const db = new Database(DB_PATH)
13
+ db.exec("PRAGMA journal_mode=WAL")
14
+ db.exec("PRAGMA foreign_keys=ON")
15
+ runMigrations(db)
16
+ return db
17
+ }
18
+
19
+ export type { Database } from "bun:sqlite"
@@ -0,0 +1,72 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ interface Migration {
4
+ version: number
5
+ up: string
6
+ }
7
+
8
+ const MIGRATIONS: Migration[] = [
9
+ {
10
+ version: 1,
11
+ up: `
12
+ CREATE TABLE IF NOT EXISTS schema_version (
13
+ version INTEGER PRIMARY KEY
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS settings (
17
+ key TEXT PRIMARY KEY,
18
+ value TEXT NOT NULL,
19
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS remote_servers (
23
+ id TEXT PRIMARY KEY,
24
+ label TEXT NOT NULL,
25
+ host TEXT NOT NULL,
26
+ port INTEGER NOT NULL DEFAULT 22,
27
+ username TEXT NOT NULL,
28
+ skills_base_path TEXT NOT NULL DEFAULT '~/.agents/skills',
29
+ ssh_key_path TEXT,
30
+ last_sync_at TEXT,
31
+ last_sync_error TEXT,
32
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
33
+ UNIQUE(host, port, username)
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS remote_skills (
37
+ id TEXT PRIMARY KEY,
38
+ server_id TEXT NOT NULL REFERENCES remote_servers(id) ON DELETE CASCADE,
39
+ name TEXT NOT NULL,
40
+ description TEXT,
41
+ remote_path TEXT NOT NULL,
42
+ content TEXT,
43
+ content_hash TEXT,
44
+ synced_at TEXT NOT NULL DEFAULT (datetime('now')),
45
+ UNIQUE(server_id, remote_path)
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_remote_skills_server ON remote_skills(server_id);
49
+
50
+ INSERT OR IGNORE INTO schema_version VALUES (1);
51
+ `,
52
+ },
53
+ ]
54
+
55
+ function getCurrentVersion(db: Database): number {
56
+ try {
57
+ const row = db.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number | null } | null
58
+ return row?.v ?? 0
59
+ } catch {
60
+ // Table doesn't exist yet
61
+ return 0
62
+ }
63
+ }
64
+
65
+ export function runMigrations(db: Database): void {
66
+ const current = getCurrentVersion(db)
67
+ for (const migration of MIGRATIONS) {
68
+ if (migration.version > current) {
69
+ db.exec(migration.up)
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,154 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import crypto from "node:crypto"
3
+
4
+ export interface RemoteServer {
5
+ id: string
6
+ label: string
7
+ host: string
8
+ port: number
9
+ username: string
10
+ skillsBasePath: string
11
+ sshKeyPath: string | null
12
+ lastSyncAt: string | null
13
+ lastSyncError: string | null
14
+ createdAt: string
15
+ }
16
+
17
+ interface ServerRow {
18
+ id: string
19
+ label: string
20
+ host: string
21
+ port: number
22
+ username: string
23
+ skills_base_path: string
24
+ ssh_key_path: string | null
25
+ last_sync_at: string | null
26
+ last_sync_error: string | null
27
+ created_at: string
28
+ }
29
+
30
+ function rowToServer(row: ServerRow): RemoteServer {
31
+ return {
32
+ id: row.id,
33
+ label: row.label,
34
+ host: row.host,
35
+ port: row.port,
36
+ username: row.username,
37
+ skillsBasePath: row.skills_base_path,
38
+ sshKeyPath: row.ssh_key_path,
39
+ lastSyncAt: row.last_sync_at,
40
+ lastSyncError: row.last_sync_error,
41
+ createdAt: row.created_at,
42
+ }
43
+ }
44
+
45
+ export type CreateServerInput = {
46
+ label: string
47
+ host: string
48
+ port: number
49
+ username: string
50
+ skillsBasePath: string
51
+ sshKeyPath?: string | null
52
+ }
53
+
54
+ export class RemoteServerStore {
55
+ private db: Database
56
+
57
+ constructor(db: Database) {
58
+ this.db = db
59
+ }
60
+
61
+ list(): RemoteServer[] {
62
+ const rows = this.db
63
+ .query("SELECT * FROM remote_servers ORDER BY label ASC")
64
+ .all() as ServerRow[]
65
+ return rows.map(rowToServer)
66
+ }
67
+
68
+ get(id: string): RemoteServer | null {
69
+ const row = this.db
70
+ .query("SELECT * FROM remote_servers WHERE id = ?")
71
+ .get(id) as ServerRow | null
72
+ return row ? rowToServer(row) : null
73
+ }
74
+
75
+ create(input: CreateServerInput): RemoteServer {
76
+ const id = crypto.randomUUID()
77
+ this.db
78
+ .query(
79
+ `INSERT INTO remote_servers (id, label, host, port, username, skills_base_path, ssh_key_path)
80
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
81
+ )
82
+ .run(
83
+ id,
84
+ input.label,
85
+ input.host,
86
+ input.port,
87
+ input.username,
88
+ input.skillsBasePath,
89
+ input.sshKeyPath ?? null,
90
+ )
91
+ return this.get(id)!
92
+ }
93
+
94
+ update(id: string, fields: Partial<CreateServerInput>): void {
95
+ const sets: string[] = []
96
+ const params: unknown[] = []
97
+
98
+ if (fields.label !== undefined) {
99
+ sets.push("label = ?")
100
+ params.push(fields.label)
101
+ }
102
+ if (fields.host !== undefined) {
103
+ sets.push("host = ?")
104
+ params.push(fields.host)
105
+ }
106
+ if (fields.port !== undefined) {
107
+ sets.push("port = ?")
108
+ params.push(fields.port)
109
+ }
110
+ if (fields.username !== undefined) {
111
+ sets.push("username = ?")
112
+ params.push(fields.username)
113
+ }
114
+ if (fields.skillsBasePath !== undefined) {
115
+ sets.push("skills_base_path = ?")
116
+ params.push(fields.skillsBasePath)
117
+ }
118
+ if (fields.sshKeyPath !== undefined) {
119
+ sets.push("ssh_key_path = ?")
120
+ params.push(fields.sshKeyPath)
121
+ }
122
+
123
+ if (sets.length === 0) return
124
+
125
+ params.push(id)
126
+ this.db.query(`UPDATE remote_servers SET ${sets.join(", ")} WHERE id = ?`).run(...params)
127
+ }
128
+
129
+ delete(id: string): void {
130
+ this.db.query("DELETE FROM remote_servers WHERE id = ?").run(id)
131
+ }
132
+
133
+ updateSyncStatus(id: string, error: string | null): void {
134
+ if (error) {
135
+ this.db
136
+ .query("UPDATE remote_servers SET last_sync_error = ? WHERE id = ?")
137
+ .run(error, id)
138
+ } else {
139
+ this.db
140
+ .query(
141
+ "UPDATE remote_servers SET last_sync_at = datetime('now'), last_sync_error = NULL WHERE id = ?"
142
+ )
143
+ .run(id)
144
+ }
145
+ }
146
+
147
+ /** Returns the count of remote skills for a given server. */
148
+ skillCount(serverId: string): number {
149
+ const row = this.db
150
+ .query("SELECT COUNT(*) as cnt FROM remote_skills WHERE server_id = ?")
151
+ .get(serverId) as { cnt: number } | null
152
+ return row?.cnt ?? 0
153
+ }
154
+ }
@@ -0,0 +1,43 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ export class SettingsStore {
4
+ private db: Database
5
+
6
+ constructor(db: Database) {
7
+ this.db = db
8
+ }
9
+
10
+ get<T>(key: string, defaultValue: T): T {
11
+ const row = this.db.query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null
12
+ if (!row) return defaultValue
13
+ try {
14
+ return JSON.parse(row.value) as T
15
+ } catch {
16
+ return defaultValue
17
+ }
18
+ }
19
+
20
+ set<T>(key: string, value: T): void {
21
+ this.db
22
+ .query(
23
+ `INSERT INTO settings (key, value, updated_at)
24
+ VALUES (?, ?, datetime('now'))
25
+ ON CONFLICT(key)
26
+ DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
27
+ )
28
+ .run(key, JSON.stringify(value))
29
+ }
30
+
31
+ getAll(): Record<string, unknown> {
32
+ const rows = this.db.query("SELECT key, value FROM settings").all() as { key: string; value: string }[]
33
+ const result: Record<string, unknown> = {}
34
+ for (const row of rows) {
35
+ try {
36
+ result[row.key] = JSON.parse(row.value)
37
+ } catch {
38
+ result[row.key] = row.value
39
+ }
40
+ }
41
+ return result
42
+ }
43
+ }