zugzbot 1.1.7 → 1.3.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.
- package/.opencode/agents/sdd-orchestrator.md +19 -0
- package/.opencode/rules/sdd-global.md +8 -0
- package/.opencode/scripts/lint-charts.js +194 -0
- package/.opencode/skills/sdd-quickstart/SKILL.md +35 -0
- package/.opencode/skills/shadcn-templates/SKILL.md +101 -17
- package/.opencode/templates/nextjs-shadcn/components.json +4 -1
- package/.opencode/tools/sdd_bootstrap.ts +21 -0
- package/.opencode/tools/sdd_catalog.ts +1040 -0
- package/.opencode/tools/sdd_network.ts +206 -41
- package/.opencode/tools/sdd_testing.ts +129 -4
- package/.utils/export_opencode_session.py +1 -1
- package/opencode.json +2 -1
- package/package.json +2 -1
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// SDD Catalog Tool - Multi-Registry shadcn Block/Component Browser
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// Catalogo unificado de bloques y componentes para shadcn UI. Indexa y
|
|
10
|
+
// resuelve items de multiples registries externos con cache local:
|
|
11
|
+
//
|
|
12
|
+
// - shadcn: https://shadcnui-blocks.com (akash3444/shadcn-ui-blocks)
|
|
13
|
+
// Componentes y bloques Radix-curados por Akash (TTL 7d).
|
|
14
|
+
// Fuente: GitHub Contents API sobre /public/r/radix.
|
|
15
|
+
// - basecn: https://basecn.dev (akash3444/basecn)
|
|
16
|
+
// Fork Base UI de los mismos bloques (TTL 7d).
|
|
17
|
+
// Fuente: GitHub Contents API sobre /public/r/basecn.
|
|
18
|
+
// - reactbits: https://reactbits.dev (David Haz)
|
|
19
|
+
// 134 primitivas animadas x 4 variantes (JS-CSS / JS-TW /
|
|
20
|
+
// TS-CSS / TS-TW) = 536 items. Fuente: /r/registry.json
|
|
21
|
+
// oficial shadcn-compatible. Default variant para el stack
|
|
22
|
+
// Next.js 16 + Tailwind v4 es TS-TW. TTL 1d.
|
|
23
|
+
//
|
|
24
|
+
// Cache local:
|
|
25
|
+
// - .openspec/cache/shadcn-index.json
|
|
26
|
+
// - .openspec/cache/basecn-index.json
|
|
27
|
+
// - .openspec/cache/reactbits-index.json
|
|
28
|
+
// - .openspec/cache/blocks/<registry>/[<author>/]<name>.json
|
|
29
|
+
//
|
|
30
|
+
// Registries configurables via env:
|
|
31
|
+
// - SDD_CATALOG_TTL_DAYS_AKASH (default 7)
|
|
32
|
+
// - SDD_CATALOG_TTL_DAYS_BASECN (default 7)
|
|
33
|
+
// - SDD_CATALOG_TTL_DAYS_REACTBITS (default 1)
|
|
34
|
+
//
|
|
35
|
+
// Tools exportadas (prefijo opencode = "sdd_catalog_<export>"):
|
|
36
|
+
// - list_blocks Lista componentes/bloques por registry, categoria, query.
|
|
37
|
+
// - get_block Obtiene codigo fuente + dependencias + install_command.
|
|
38
|
+
// - warm_index Pre-calienta los indices locales (idempotente, TTL-aware).
|
|
39
|
+
//
|
|
40
|
+
// Naming del parametro `name` para get_block:
|
|
41
|
+
// - shadcn/basecn: "hero-06", "sidebar-07", etc.
|
|
42
|
+
// - reactbits: "Dither" (base, resuelve a Dither-TS-TW por default)
|
|
43
|
+
// "Dither-JS-CSS" (variante canonica completa)
|
|
44
|
+
// "https://reactbits.dev/r/Dither-TS-TW.json" (URL directa)
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Akash (shadcn-ui-blocks)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
const AKASH_REPO = "akash3444/shadcn-ui-blocks"
|
|
51
|
+
const AKASH_API = `https://api.github.com/repos/${AKASH_REPO}/contents/public/r/radix`
|
|
52
|
+
const AKASH_RAW = (name: string) =>
|
|
53
|
+
`https://raw.githubusercontent.com/${AKASH_REPO}/main/public/r/radix/${name}.json`
|
|
54
|
+
const AKASH_INSTALL = (name: string) =>
|
|
55
|
+
`https://shadcnui-blocks.com/r/radix/${name}.json`
|
|
56
|
+
const AKASH_TTL_DAYS = Number(process.env.SDD_CATALOG_TTL_DAYS_AKASH || 7)
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Basecn (akash3444/basecn)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
const BASECN_REPO = "akash3444/basecn"
|
|
62
|
+
const BASECN_API = `https://api.github.com/repos/${BASECN_REPO}/contents/public/r`
|
|
63
|
+
const BASECN_RAW = (name: string) =>
|
|
64
|
+
`https://raw.githubusercontent.com/${BASECN_REPO}/main/public/r/${name}.json`
|
|
65
|
+
const BASECN_INSTALL = (name: string) =>
|
|
66
|
+
`https://basecn.dev/r/${name}.json`
|
|
67
|
+
const BASECN_TTL_DAYS = Number(process.env.SDD_CATALOG_TTL_DAYS_BASECN || 7)
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// reactbits.dev (David Haz)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
const REACTBITS_HOME = "https://reactbits.dev"
|
|
73
|
+
const REACTBITS_CATALOG = "https://reactbits.dev/r/registry.json"
|
|
74
|
+
const REACTBITS_RAW = (name: string) => `${REACTBITS_HOME}/r/${name}.json`
|
|
75
|
+
const REACTBITS_INSTALL = (name: string) => `${REACTBITS_HOME}/r/${name}.json`
|
|
76
|
+
const REACTBITS_PREVIEW = (baseName: string) => `${REACTBITS_HOME}/${baseName}`
|
|
77
|
+
const REACTBITS_TTL_DAYS = Number(process.env.SDD_CATALOG_TTL_DAYS_REACTBITS || 1)
|
|
78
|
+
|
|
79
|
+
// Las 4 variantes que reactbits publica por cada componente base.
|
|
80
|
+
const REACTBITS_VARIANTS = ["JS-CSS", "JS-TW", "TS-CSS", "TS-TW"] as const
|
|
81
|
+
type ReactBitsVariant = (typeof REACTBITS_VARIANTS)[number]
|
|
82
|
+
const DEFAULT_REACTBITS_VARIANT: ReactBitsVariant = "TS-TW"
|
|
83
|
+
|
|
84
|
+
// Sufijos reconocibles al final de un name canónico de reactbits
|
|
85
|
+
const REACTBITS_VARIANT_SUFFIX_RE = /-(JS-CSS|JS-TW|TS-CSS|TS-TW)$/
|
|
86
|
+
|
|
87
|
+
// Mapa curado de las bases mas populares de reactbits.dev organizadas por
|
|
88
|
+
// categoria de uso. La categoria se infiere por membership; si la base no
|
|
89
|
+
// esta en el mapa, cae en "misc" pero el query/substring sigue funcionando.
|
|
90
|
+
const REACTBITS_POPULAR_BY_CATEGORY: Record<string, string[]> = {
|
|
91
|
+
backgrounds: [
|
|
92
|
+
"Aurora", "Beams", "ColorBends", "DarkVeil", "Dither", "FlickeringGrid",
|
|
93
|
+
"GradualBlur", "GridDistortion", "GridMotion", "Hyperspeed", "Iridescence",
|
|
94
|
+
"LightRays", "Lightning", "LiquidChrome", "MetaBalls", "Noise", "Orb",
|
|
95
|
+
"Particles", "PixelSnow", "Silk", "Squares", "StarField", "Strands",
|
|
96
|
+
"Threads", "Vortex", "Waves"
|
|
97
|
+
],
|
|
98
|
+
text: [
|
|
99
|
+
"ASCIIText", "BlurText", "CircularText", "CountUp", "Counter", "CurvedLoop",
|
|
100
|
+
"DecryptedText", "FuzzyText", "GlitchText", "GradientText", "RotatingText",
|
|
101
|
+
"ScrambledText", "ShinyText", "TextPressure", "TextTrail", "Typewriter"
|
|
102
|
+
],
|
|
103
|
+
cards: [
|
|
104
|
+
"BounceCards", "CardSwap", "DecayCard", "DraggableCard", "ExpandingCards",
|
|
105
|
+
"FlipCard", "FluidGlass", "GlareHover", "HoloCard", "MagicBento",
|
|
106
|
+
"Masonry", "SpotlightCard", "Stack", "TiltedCard"
|
|
107
|
+
],
|
|
108
|
+
navigation: [
|
|
109
|
+
"BubbleMenu", "CardNav", "FloatingDock", "FluidMenu", "GooeyNav",
|
|
110
|
+
"MagnetLines", "PillNav", "StaggeredMenu", "Stepper"
|
|
111
|
+
],
|
|
112
|
+
buttons: [
|
|
113
|
+
"BounceButton", "ClickSpark", "MagneticButton", "RainbowButton", "StarBorder"
|
|
114
|
+
],
|
|
115
|
+
animations: [
|
|
116
|
+
"AnimatedContent", "AnimatedList", "AnimatedPin", "FadeContent",
|
|
117
|
+
"ScrollFloat", "ScrollReveal", "TrueFocus", "VariableProximity"
|
|
118
|
+
],
|
|
119
|
+
cursors: [
|
|
120
|
+
"BlobCursor", "Crosshair", "GhostCursor", "ImageTrail",
|
|
121
|
+
"PixelTrail", "SplashCursor", "TextCursor"
|
|
122
|
+
],
|
|
123
|
+
misc: [
|
|
124
|
+
"Antigravity", "Balatro", "Ballpit", "ChromaGrid", "CircularGallery",
|
|
125
|
+
"ElectricBorder", "Magnet", "MetallicPaint", "ShapeBlur", "StickerPeel"
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Construye el mapa inverso: base name -> categoria
|
|
130
|
+
const REACTBITS_CATEGORY_BY_BASE: Map<string, string> = (() => {
|
|
131
|
+
const map = new Map<string, string>()
|
|
132
|
+
for (const [cat, bases] of Object.entries(REACTBITS_POPULAR_BY_CATEGORY)) {
|
|
133
|
+
for (const base of bases) map.set(base, cat)
|
|
134
|
+
}
|
|
135
|
+
return map
|
|
136
|
+
})()
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Categorias (heuristica por nombre de archivo para Akash/Basecn)
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
const CATEGORY_PREFIXES = [
|
|
142
|
+
"hero", "footer", "pricing", "features", "faq", "testimonials",
|
|
143
|
+
"cta", "navbar", "stats", "logo-cloud", "blog", "contact",
|
|
144
|
+
"team", "about", "gallery", "login", "signup", "forgot-password",
|
|
145
|
+
"otp", "chart", "sidebar", "dashboard", "table", "calendar",
|
|
146
|
+
"accordion", "alert", "avatar", "badge", "button", "card", "checkbox",
|
|
147
|
+
"combobox", "command", "date-picker", "dialog", "drawer", "dropdown-menu",
|
|
148
|
+
"input", "kbd", "menubar", "pagination", "popover", "progress",
|
|
149
|
+
"radio-group", "scroll-area", "select", "separator", "sheet", "skeleton",
|
|
150
|
+
"slider", "sonner", "switch", "tabs", "textarea", "toast", "toggle",
|
|
151
|
+
"tooltip", "carousel", "marquee", "bento", "integration", "comparison",
|
|
152
|
+
"how-it-works", "use-cases", "changelog", "cookie-banner", "newsletter"
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Helpers
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
type RegistryName = "shadcn" | "basecn" | "reactbits"
|
|
160
|
+
|
|
161
|
+
const getRoot = (context: any): string => {
|
|
162
|
+
if (context?.directory && context.directory !== "/") return context.directory
|
|
163
|
+
if (context?.worktree && context.worktree !== "/") return context.worktree
|
|
164
|
+
if (context?.cwd && context.directory !== "/") return context.directory
|
|
165
|
+
return process.cwd()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const getCacheDir = (root: string) => path.resolve(root, ".openspec/cache")
|
|
169
|
+
const getBlocksDir = (root: string, registry: RegistryName, subdir?: string) =>
|
|
170
|
+
subdir
|
|
171
|
+
? path.resolve(getCacheDir(root), "blocks", registry, subdir)
|
|
172
|
+
: path.resolve(getCacheDir(root), "blocks", registry)
|
|
173
|
+
const getIndexPath = (root: string, registry: RegistryName) =>
|
|
174
|
+
path.resolve(getCacheDir(root), `${registry}-index.json`)
|
|
175
|
+
|
|
176
|
+
const ensureDir = (dir: string) => {
|
|
177
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const inferCategory = (filename: string): string => {
|
|
181
|
+
const stem = filename.replace(/\.json$/, "")
|
|
182
|
+
for (const prefix of CATEGORY_PREFIXES) {
|
|
183
|
+
if (stem === prefix || stem.startsWith(`${prefix}-`)) return prefix
|
|
184
|
+
}
|
|
185
|
+
return "misc"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ttlMs = (days: number) => days * 24 * 60 * 60 * 1000
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Tipos
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
type IndexEntry = {
|
|
195
|
+
name: string
|
|
196
|
+
base_name?: string
|
|
197
|
+
filename?: string
|
|
198
|
+
category: string
|
|
199
|
+
registry: RegistryName
|
|
200
|
+
author?: string
|
|
201
|
+
description?: string
|
|
202
|
+
tags?: string[]
|
|
203
|
+
variants?: string[]
|
|
204
|
+
default_variant?: string
|
|
205
|
+
size?: number
|
|
206
|
+
sha?: string
|
|
207
|
+
raw_url?: string
|
|
208
|
+
install_url: string
|
|
209
|
+
install_url_template?: string
|
|
210
|
+
preview_url?: string
|
|
211
|
+
source?: "github" | "registry_json" | "curated"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type Index = {
|
|
215
|
+
cached_at: string
|
|
216
|
+
ttl_days: number
|
|
217
|
+
source: string
|
|
218
|
+
total: number
|
|
219
|
+
entries: IndexEntry[]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const writeJson = (filePath: string, data: unknown) => {
|
|
223
|
+
ensureDir(path.dirname(filePath))
|
|
224
|
+
const tmp = `${filePath}.tmp`
|
|
225
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8")
|
|
226
|
+
fs.renameSync(tmp, filePath)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const readJson = <T>(filePath: string): T | null => {
|
|
230
|
+
try {
|
|
231
|
+
if (fs.existsSync(filePath)) {
|
|
232
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// ignore corrupt cache; will rebuild
|
|
236
|
+
}
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const isCacheStale = (index: Index | null, ttl: number): boolean => {
|
|
241
|
+
if (!index) return true
|
|
242
|
+
const cached = new Date(index.cached_at).getTime()
|
|
243
|
+
if (Number.isNaN(cached)) return true
|
|
244
|
+
return Date.now() - cached > ttl
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// GitHub API fetchers (Akash + Basecn)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
const fetchGithubTree = async (
|
|
252
|
+
apiUrl: string,
|
|
253
|
+
repoLabel: string
|
|
254
|
+
): Promise<IndexEntry[]> => {
|
|
255
|
+
const res = await fetch(apiUrl, {
|
|
256
|
+
headers: {
|
|
257
|
+
"User-Agent": "zugzbot-sdd-catalog/1.0",
|
|
258
|
+
Accept: "application/vnd.github+json"
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
if (res.status === 403) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`GitHub API rate limit hit consultando ${repoLabel}. ` +
|
|
266
|
+
`Espera 1h o usa force_refresh=false con cache existente.`
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
if (res.status === 404) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Repositorio ${repoLabel} no encontrado o ruta cambiada. ` +
|
|
272
|
+
`Verifica que ${apiUrl} siga vigente.`
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`GitHub API ${res.status} ${res.statusText} consultando ${repoLabel}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const arr = (await res.json()) as Array<{
|
|
279
|
+
name: string
|
|
280
|
+
size: number
|
|
281
|
+
sha: string
|
|
282
|
+
download_url: string
|
|
283
|
+
}>
|
|
284
|
+
|
|
285
|
+
const registry: RegistryName = repoLabel.includes("basecn") ? "basecn" : "shadcn"
|
|
286
|
+
|
|
287
|
+
return arr
|
|
288
|
+
.filter((f) => f.name.endsWith(".json") && !f.name.startsWith("_"))
|
|
289
|
+
.map((f) => {
|
|
290
|
+
const filename = f.name
|
|
291
|
+
const name = filename.replace(/\.json$/, "")
|
|
292
|
+
const installUrl =
|
|
293
|
+
registry === "basecn" ? BASECN_INSTALL(name) : AKASH_INSTALL(name)
|
|
294
|
+
return {
|
|
295
|
+
name,
|
|
296
|
+
filename,
|
|
297
|
+
category: inferCategory(filename),
|
|
298
|
+
registry,
|
|
299
|
+
size: f.size,
|
|
300
|
+
sha: f.sha,
|
|
301
|
+
raw_url: undefined,
|
|
302
|
+
install_url: installUrl,
|
|
303
|
+
preview_url: registry === "basecn"
|
|
304
|
+
? `https://basecn.dev/blocks/${name}`
|
|
305
|
+
: `https://shadcnui-blocks.com/blocks/${name}`,
|
|
306
|
+
source: "github" as const
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const buildOrLoadIndex = async (
|
|
312
|
+
root: string,
|
|
313
|
+
registry: "shadcn" | "basecn",
|
|
314
|
+
forceRefresh: boolean
|
|
315
|
+
): Promise<{ index: Index; refreshed: boolean; error?: string }> => {
|
|
316
|
+
const indexPath = getIndexPath(root, registry)
|
|
317
|
+
const apiUrl = registry === "basecn" ? BASECN_API : AKASH_API
|
|
318
|
+
const repoLabel = registry === "basecn" ? BASECN_REPO : AKASH_REPO
|
|
319
|
+
const ttl = registry === "basecn" ? ttlMs(BASECN_TTL_DAYS) : ttlMs(AKASH_TTL_DAYS)
|
|
320
|
+
|
|
321
|
+
const cached = readJson<Index>(indexPath)
|
|
322
|
+
if (!forceRefresh && !isCacheStale(cached, ttl)) {
|
|
323
|
+
return { index: cached!, refreshed: false }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const entries = await fetchGithubTree(apiUrl, repoLabel)
|
|
328
|
+
const index: Index = {
|
|
329
|
+
cached_at: new Date().toISOString(),
|
|
330
|
+
ttl_days: registry === "basecn" ? BASECN_TTL_DAYS : AKASH_TTL_DAYS,
|
|
331
|
+
source: repoLabel,
|
|
332
|
+
total: entries.length,
|
|
333
|
+
entries
|
|
334
|
+
}
|
|
335
|
+
writeJson(indexPath, index)
|
|
336
|
+
return { index, refreshed: true }
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (cached) {
|
|
339
|
+
return {
|
|
340
|
+
index: cached,
|
|
341
|
+
refreshed: false,
|
|
342
|
+
error: `Refresh fallo (${(e as Error).message}); usando cache de ${cached.cached_at}`
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
throw e
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// reactbits.dev registry.json loader
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
type ReactBitsRegistryItem = {
|
|
354
|
+
name: string
|
|
355
|
+
title?: string
|
|
356
|
+
description?: string
|
|
357
|
+
type?: string
|
|
358
|
+
dependencies?: string[]
|
|
359
|
+
registryDependencies?: string[]
|
|
360
|
+
files?: Array<{ type?: string; path?: string; content?: string }>
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type ReactBitsCatalog = {
|
|
364
|
+
$schema?: string
|
|
365
|
+
name?: string
|
|
366
|
+
homepage?: string
|
|
367
|
+
items?: ReactBitsRegistryItem[]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const stripVariantSuffix = (name: string): { base: string; variant: ReactBitsVariant | null } => {
|
|
371
|
+
const m = name.match(REACTBITS_VARIANT_SUFFIX_RE)
|
|
372
|
+
if (m) {
|
|
373
|
+
return { base: name.slice(0, m[0].length * -1), variant: m[1] as ReactBitsVariant }
|
|
374
|
+
}
|
|
375
|
+
return { base: name, variant: null }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const inferReactbitsCategory = (base: string): string => {
|
|
379
|
+
return REACTBITS_CATEGORY_BY_BASE.get(base) || "misc"
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const fetchReactbitsCatalog = async (): Promise<ReactBitsCatalog> => {
|
|
383
|
+
const res = await fetch(REACTBITS_CATALOG, {
|
|
384
|
+
headers: {
|
|
385
|
+
"User-Agent": "zugzbot-sdd-catalog/1.0",
|
|
386
|
+
Accept: "application/json"
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
throw new Error(`Fetch ${res.status} ${res.statusText} -> ${REACTBITS_CATALOG}`)
|
|
391
|
+
}
|
|
392
|
+
const text = await res.text()
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(text) as ReactBitsCatalog
|
|
395
|
+
} catch (e) {
|
|
396
|
+
throw new Error(`No se pudo parsear catalog de reactbits: ${(e as Error).message}`)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const buildReactbitsIndex = async (
|
|
401
|
+
root: string,
|
|
402
|
+
forceRefresh: boolean
|
|
403
|
+
): Promise<{ index: Index; refreshed: boolean; error?: string }> => {
|
|
404
|
+
const indexPath = getIndexPath(root, "reactbits")
|
|
405
|
+
const ttl = ttlMs(REACTBITS_TTL_DAYS)
|
|
406
|
+
|
|
407
|
+
const cached = readJson<Index>(indexPath)
|
|
408
|
+
if (!forceRefresh && !isCacheStale(cached, ttl)) {
|
|
409
|
+
return { index: cached!, refreshed: false }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const catalog = await fetchReactbitsCatalog()
|
|
414
|
+
const items = catalog.items || []
|
|
415
|
+
|
|
416
|
+
// Agrupar items por base. Cada base tiene hasta 4 entradas (variantes).
|
|
417
|
+
const byBase = new Map<string, ReactBitsRegistryItem[]>()
|
|
418
|
+
for (const it of items) {
|
|
419
|
+
const { base } = stripVariantSuffix(it.name)
|
|
420
|
+
if (!byBase.has(base)) byBase.set(base, [])
|
|
421
|
+
byBase.get(base)!.push(it)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const entries: IndexEntry[] = []
|
|
425
|
+
for (const [base, variants] of byBase.entries()) {
|
|
426
|
+
const variantsAvailable = variants
|
|
427
|
+
.map((v) => stripVariantSuffix(v.name).variant)
|
|
428
|
+
.filter((v): v is ReactBitsVariant => !!v)
|
|
429
|
+
const first = variants[0]
|
|
430
|
+
const description = first.description || first.title || ""
|
|
431
|
+
const category = inferReactbitsCategory(base)
|
|
432
|
+
const deps = Array.from(
|
|
433
|
+
new Set(
|
|
434
|
+
variants.flatMap((v) =>
|
|
435
|
+
Array.isArray(v.dependencies) ? v.dependencies : []
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
entries.push({
|
|
441
|
+
name: base,
|
|
442
|
+
base_name: base,
|
|
443
|
+
filename: `${base}-${DEFAULT_REACTBITS_VARIANT}.json`,
|
|
444
|
+
category,
|
|
445
|
+
registry: "reactbits",
|
|
446
|
+
description,
|
|
447
|
+
tags: [category, "animated", "reactbits"].concat(deps.slice(0, 3)),
|
|
448
|
+
variants: variantsAvailable,
|
|
449
|
+
default_variant: DEFAULT_REACTBITS_VARIANT,
|
|
450
|
+
install_url: REACTBITS_INSTALL(`${base}-${DEFAULT_REACTBITS_VARIANT}`),
|
|
451
|
+
install_url_template: `https://reactbits.dev/r/${base}-{variant}.json`,
|
|
452
|
+
preview_url: REACTBITS_PREVIEW(base),
|
|
453
|
+
source: "registry_json"
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Orden alfabetico para mejor UX
|
|
458
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
459
|
+
|
|
460
|
+
const index: Index = {
|
|
461
|
+
cached_at: new Date().toISOString(),
|
|
462
|
+
ttl_days: REACTBITS_TTL_DAYS,
|
|
463
|
+
source: REACTBITS_CATALOG,
|
|
464
|
+
total: entries.length,
|
|
465
|
+
entries
|
|
466
|
+
}
|
|
467
|
+
writeJson(indexPath, index)
|
|
468
|
+
return { index, refreshed: true }
|
|
469
|
+
} catch (e) {
|
|
470
|
+
if (cached) {
|
|
471
|
+
return {
|
|
472
|
+
index: cached,
|
|
473
|
+
refreshed: false,
|
|
474
|
+
error: `Refresh fallo (${(e as Error).message}); usando cache de ${cached.cached_at}`
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
throw e
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Resuelve el slug canonico final para reactbits.
|
|
482
|
+
// Acepta:
|
|
483
|
+
// - "Dither" + variant "TS-TW" (default) => "Dither-TS-TW"
|
|
484
|
+
// - "Dither-JS-CSS" (canonica completa) => "Dither-JS-CSS"
|
|
485
|
+
// - "Dither" sin variant => "Dither-TS-TW"
|
|
486
|
+
const resolveReactbitsSlug = (
|
|
487
|
+
input: string,
|
|
488
|
+
variantArg?: string
|
|
489
|
+
): { canonicalName: string; baseName: string; variant: ReactBitsVariant } => {
|
|
490
|
+
const trimmed = input.trim()
|
|
491
|
+
const suffixMatch = trimmed.match(REACTBITS_VARIANT_SUFFIX_RE)
|
|
492
|
+
if (suffixMatch) {
|
|
493
|
+
const variant = suffixMatch[1] as ReactBitsVariant
|
|
494
|
+
const base = trimmed.slice(0, suffixMatch[0].length * -1)
|
|
495
|
+
return { canonicalName: `${base}-${variant}`, baseName: base, variant }
|
|
496
|
+
}
|
|
497
|
+
const requestedVariant = (variantArg || DEFAULT_REACTBITS_VARIANT) as ReactBitsVariant
|
|
498
|
+
const safeVariant: ReactBitsVariant = REACTBITS_VARIANTS.includes(requestedVariant)
|
|
499
|
+
? requestedVariant
|
|
500
|
+
: DEFAULT_REACTBITS_VARIANT
|
|
501
|
+
return {
|
|
502
|
+
canonicalName: `${trimmed}-${safeVariant}`,
|
|
503
|
+
baseName: trimmed,
|
|
504
|
+
variant: safeVariant
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// Fetchers compartidos
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
const fetchRaw = async (url: string, acceptJson = false): Promise<string> => {
|
|
513
|
+
const res = await fetch(url, {
|
|
514
|
+
headers: {
|
|
515
|
+
"User-Agent": "zugzbot-sdd-catalog/1.0",
|
|
516
|
+
...(acceptJson ? { Accept: "application/json" } : {})
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
if (!res.ok) {
|
|
520
|
+
throw new Error(`Fetch ${res.status} ${res.statusText} -> ${url}`)
|
|
521
|
+
}
|
|
522
|
+
return await res.text()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const tryParseBlockJson = (raw: string): any => {
|
|
526
|
+
const trimmed = raw.trim()
|
|
527
|
+
try {
|
|
528
|
+
return JSON.parse(trimmed)
|
|
529
|
+
} catch {
|
|
530
|
+
try {
|
|
531
|
+
return JSON.parse(JSON.parse(trimmed))
|
|
532
|
+
} catch {
|
|
533
|
+
throw new Error(`No se pudo parsear JSON del bloque`)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const normalizeBlock = (parsed: any, registry: RegistryName, name: string) => {
|
|
539
|
+
const files: Array<{
|
|
540
|
+
path: string
|
|
541
|
+
type: string
|
|
542
|
+
target?: string
|
|
543
|
+
content?: string
|
|
544
|
+
}> = []
|
|
545
|
+
|
|
546
|
+
const deps: string[] = []
|
|
547
|
+
let installUrl: string
|
|
548
|
+
|
|
549
|
+
if (registry === "reactbits") {
|
|
550
|
+
installUrl = REACTBITS_INSTALL(name)
|
|
551
|
+
} else if (registry === "basecn") {
|
|
552
|
+
installUrl = BASECN_INSTALL(name)
|
|
553
|
+
} else {
|
|
554
|
+
installUrl = AKASH_INSTALL(name)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (Array.isArray(parsed?.files)) {
|
|
558
|
+
for (const f of parsed.files) {
|
|
559
|
+
files.push({
|
|
560
|
+
path: f.path || f.name || "unknown",
|
|
561
|
+
type: f.type || "unknown",
|
|
562
|
+
target: f.target,
|
|
563
|
+
content: f.content || f.code
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (Array.isArray(parsed?.dependencies)) deps.push(...parsed.dependencies)
|
|
568
|
+
if (Array.isArray(parsed?.deps)) deps.push(...parsed.deps)
|
|
569
|
+
if (parsed?.registryDependencies && typeof parsed.registryDependencies === "object") {
|
|
570
|
+
for (const [k, v] of Object.entries(parsed.registryDependencies)) {
|
|
571
|
+
deps.push(`${k}: ${v}`)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return { files, dependencies: deps, installUrl }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ============================================================================
|
|
579
|
+
// Tool: list_blocks
|
|
580
|
+
// ============================================================================
|
|
581
|
+
|
|
582
|
+
type ListResult = {
|
|
583
|
+
status: "SUCCESS" | "ERROR"
|
|
584
|
+
message: string
|
|
585
|
+
registries_queried: RegistryName[]
|
|
586
|
+
cached_at?: Record<string, string>
|
|
587
|
+
total?: number
|
|
588
|
+
filtered?: number
|
|
589
|
+
entries?: IndexEntry[]
|
|
590
|
+
errors?: string[]
|
|
591
|
+
hints?: string[]
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const REGISTRIES_ALL: RegistryName[] = ["shadcn", "basecn", "reactbits"]
|
|
595
|
+
|
|
596
|
+
export const list_blocks = tool({
|
|
597
|
+
description:
|
|
598
|
+
"Lista bloques/componentes del catalogo unificado sdd_catalog. " +
|
|
599
|
+
"Soporta 3 registries con catalogo JSON oficial: 'shadcn' (akash3444/shadcn-ui-blocks, " +
|
|
600
|
+
"Radix), 'basecn' (akash3444/basecn, fork Base UI) y 'reactbits' (reactbits.dev, " +
|
|
601
|
+
"primitivas animadas; para cada base se exponen sus 4 variantes JS-CSS/JS-TW/TS-CSS/TS-TW). " +
|
|
602
|
+
"Cachea en .openspec/cache/ con TTL por registry (7d shadcn/basecn, 1d reactbits). " +
|
|
603
|
+
"Filtra por categoria (hero, footer, pricing, backgrounds, text, cards, etc) " +
|
|
604
|
+
"y por query libre (substring sobre name/description/tags).",
|
|
605
|
+
args: {
|
|
606
|
+
category: tool.schema
|
|
607
|
+
.string()
|
|
608
|
+
.optional()
|
|
609
|
+
.describe(
|
|
610
|
+
"Filtra por categoria exacta (hero, footer, pricing, backgrounds, text, " +
|
|
611
|
+
"cards, navigation, buttons, animations, cursors, etc). Opcional."
|
|
612
|
+
),
|
|
613
|
+
query: tool.schema
|
|
614
|
+
.string()
|
|
615
|
+
.optional()
|
|
616
|
+
.describe("Busqueda libre por substring sobre name/filename/description. Opcional."),
|
|
617
|
+
registry: tool.schema
|
|
618
|
+
.enum(["shadcn", "basecn", "reactbits", "all"])
|
|
619
|
+
.optional()
|
|
620
|
+
.default("all")
|
|
621
|
+
.describe("Que catalogos consultar. 'all' = shadcn+basecn+reactbits."),
|
|
622
|
+
limit: tool.schema
|
|
623
|
+
.number()
|
|
624
|
+
.optional()
|
|
625
|
+
.default(50)
|
|
626
|
+
.describe("Maximo de entradas a devolver (default 50, max 500)."),
|
|
627
|
+
force_refresh: tool.schema
|
|
628
|
+
.boolean()
|
|
629
|
+
.optional()
|
|
630
|
+
.default(false)
|
|
631
|
+
.describe("Si true, ignora TTL y re-descarga el indice del registry.")
|
|
632
|
+
},
|
|
633
|
+
async execute(args, context) {
|
|
634
|
+
const root = getRoot(context)
|
|
635
|
+
const requested: RegistryName[] =
|
|
636
|
+
args.registry === "all" || !args.registry
|
|
637
|
+
? REGISTRIES_ALL
|
|
638
|
+
: [args.registry as RegistryName]
|
|
639
|
+
|
|
640
|
+
const allEntries: IndexEntry[] = []
|
|
641
|
+
const cachedAt: Record<string, string> = {}
|
|
642
|
+
const errors: string[] = []
|
|
643
|
+
const hints: string[] = []
|
|
644
|
+
|
|
645
|
+
for (const reg of requested) {
|
|
646
|
+
try {
|
|
647
|
+
if (reg === "reactbits") {
|
|
648
|
+
const { index, refreshed, error } = await buildReactbitsIndex(
|
|
649
|
+
root,
|
|
650
|
+
args.force_refresh || false
|
|
651
|
+
)
|
|
652
|
+
cachedAt[reg] = index.cached_at
|
|
653
|
+
if (refreshed) {
|
|
654
|
+
// ok
|
|
655
|
+
}
|
|
656
|
+
if (error) errors.push(`[${reg}] ${error}`)
|
|
657
|
+
allEntries.push(...index.entries)
|
|
658
|
+
hints.push(
|
|
659
|
+
"[reactbits] Cada entrada lista las 4 variantes disponibles " +
|
|
660
|
+
"(`variants[]`) y `install_url` apunta a la variante por default " +
|
|
661
|
+
"TS-TW (TypeScript + Tailwind v4). Para otra variante, pasa " +
|
|
662
|
+
"`variant='JS-CSS'` a `sdd_catalog_get_block`."
|
|
663
|
+
)
|
|
664
|
+
} else {
|
|
665
|
+
const { index, refreshed, error } = await buildOrLoadIndex(
|
|
666
|
+
root,
|
|
667
|
+
reg,
|
|
668
|
+
args.force_refresh || false
|
|
669
|
+
)
|
|
670
|
+
cachedAt[reg] = index.cached_at
|
|
671
|
+
if (refreshed) {
|
|
672
|
+
// ok
|
|
673
|
+
}
|
|
674
|
+
if (error) errors.push(`[${reg}] ${error}`)
|
|
675
|
+
allEntries.push(...index.entries)
|
|
676
|
+
}
|
|
677
|
+
} catch (e) {
|
|
678
|
+
errors.push(`[${reg}] ${(e as Error).message}`)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let filtered = allEntries
|
|
683
|
+
if (args.category) {
|
|
684
|
+
const cat = args.category.toLowerCase()
|
|
685
|
+
filtered = filtered.filter(
|
|
686
|
+
(e) =>
|
|
687
|
+
e.category === cat ||
|
|
688
|
+
e.name.toLowerCase().startsWith(cat + "-") ||
|
|
689
|
+
(e.tags || []).some((t) => t.toLowerCase().includes(cat))
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
if (args.query) {
|
|
693
|
+
const q = args.query.toLowerCase()
|
|
694
|
+
filtered = filtered.filter(
|
|
695
|
+
(e) =>
|
|
696
|
+
e.name.toLowerCase().includes(q) ||
|
|
697
|
+
(e.filename || "").toLowerCase().includes(q) ||
|
|
698
|
+
(e.description || "").toLowerCase().includes(q) ||
|
|
699
|
+
(e.author || "").toLowerCase().includes(q) ||
|
|
700
|
+
(e.tags || []).some((t) => t.toLowerCase().includes(q))
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const limit = Math.min(args.limit || 50, 500)
|
|
705
|
+
filtered = filtered.slice(0, limit)
|
|
706
|
+
|
|
707
|
+
const result: ListResult = {
|
|
708
|
+
status: errors.length && allEntries.length === 0 ? "ERROR" : "SUCCESS",
|
|
709
|
+
message:
|
|
710
|
+
errors.length && allEntries.length === 0
|
|
711
|
+
? `No se pudo cargar el catalogo. Errores: ${errors.join("; ")}`
|
|
712
|
+
: `Se encontraron ${filtered.length} bloques (total sin filtro: ${allEntries.length}).`,
|
|
713
|
+
registries_queried: requested,
|
|
714
|
+
cached_at: cachedAt,
|
|
715
|
+
total: allEntries.length,
|
|
716
|
+
filtered: filtered.length,
|
|
717
|
+
entries: filtered
|
|
718
|
+
}
|
|
719
|
+
if (errors.length) result.errors = errors
|
|
720
|
+
if (hints.length) result.hints = hints
|
|
721
|
+
return JSON.stringify(result, null, 2)
|
|
722
|
+
}
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
// ============================================================================
|
|
726
|
+
// Tool: get_block
|
|
727
|
+
// ============================================================================
|
|
728
|
+
|
|
729
|
+
type GetResult = {
|
|
730
|
+
status: "SUCCESS" | "ERROR"
|
|
731
|
+
message: string
|
|
732
|
+
block: {
|
|
733
|
+
name: string
|
|
734
|
+
base_name?: string
|
|
735
|
+
variant?: string
|
|
736
|
+
registry: RegistryName
|
|
737
|
+
category: string
|
|
738
|
+
author?: string
|
|
739
|
+
install_url: string
|
|
740
|
+
install_command: string
|
|
741
|
+
cached_source: boolean
|
|
742
|
+
cached_at: string
|
|
743
|
+
source_files: Array<{
|
|
744
|
+
path: string
|
|
745
|
+
type: string
|
|
746
|
+
target?: string
|
|
747
|
+
content?: string
|
|
748
|
+
}>
|
|
749
|
+
dependencies?: string[]
|
|
750
|
+
notes?: string
|
|
751
|
+
preview_url?: string
|
|
752
|
+
} | null
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export const get_block = tool({
|
|
756
|
+
description:
|
|
757
|
+
"Obtiene el codigo fuente, archivos y dependencias de un bloque/componente " +
|
|
758
|
+
"especifico del catalogo unificado. " +
|
|
759
|
+
"Para shadcn/basecn: `name='hero-06'`. " +
|
|
760
|
+
"Para reactbits: `name='Dither'` (resuelve a Dither-TS-TW por default) o " +
|
|
761
|
+
"`name='Dither-JS-CSS'` (variante canonica completa). Tambien acepta URL " +
|
|
762
|
+
"directa `name='https://reactbits.dev/r/Dither-TS-TW.json'` y namespace shadcn " +
|
|
763
|
+
"`name='@acme/button'`. Cachea en .openspec/cache/blocks/<registry>/<name>.json. " +
|
|
764
|
+
"Devuelve `install_command` listo para ejecutar con `npx shadcn@latest add`.",
|
|
765
|
+
args: {
|
|
766
|
+
name: tool.schema
|
|
767
|
+
.string()
|
|
768
|
+
.describe(
|
|
769
|
+
"Identificador del bloque. Formatos aceptados:\n" +
|
|
770
|
+
" - shadcn/basecn: 'hero-06', 'sidebar-07'\n" +
|
|
771
|
+
" - reactbits: 'Dither' (default TS-TW) o 'Dither-JS-CSS' (canonica)\n" +
|
|
772
|
+
" - URL directa: 'https://reactbits.dev/r/Dither-TS-TW.json'\n" +
|
|
773
|
+
" - Namespace shadcn: '@acme/button'"
|
|
774
|
+
),
|
|
775
|
+
registry: tool.schema
|
|
776
|
+
.enum(["auto", "shadcn", "basecn", "reactbits"])
|
|
777
|
+
.optional()
|
|
778
|
+
.default("auto")
|
|
779
|
+
.describe(
|
|
780
|
+
"Registry a consultar. 'auto' detecta por el formato del nombre " +
|
|
781
|
+
"(contiene '/' => namespace, contiene '-JS-...' o '-TS-...' => reactbits, " +
|
|
782
|
+
"sin prefijo => shadcn por default)."
|
|
783
|
+
),
|
|
784
|
+
variant: tool.schema
|
|
785
|
+
.enum(["JS-CSS", "JS-TW", "TS-CSS", "TS-TW"])
|
|
786
|
+
.optional()
|
|
787
|
+
.describe(
|
|
788
|
+
"Solo para reactbits: variante del componente (default: TS-TW). " +
|
|
789
|
+
"Aplicar cuando se pasa el nombre base sin sufijo de variante."
|
|
790
|
+
),
|
|
791
|
+
force_refresh: tool.schema
|
|
792
|
+
.boolean()
|
|
793
|
+
.optional()
|
|
794
|
+
.default(false)
|
|
795
|
+
.describe("Si true, re-descarga aunque exista cache.")
|
|
796
|
+
},
|
|
797
|
+
async execute(args, context) {
|
|
798
|
+
const root = getRoot(context)
|
|
799
|
+
const rawName = args.name.trim()
|
|
800
|
+
|
|
801
|
+
// --- 1. Resolver registry y slug canonico ---
|
|
802
|
+
let registry: RegistryName
|
|
803
|
+
let canonicalName: string
|
|
804
|
+
let baseName: string | undefined
|
|
805
|
+
let variant: ReactBitsVariant | undefined
|
|
806
|
+
let directUrl: string | undefined
|
|
807
|
+
|
|
808
|
+
if (rawName.startsWith("http://") || rawName.startsWith("https://")) {
|
|
809
|
+
const url = rawName
|
|
810
|
+
if (url.includes("reactbits.dev/r/")) {
|
|
811
|
+
const m = url.match(/\/r\/([a-zA-Z0-9_.-]+)\.json/)
|
|
812
|
+
if (!m) {
|
|
813
|
+
return JSON.stringify({
|
|
814
|
+
status: "ERROR",
|
|
815
|
+
message: `URL reactbits invalida: ${url}. Esperado patron /r/<name>.json.`,
|
|
816
|
+
block: null
|
|
817
|
+
}, null, 2)
|
|
818
|
+
}
|
|
819
|
+
const slug = m[1]
|
|
820
|
+
const resolved = resolveReactbitsSlug(slug, args.variant)
|
|
821
|
+
registry = "reactbits"
|
|
822
|
+
canonicalName = resolved.canonicalName
|
|
823
|
+
baseName = resolved.baseName
|
|
824
|
+
variant = resolved.variant
|
|
825
|
+
directUrl = url
|
|
826
|
+
} else {
|
|
827
|
+
// URL generica shadcn: tratarla como shadcn (1 sola parte)
|
|
828
|
+
const m = url.match(/\/([a-zA-Z0-9_.-]+)\.json/)
|
|
829
|
+
if (!m) {
|
|
830
|
+
return JSON.stringify({
|
|
831
|
+
status: "ERROR",
|
|
832
|
+
message: `URL no reconocida como item de registry valido: ${url}`,
|
|
833
|
+
block: null
|
|
834
|
+
}, null, 2)
|
|
835
|
+
}
|
|
836
|
+
registry = "shadcn"
|
|
837
|
+
canonicalName = m[1]
|
|
838
|
+
directUrl = url
|
|
839
|
+
}
|
|
840
|
+
} else if (rawName.startsWith("@")) {
|
|
841
|
+
// Namespace shadcn @acme/item
|
|
842
|
+
const m = rawName.match(/^@([a-zA-Z0-9_.-]+)\/(.+)$/)
|
|
843
|
+
if (!m) {
|
|
844
|
+
return JSON.stringify({
|
|
845
|
+
status: "ERROR",
|
|
846
|
+
message: `Namespace invalido: ${rawName}`,
|
|
847
|
+
block: null
|
|
848
|
+
}, null, 2)
|
|
849
|
+
}
|
|
850
|
+
registry = "shadcn"
|
|
851
|
+
canonicalName = `${m[1]}/${m[2]}`
|
|
852
|
+
} else {
|
|
853
|
+
// Auto-detectar: si contiene '-JS-' o '-TS-' seguido de CSS/TW, es reactbits canonica.
|
|
854
|
+
// Si el registry forzado es reactbits, resolver siempre.
|
|
855
|
+
const looksLikeReactbitsVariant = REACTBITS_VARIANT_SUFFIX_RE.test(rawName)
|
|
856
|
+
const requestedReg = args.registry === "auto" || !args.registry
|
|
857
|
+
? (looksLikeReactbitsVariant ? "reactbits" : "shadcn")
|
|
858
|
+
: (args.registry as RegistryName)
|
|
859
|
+
|
|
860
|
+
if (requestedReg === "reactbits") {
|
|
861
|
+
const resolved = resolveReactbitsSlug(rawName, args.variant)
|
|
862
|
+
registry = "reactbits"
|
|
863
|
+
canonicalName = resolved.canonicalName
|
|
864
|
+
baseName = resolved.baseName
|
|
865
|
+
variant = resolved.variant
|
|
866
|
+
} else if (requestedReg === "basecn") {
|
|
867
|
+
registry = "basecn"
|
|
868
|
+
canonicalName = rawName
|
|
869
|
+
} else {
|
|
870
|
+
registry = "shadcn"
|
|
871
|
+
canonicalName = rawName
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// --- 2. Resolver URLs de fetch e install ---
|
|
876
|
+
let rawUrl: string
|
|
877
|
+
let cachePath: string
|
|
878
|
+
let installUrl: string
|
|
879
|
+
let previewUrl: string | undefined
|
|
880
|
+
let category: string
|
|
881
|
+
let installCommand: string
|
|
882
|
+
let notes: string
|
|
883
|
+
|
|
884
|
+
if (registry === "reactbits") {
|
|
885
|
+
rawUrl = directUrl || REACTBITS_RAW(canonicalName)
|
|
886
|
+
cachePath = path.resolve(getBlocksDir(root, "reactbits"), `${canonicalName}.json`)
|
|
887
|
+
installUrl = REACTBITS_INSTALL(canonicalName)
|
|
888
|
+
previewUrl = baseName ? REACTBITS_PREVIEW(baseName) : undefined
|
|
889
|
+
installCommand = `npx shadcn@latest add ${installUrl} --yes`
|
|
890
|
+
category = baseName ? inferReactbitsCategory(baseName) : "misc"
|
|
891
|
+
notes = "Item reactbits.dev (David Haz). Dependencias pesadas (gsap/three/motion) son habituales; valida compat antes de instalar."
|
|
892
|
+
} else if (registry === "basecn") {
|
|
893
|
+
rawUrl = BASECN_RAW(canonicalName)
|
|
894
|
+
cachePath = path.resolve(getBlocksDir(root, "basecn"), `${canonicalName}.json`)
|
|
895
|
+
installUrl = BASECN_INSTALL(canonicalName)
|
|
896
|
+
previewUrl = `https://basecn.dev/blocks/${canonicalName}`
|
|
897
|
+
installCommand = `npx shadcn@latest add ${installUrl} --yes`
|
|
898
|
+
category = inferCategory(canonicalName)
|
|
899
|
+
notes = "Bloque Base UI (fork). Verifica que tu proyecto tenga Base UI instalado."
|
|
900
|
+
} else {
|
|
901
|
+
rawUrl = AKASH_RAW(canonicalName)
|
|
902
|
+
cachePath = path.resolve(getBlocksDir(root, "shadcn"), `${canonicalName}.json`)
|
|
903
|
+
installUrl = AKASH_INSTALL(canonicalName)
|
|
904
|
+
previewUrl = `https://shadcnui-blocks.com/blocks/${canonicalName}`
|
|
905
|
+
installCommand = `npx shadcn@latest add ${installUrl} --yes`
|
|
906
|
+
category = inferCategory(canonicalName)
|
|
907
|
+
notes = "Bloque Radix/shadcn-ui-blocks. Compatible con el stack nextjs-shadcn."
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// --- 3. Fetch + cache ---
|
|
911
|
+
let rawText: string
|
|
912
|
+
let cachedSource = false
|
|
913
|
+
if (!args.force_refresh && fs.existsSync(cachePath)) {
|
|
914
|
+
rawText = fs.readFileSync(cachePath, "utf8")
|
|
915
|
+
cachedSource = true
|
|
916
|
+
} else {
|
|
917
|
+
try {
|
|
918
|
+
// El shadcn CLI negocia content-type via Accept: application/json
|
|
919
|
+
rawText = await fetchRaw(rawUrl, true)
|
|
920
|
+
ensureDir(path.dirname(cachePath))
|
|
921
|
+
fs.writeFileSync(cachePath, rawText, "utf8")
|
|
922
|
+
} catch (e) {
|
|
923
|
+
return JSON.stringify(
|
|
924
|
+
{
|
|
925
|
+
status: "ERROR",
|
|
926
|
+
message: `No se pudo descargar ${rawUrl}: ${(e as Error).message}`,
|
|
927
|
+
block: null
|
|
928
|
+
} satisfies GetResult,
|
|
929
|
+
null,
|
|
930
|
+
2
|
|
931
|
+
)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// --- 4. Parse + normalizar ---
|
|
936
|
+
let parsed: any
|
|
937
|
+
try {
|
|
938
|
+
parsed = tryParseBlockJson(rawText)
|
|
939
|
+
} catch (e) {
|
|
940
|
+
return JSON.stringify(
|
|
941
|
+
{
|
|
942
|
+
status: "ERROR",
|
|
943
|
+
message: `Bloque ${canonicalName} (${registry}) descargado pero no parseable: ${(e as Error).message}`,
|
|
944
|
+
block: null
|
|
945
|
+
} satisfies GetResult,
|
|
946
|
+
null,
|
|
947
|
+
2
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const { files, dependencies } = normalizeBlock(parsed, registry, canonicalName)
|
|
952
|
+
|
|
953
|
+
const result: GetResult = {
|
|
954
|
+
status: "SUCCESS",
|
|
955
|
+
message: `Bloque '${canonicalName}' (${registry}) listo. ${files.length} archivo(s), ${dependencies.length} dependencia(s).`,
|
|
956
|
+
block: {
|
|
957
|
+
name: canonicalName,
|
|
958
|
+
base_name: baseName,
|
|
959
|
+
variant,
|
|
960
|
+
registry,
|
|
961
|
+
category,
|
|
962
|
+
install_url: installUrl,
|
|
963
|
+
install_command: installCommand,
|
|
964
|
+
cached_source: cachedSource,
|
|
965
|
+
cached_at: new Date().toISOString(),
|
|
966
|
+
source_files: files,
|
|
967
|
+
dependencies,
|
|
968
|
+
notes,
|
|
969
|
+
preview_url: previewUrl
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return JSON.stringify(result, null, 2)
|
|
973
|
+
}
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
// ============================================================================
|
|
977
|
+
// Tool: warm_index
|
|
978
|
+
// ============================================================================
|
|
979
|
+
|
|
980
|
+
type WarmResult = {
|
|
981
|
+
status: "SUCCESS" | "ERROR"
|
|
982
|
+
message: string
|
|
983
|
+
warmed: string[]
|
|
984
|
+
errors: string[]
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
export const warm_index = tool({
|
|
988
|
+
description:
|
|
989
|
+
"Pre-calienta (descarga) los indices de los registries soportados " +
|
|
990
|
+
"para evitar la latencia del primer list_blocks/get_block. " +
|
|
991
|
+
"Idempotente: si el cache existe y es fresco, no re-descarga. " +
|
|
992
|
+
"TTL por registry: shadcn/basecn 7d, reactbits 1d.",
|
|
993
|
+
args: {
|
|
994
|
+
registry: tool.schema
|
|
995
|
+
.enum(["shadcn", "basecn", "reactbits", "all"])
|
|
996
|
+
.optional()
|
|
997
|
+
.default("all")
|
|
998
|
+
.describe("Que catalogos pre-calentar.")
|
|
999
|
+
},
|
|
1000
|
+
async execute(args, context) {
|
|
1001
|
+
const root = getRoot(context)
|
|
1002
|
+
const requested: RegistryName[] =
|
|
1003
|
+
args.registry === "all" || !args.registry
|
|
1004
|
+
? REGISTRIES_ALL
|
|
1005
|
+
: [args.registry as RegistryName]
|
|
1006
|
+
|
|
1007
|
+
const warmed: string[] = []
|
|
1008
|
+
const errors: string[] = []
|
|
1009
|
+
|
|
1010
|
+
for (const reg of requested) {
|
|
1011
|
+
try {
|
|
1012
|
+
if (reg === "reactbits") {
|
|
1013
|
+
const { index, refreshed } = await buildReactbitsIndex(root, false)
|
|
1014
|
+
warmed.push(
|
|
1015
|
+
refreshed
|
|
1016
|
+
? `${reg} (re-descargado, ${index.total} bases)`
|
|
1017
|
+
: `${reg} (cache fresco de ${index.cached_at})`
|
|
1018
|
+
)
|
|
1019
|
+
} else {
|
|
1020
|
+
const { index, refreshed } = await buildOrLoadIndex(root, reg, false)
|
|
1021
|
+
warmed.push(
|
|
1022
|
+
refreshed
|
|
1023
|
+
? `${reg} (re-descargado, ${index.total} bloques)`
|
|
1024
|
+
: `${reg} (cache fresco de ${index.cached_at})`
|
|
1025
|
+
)
|
|
1026
|
+
}
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
errors.push(`[${reg}] ${(e as Error).message}`)
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const result: WarmResult = {
|
|
1033
|
+
status: errors.length && warmed.length === 0 ? "ERROR" : "SUCCESS",
|
|
1034
|
+
message: `Indices: ${warmed.length} listos, ${errors.length} errores.`,
|
|
1035
|
+
warmed,
|
|
1036
|
+
errors
|
|
1037
|
+
}
|
|
1038
|
+
return JSON.stringify(result, null, 2)
|
|
1039
|
+
}
|
|
1040
|
+
})
|