zugzbot 1.1.7 → 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.
- package/.opencode/agents/sdd-orchestrator.md +18 -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 +31 -8
- package/.opencode/tools/akash_catalog.ts +596 -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,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
|
+
})
|