zugzbot 1.1.6 → 1.2.0

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.
@@ -0,0 +1,596 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+ import crypto from "crypto"
5
+
6
+ // ============================================================================
7
+ // Akash3444 Catalog MCP - Shadcn UI Blocks + Basecn
8
+ // ============================================================================
9
+ //
10
+ // Indexa localmente los catalogos externos de Akash (akash3444):
11
+ // - shadcn-ui-blocks: https://shadcnui-blocks.com (Radix-based)
12
+ // - basecn: https://basecn.dev (Base UI-based, fork)
13
+ //
14
+ // Para evitar redescubrimiento por webfetch en cada sesion, cacheamos:
15
+ // - .openspec/cache/akash-index.json (listado maestro, refresco cada 7d)
16
+ // - .openspec/cache/basecn-index.json (idem para basecn)
17
+ // - .openspec/cache/blocks/<registry>/<name>.json (fuente individual por bloque)
18
+ //
19
+ // TTL configurable via AKASH_INDEX_TTL_DAYS env (default 7).
20
+ // ============================================================================
21
+
22
+ const AKASH_REPO = "akash3444/shadcn-ui-blocks"
23
+ const BASECN_REPO = "akash3444/basecn"
24
+
25
+ const AKASH_API = `https://api.github.com/repos/${AKASH_REPO}/contents/public/r/radix`
26
+ const BASECN_API = `https://api.github.com/repos/${BASECN_REPO}/contents/public/r/basecn`
27
+
28
+ const AKASH_RAW = (name: string) =>
29
+ `https://raw.githubusercontent.com/${AKASH_REPO}/main/public/r/radix/${name}.json`
30
+ const BASECN_RAW = (name: string) =>
31
+ `https://raw.githubusercontent.com/${BASECN_REPO}/main/public/r/basecn/${name}.json`
32
+
33
+ const AKASH_INSTALL = (name: string) =>
34
+ `https://shadcnui-blocks.com/r/radix/${name}.json`
35
+ const BASECN_INSTALL = (name: string) =>
36
+ `https://basecn.dev/r/basecn/${name}.json`
37
+
38
+ const DEFAULT_TTL_DAYS = Number(process.env.AKASH_INDEX_TTL_DAYS || 7)
39
+ const TTL_MS = DEFAULT_TTL_DAYS * 24 * 60 * 60 * 1000
40
+
41
+ // Prefijos conocidos para extraer categoria del nombre de archivo
42
+ const CATEGORY_PREFIXES = [
43
+ "hero", "footer", "pricing", "features", "faq", "testimonials",
44
+ "cta", "navbar", "stats", "logo-cloud", "blog", "contact",
45
+ "team", "about", "gallery", "login", "signup", "forgot-password",
46
+ "otp", "chart", "sidebar", "dashboard", "table", "calendar",
47
+ "accordion", "alert", "avatar", "badge", "button", "card", "checkbox",
48
+ "combobox", "command", "date-picker", "dialog", "drawer", "dropdown-menu",
49
+ "input", "kbd", "menubar", "pagination", "popover", "progress",
50
+ "radio-group", "scroll-area", "select", "separator", "sheet", "skeleton",
51
+ "slider", "sonner", "switch", "tabs", "textarea", "toast", "toggle",
52
+ "tooltip", "carousel", "marquee", "bento", "integration", "comparison",
53
+ "how-it-works", "use-cases", "changelog", "cookie-banner", "newsletter"
54
+ ]
55
+
56
+ // ============================================================================
57
+ // Helpers
58
+ // ============================================================================
59
+
60
+ const getRoot = (context: any): string => {
61
+ if (context?.directory && context.directory !== "/") return context.directory
62
+ if (context?.worktree && context.worktree !== "/") return context.worktree
63
+ if (context?.cwd && context.cwd !== "/") return context.cwd
64
+ return process.cwd()
65
+ }
66
+
67
+ const getCacheDir = (root: string) => path.resolve(root, ".openspec/cache")
68
+ const getBlocksDir = (root: string, registry: string) =>
69
+ path.resolve(getCacheDir(root), "blocks", registry)
70
+ const getIndexPath = (root: string, registry: string) =>
71
+ path.resolve(getCacheDir(root), `${registry}-index.json`)
72
+
73
+ const ensureDir = (dir: string) => {
74
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
75
+ }
76
+
77
+ const inferCategory = (filename: string): string => {
78
+ const stem = filename.replace(/\.json$/, "")
79
+ for (const prefix of CATEGORY_PREFIXES) {
80
+ if (stem === prefix || stem.startsWith(`${prefix}-`)) return prefix
81
+ }
82
+ return "misc"
83
+ }
84
+
85
+ type IndexEntry = {
86
+ name: string
87
+ filename: string
88
+ category: string
89
+ registry: "shadcn" | "basecn"
90
+ size: number
91
+ sha: string
92
+ raw_url: string
93
+ install_url: string
94
+ }
95
+
96
+ type Index = {
97
+ cached_at: string
98
+ ttl_days: number
99
+ source_repo: string
100
+ total: number
101
+ entries: IndexEntry[]
102
+ }
103
+
104
+ const writeJson = (filePath: string, data: unknown) => {
105
+ ensureDir(path.dirname(filePath))
106
+ const tmp = `${filePath}.tmp`
107
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8")
108
+ fs.renameSync(tmp, filePath)
109
+ }
110
+
111
+ const readJson = <T>(filePath: string): T | null => {
112
+ try {
113
+ if (fs.existsSync(filePath)) {
114
+ return JSON.parse(fs.readFileSync(filePath, "utf8")) as T
115
+ }
116
+ } catch (e) {
117
+ // ignore corrupt cache; will rebuild
118
+ }
119
+ return null
120
+ }
121
+
122
+ const isCacheStale = (index: Index | null): boolean => {
123
+ if (!index) return true
124
+ const cached = new Date(index.cached_at).getTime()
125
+ if (Number.isNaN(cached)) return true
126
+ return Date.now() - cached > TTL_MS
127
+ }
128
+
129
+ // ============================================================================
130
+ // GitHub API fetchers (with simple rate-limit handling)
131
+ // ============================================================================
132
+
133
+ const fetchGithubTree = async (
134
+ apiUrl: string,
135
+ repoLabel: string
136
+ ): Promise<IndexEntry[]> => {
137
+ const res = await fetch(apiUrl, {
138
+ headers: {
139
+ "User-Agent": "zugzbot-sdd-catalog/1.0",
140
+ Accept: "application/vnd.github+json"
141
+ }
142
+ })
143
+
144
+ if (!res.ok) {
145
+ if (res.status === 403) {
146
+ throw new Error(
147
+ `GitHub API rate limit hit consultando ${repoLabel}. ` +
148
+ `Espera 1h o usa force_refresh=false con cache existente.`
149
+ )
150
+ }
151
+ if (res.status === 404) {
152
+ throw new Error(
153
+ `Repositorio ${repoLabel} no encontrado o ruta cambiada. ` +
154
+ `Verifica que ${apiUrl} siga vigente.`
155
+ )
156
+ }
157
+ throw new Error(`GitHub API ${res.status} ${res.statusText} consultando ${repoLabel}`)
158
+ }
159
+
160
+ const arr = (await res.json()) as Array<{
161
+ name: string
162
+ size: number
163
+ sha: string
164
+ download_url: string
165
+ }>
166
+
167
+ const registry: "shadcn" | "basecn" =
168
+ repoLabel.includes("basecn") ? "basecn" : "shadcn"
169
+
170
+ return arr
171
+ .filter((f) => f.name.endsWith(".json") && !f.name.startsWith("_"))
172
+ .map((f) => {
173
+ const filename = f.name
174
+ const name = filename.replace(/\.json$/, "")
175
+ const rawUrl =
176
+ registry === "basecn" ? BASECN_RAW(filename) : AKASH_RAW(filename)
177
+ const installUrl =
178
+ registry === "basecn"
179
+ ? BASECN_INSTALL(filename)
180
+ : AKASH_INSTALL(filename)
181
+
182
+ return {
183
+ name,
184
+ filename,
185
+ category: inferCategory(filename),
186
+ registry,
187
+ size: f.size,
188
+ sha: f.sha,
189
+ raw_url: rawUrl,
190
+ install_url: installUrl
191
+ }
192
+ })
193
+ }
194
+
195
+ const buildOrLoadIndex = async (
196
+ root: string,
197
+ registry: "shadcn" | "basecn",
198
+ forceRefresh: boolean
199
+ ): Promise<{ index: Index; refreshed: boolean; error?: string }> => {
200
+ const indexPath = getIndexPath(root, registry)
201
+ const apiUrl = registry === "basecn" ? BASECN_API : AKASH_API
202
+ const repoLabel = registry === "basecn" ? BASECN_REPO : AKASH_REPO
203
+
204
+ const cached = readJson<Index>(indexPath)
205
+ if (!forceRefresh && !isCacheStale(cached)) {
206
+ return { index: cached!, refreshed: false }
207
+ }
208
+
209
+ try {
210
+ const entries = await fetchGithubTree(apiUrl, repoLabel)
211
+ const index: Index = {
212
+ cached_at: new Date().toISOString(),
213
+ ttl_days: DEFAULT_TTL_DAYS,
214
+ source_repo: repoLabel,
215
+ total: entries.length,
216
+ entries
217
+ }
218
+ writeJson(indexPath, index)
219
+ return { index, refreshed: true }
220
+ } catch (e) {
221
+ if (cached) {
222
+ return {
223
+ index: cached,
224
+ refreshed: false,
225
+ error: `Refresh fallo (${(e as Error).message}); usando cache de ${cached.cached_at}`
226
+ }
227
+ }
228
+ throw e
229
+ }
230
+ }
231
+
232
+ // ============================================================================
233
+ // Tool: list_blocks
234
+ // ============================================================================
235
+
236
+ type ListResult = {
237
+ status: "SUCCESS" | "ERROR"
238
+ message: string
239
+ registries_queried: Array<"shadcn" | "basecn">
240
+ cached_at?: Record<string, string>
241
+ total?: number
242
+ filtered?: number
243
+ entries?: IndexEntry[]
244
+ errors?: string[]
245
+ }
246
+
247
+ export const list_blocks = tool({
248
+ description:
249
+ "Lista bloques del catalogo externo de Akash (shadcn-ui-blocks y/o basecn). " +
250
+ "Cachea el indice localmente en .openspec/cache/ y lo refresca cada 7 dias (configurable con AKASH_INDEX_TTL_DAYS). " +
251
+ "Filtra por categoria (hero, footer, pricing, faq, dashboard, etc.) y por registry.",
252
+ args: {
253
+ category: tool.schema
254
+ .string()
255
+ .optional()
256
+ .describe(
257
+ "Filtra por categoria exacta (hero, footer, pricing, faq, dashboard, etc). Opcional."
258
+ ),
259
+ query: tool.schema
260
+ .string()
261
+ .optional()
262
+ .describe(
263
+ "Busqueda libre por substring sobre name o filename (case-insensitive). Opcional."
264
+ ),
265
+ registry: tool.schema
266
+ .enum(["shadcn", "basecn", "both"])
267
+ .optional()
268
+ .default("both")
269
+ .describe(
270
+ "Que catalogo consultar: 'shadcn' (Radix), 'basecn' (Base UI) o 'both' (default)."
271
+ ),
272
+ limit: tool.schema
273
+ .number()
274
+ .optional()
275
+ .default(50)
276
+ .describe("Maximo de entradas a devolver (default 50, max 500)."),
277
+ force_refresh: tool.schema
278
+ .boolean()
279
+ .optional()
280
+ .default(false)
281
+ .describe(
282
+ "Si true, ignora el TTL y re-descarga el arbol completo de GitHub API."
283
+ )
284
+ },
285
+ async execute(args, context) {
286
+ const root = getRoot(context)
287
+ const registries: Array<"shadcn" | "basecn"> =
288
+ args.registry === "both" || !args.registry
289
+ ? ["shadcn", "basecn"]
290
+ : [args.registry]
291
+
292
+ const allEntries: IndexEntry[] = []
293
+ const cachedAt: Record<string, string> = {}
294
+ const errors: string[] = []
295
+
296
+ for (const reg of registries) {
297
+ try {
298
+ const { index, refreshed, error } = await buildOrLoadIndex(
299
+ root,
300
+ reg,
301
+ args.force_refresh || false
302
+ )
303
+ cachedAt[reg] = index.cached_at
304
+ if (refreshed) {
305
+ // noop; success already
306
+ }
307
+ if (error) errors.push(`[${reg}] ${error}`)
308
+ allEntries.push(...index.entries)
309
+ } catch (e) {
310
+ errors.push(`[${reg}] ${(e as Error).message}`)
311
+ }
312
+ }
313
+
314
+ let filtered = allEntries
315
+ if (args.category) {
316
+ const cat = args.category.toLowerCase()
317
+ filtered = filtered.filter(
318
+ (e) => e.category === cat || e.name.toLowerCase().startsWith(cat + "-")
319
+ )
320
+ }
321
+ if (args.query) {
322
+ const q = args.query.toLowerCase()
323
+ filtered = filtered.filter(
324
+ (e) =>
325
+ e.name.toLowerCase().includes(q) ||
326
+ e.filename.toLowerCase().includes(q)
327
+ )
328
+ }
329
+
330
+ const limit = Math.min(args.limit || 50, 500)
331
+ filtered = filtered.slice(0, limit)
332
+
333
+ const result: ListResult = {
334
+ status: errors.length && allEntries.length === 0 ? "ERROR" : "SUCCESS",
335
+ message:
336
+ errors.length && allEntries.length === 0
337
+ ? `No se pudo cargar el catalogo. Errores: ${errors.join("; ")}`
338
+ : `Se encontraron ${filtered.length} bloques (total sin filtro: ${allEntries.length}).`,
339
+ registries_queried: registries,
340
+ cached_at: cachedAt,
341
+ total: allEntries.length,
342
+ filtered: filtered.length,
343
+ entries: filtered
344
+ }
345
+ if (errors.length) result.errors = errors
346
+ return JSON.stringify(result, null, 2)
347
+ }
348
+ })
349
+
350
+ // ============================================================================
351
+ // Tool: get_block
352
+ // ============================================================================
353
+
354
+ type GetResult = {
355
+ status: "SUCCESS" | "ERROR"
356
+ message: string
357
+ block: {
358
+ name: string
359
+ registry: "shadcn" | "basecn"
360
+ category: string
361
+ install_url: string
362
+ install_command: string
363
+ cached_source: boolean
364
+ cached_at: string
365
+ source_files: Array<{
366
+ path: string
367
+ type: string
368
+ target?: string
369
+ content?: string
370
+ }>
371
+ dependencies?: string[]
372
+ notes?: string
373
+ preview_url?: string
374
+ } | null
375
+ }
376
+
377
+ const fetchRaw = async (url: string): Promise<string> => {
378
+ const res = await fetch(url, {
379
+ headers: { "User-Agent": "zugzbot-sdd-catalog/1.0" }
380
+ })
381
+ if (!res.ok) {
382
+ throw new Error(`Fetch ${res.status} ${res.statusText} -> ${url}`)
383
+ }
384
+ return await res.text()
385
+ }
386
+
387
+ const tryParseBlockJson = (raw: string): any => {
388
+ // Algunos bloques Akash envuelven el JSON en JSON.stringify(...) dentro
389
+ // de un .json "doblemente serializado" para shadcn CLI. Manejamos ambos.
390
+ const trimmed = raw.trim()
391
+ try {
392
+ return JSON.parse(trimmed)
393
+ } catch {
394
+ // posible doble encoding
395
+ try {
396
+ return JSON.parse(JSON.parse(trimmed))
397
+ } catch {
398
+ throw new Error(`No se pudo parsear JSON del bloque`)
399
+ }
400
+ }
401
+ }
402
+
403
+ const normalizeBlock = (parsed: any, registry: "shadcn" | "basecn", name: string) => {
404
+ const files: Array<{
405
+ path: string
406
+ type: string
407
+ target?: string
408
+ content?: string
409
+ }> = []
410
+
411
+ const deps: string[] = []
412
+ const installUrl =
413
+ registry === "basecn" ? BASECN_INSTALL(name) : AKASH_INSTALL(name)
414
+
415
+ // Variantes comunes del schema JSON de Akash
416
+ if (Array.isArray(parsed?.files)) {
417
+ for (const f of parsed.files) {
418
+ files.push({
419
+ path: f.path || f.name || "unknown",
420
+ type: f.type || "unknown",
421
+ target: f.target,
422
+ content: f.content || f.code
423
+ })
424
+ }
425
+ }
426
+ if (Array.isArray(parsed?.dependencies)) deps.push(...parsed.dependencies)
427
+ if (Array.isArray(parsed?.deps)) deps.push(...parsed.deps)
428
+ if (parsed?.registryDependencies && typeof parsed.registryDependencies === "object") {
429
+ for (const [k, v] of Object.entries(parsed.registryDependencies)) {
430
+ deps.push(`${k}: ${v}`)
431
+ }
432
+ }
433
+
434
+ return { files, dependencies: deps, installUrl }
435
+ }
436
+
437
+ export const get_block = tool({
438
+ description:
439
+ "Obtiene el codigo fuente, archivos y dependencias de un bloque especifico " +
440
+ "del catalogo de Akash (shadcn-ui-blocks o basecn). " +
441
+ "Cachea la fuente localmente en .openspec/cache/blocks/<registry>/<name>.json. " +
442
+ "Devuelve ademas el comando `npx shadcn add` listo para ejecutar.",
443
+ args: {
444
+ name: tool.schema
445
+ .string()
446
+ .describe(
447
+ "Nombre del bloque SIN .json (ej: 'hero-06', 'login-01', 'sidebar-07')."
448
+ ),
449
+ registry: tool.schema
450
+ .enum(["shadcn", "basecn"])
451
+ .optional()
452
+ .default("shadcn")
453
+ .describe("Catalogo a consultar. Default: 'shadcn'."),
454
+ force_refresh: tool.schema
455
+ .boolean()
456
+ .optional()
457
+ .default(false)
458
+ .describe("Si true, re-descarga la fuente del bloque aunque exista cache.")
459
+ },
460
+ async execute(args, context) {
461
+ const root = getRoot(context)
462
+ const reg = args.registry || "shadcn"
463
+ const filename = `${args.name}.json`
464
+ const cachePath = path.resolve(getBlocksDir(root, reg), filename)
465
+ const rawUrl = reg === "basecn" ? BASECN_RAW(filename) : AKASH_RAW(filename)
466
+ const installUrl = reg === "basecn"
467
+ ? BASECN_INSTALL(filename)
468
+ : AKASH_INSTALL(filename)
469
+
470
+ let rawText: string
471
+ let cachedSource = false
472
+ if (!args.force_refresh && fs.existsSync(cachePath)) {
473
+ rawText = fs.readFileSync(cachePath, "utf8")
474
+ cachedSource = true
475
+ } else {
476
+ try {
477
+ rawText = await fetchRaw(rawUrl)
478
+ ensureDir(path.dirname(cachePath))
479
+ fs.writeFileSync(cachePath, rawText, "utf8")
480
+ } catch (e) {
481
+ return JSON.stringify(
482
+ {
483
+ status: "ERROR",
484
+ message: `No se pudo descargar ${rawUrl}: ${(e as Error).message}`,
485
+ block: null
486
+ } satisfies GetResult,
487
+ null,
488
+ 2
489
+ )
490
+ }
491
+ }
492
+
493
+ let parsed: any
494
+ try {
495
+ parsed = tryParseBlockJson(rawText)
496
+ } catch (e) {
497
+ return JSON.stringify(
498
+ {
499
+ status: "ERROR",
500
+ message: `Bloque ${args.name} descargado pero no parseable: ${(e as Error).message}`,
501
+ block: null
502
+ } satisfies GetResult,
503
+ null,
504
+ 2
505
+ )
506
+ }
507
+
508
+ const { files, dependencies, installUrl: install } = normalizeBlock(
509
+ parsed,
510
+ reg,
511
+ args.name
512
+ )
513
+
514
+ const previewUrl =
515
+ reg === "basecn"
516
+ ? `https://basecn.dev/blocks/${args.name}`
517
+ : `https://shadcnui-blocks.com/blocks/${args.name}`
518
+
519
+ const installCommand = `npx shadcn@latest add ${install} --yes`
520
+
521
+ const result: GetResult = {
522
+ status: "SUCCESS",
523
+ message: `Bloque '${args.name}' (${reg}) listo. ${files.length} archivo(s), ${dependencies.length} dependencia(s).`,
524
+ block: {
525
+ name: args.name,
526
+ registry: reg,
527
+ category: inferCategory(filename),
528
+ install_url: install,
529
+ install_command: installCommand,
530
+ cached_source: cachedSource,
531
+ cached_at: new Date().toISOString(),
532
+ source_files: files,
533
+ dependencies,
534
+ notes:
535
+ reg === "basecn"
536
+ ? "Bloque Base UI (fork). Verifica que tu proyecto tenga Base UI instalado."
537
+ : "Bloque Radix/shadcn-ui-blocks. Compatible con el stack nextjs-shadcn.",
538
+ preview_url: previewUrl
539
+ }
540
+ }
541
+ return JSON.stringify(result, null, 2)
542
+ }
543
+ })
544
+
545
+ // ============================================================================
546
+ // Tool: warm_index (utilidad para bootstrap)
547
+ // ============================================================================
548
+
549
+ type WarmResult = {
550
+ status: "SUCCESS" | "ERROR"
551
+ message: string
552
+ warmed: string[]
553
+ errors: string[]
554
+ }
555
+
556
+ export const warm_index = tool({
557
+ description:
558
+ "Pre-calienta (descarga) los indices de Akash y/o Basecn para evitar la latencia " +
559
+ "del primer list_blocks. Ideal de llamar al bootstrap del proyecto o al inicio de F0. " +
560
+ "Idempotente: si el cache existe y es fresco (<7d), no re-descarga.",
561
+ args: {
562
+ registry: tool.schema
563
+ .enum(["shadcn", "basecn", "both"])
564
+ .optional()
565
+ .default("both")
566
+ .describe("Que catalogos pre-calentar.")
567
+ },
568
+ async execute(args, context) {
569
+ const root = getRoot(context)
570
+ const registries: Array<"shadcn" | "basecn"> =
571
+ args.registry === "both" || !args.registry
572
+ ? ["shadcn", "basecn"]
573
+ : [args.registry]
574
+
575
+ const warmed: string[] = []
576
+ const errors: string[] = []
577
+
578
+ for (const reg of registries) {
579
+ try {
580
+ const { refreshed, index } = await buildOrLoadIndex(root, reg, false)
581
+ if (refreshed) warmed.push(`${reg} (re-descargado, ${index.total} bloques)`)
582
+ else warmed.push(`${reg} (cache fresco de ${index.cached_at})`)
583
+ } catch (e) {
584
+ errors.push(`[${reg}] ${(e as Error).message}`)
585
+ }
586
+ }
587
+
588
+ const result: WarmResult = {
589
+ status: errors.length && warmed.length === 0 ? "ERROR" : "SUCCESS",
590
+ message: `Indices: ${warmed.length} listos, ${errors.length} errores.`,
591
+ warmed,
592
+ errors
593
+ }
594
+ return JSON.stringify(result, null, 2)
595
+ }
596
+ })