zugzbot 1.0.17 → 1.0.19

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 (53) hide show
  1. package/.opencode/agents/sdd-coder.md +37 -5
  2. package/.opencode/agents/sdd-deployer.md +4 -1
  3. package/.opencode/agents/sdd-orchestrator.md +33 -12
  4. package/.opencode/agents/sdd-reviewer.md +53 -0
  5. package/.opencode/agents/sdd-spec-writer.md +14 -2
  6. package/.opencode/agents/sdd-tester.md +27 -4
  7. package/.opencode/commands/fast.md +19 -0
  8. package/.opencode/commands/reset.md +1 -1
  9. package/.opencode/commands/review.md +18 -0
  10. package/.opencode/contract-schema.json +8 -3
  11. package/.opencode/plugins/sdd-bridge.ts +96 -1
  12. package/.opencode/tools/brain.ts +26 -15
  13. package/.opencode/tools/fast-track-init.js +36 -0
  14. package/.opencode/tools/sdd_bootstrap.ts +625 -0
  15. package/.opencode/tools/sdd_core.ts +673 -0
  16. package/.opencode/tools/sdd_design.ts +303 -0
  17. package/.opencode/tools/sdd_docker.ts +254 -0
  18. package/.opencode/tools/sdd_network.ts +152 -0
  19. package/.opencode/tools/sdd_testing.ts +362 -0
  20. package/.utils/zugzweb/client/node_modules/.tmp/tsconfig.app.tsbuildinfo +1 -0
  21. package/.utils/zugzweb/client/node_modules/.tmp/tsconfig.node.tsbuildinfo +1 -0
  22. package/.utils/zugzweb/client/node_modules/nanoid/.claude/settings.local.json +14 -0
  23. package/bin/init.js +1 -1
  24. package/opencode.json +2 -109
  25. package/package.json +3 -48
  26. package/tui.json +9 -1
  27. package/.opencode/commands/web.md +0 -15
  28. package/.opencode/tools/sdd.ts +0 -2072
  29. package/.utils/zugzweb/client/README.md +0 -73
  30. package/.utils/zugzweb/client/dist/assets/index-CBm0KdaD.js +0 -291
  31. package/.utils/zugzweb/client/dist/assets/index-GxkFc3Mh.css +0 -1
  32. package/.utils/zugzweb/client/dist/favicon.svg +0 -1
  33. package/.utils/zugzweb/client/dist/icons.svg +0 -24
  34. package/.utils/zugzweb/client/dist/index.html +0 -14
  35. package/.utils/zugzweb/client/eslint.config.js +0 -22
  36. package/.utils/zugzweb/client/index.html +0 -13
  37. package/.utils/zugzweb/client/package.json +0 -36
  38. package/.utils/zugzweb/client/public/favicon.svg +0 -1
  39. package/.utils/zugzweb/client/public/icons.svg +0 -24
  40. package/.utils/zugzweb/client/src/App.tsx +0 -2578
  41. package/.utils/zugzweb/client/src/assets/hero.png +0 -0
  42. package/.utils/zugzweb/client/src/assets/react.svg +0 -1
  43. package/.utils/zugzweb/client/src/assets/vite.svg +0 -1
  44. package/.utils/zugzweb/client/src/index.css +0 -185
  45. package/.utils/zugzweb/client/src/main.tsx +0 -10
  46. package/.utils/zugzweb/client/tsconfig.app.json +0 -25
  47. package/.utils/zugzweb/client/tsconfig.json +0 -7
  48. package/.utils/zugzweb/client/tsconfig.node.json +0 -24
  49. package/.utils/zugzweb/client/vite.config.ts +0 -11
  50. package/.utils/zugzweb/daemon.js +0 -396
  51. package/.utils/zugzweb/daemon.log +0 -5
  52. package/.utils/zugzweb/package.json +0 -3
  53. package/bin/web.js +0 -20
@@ -0,0 +1,36 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const projectRoot = process.cwd();
5
+ const stateFilePath = path.resolve(projectRoot, '.openspec/sdd_state.json');
6
+
7
+ try {
8
+ const dir = path.dirname(stateFilePath);
9
+ if (!fs.existsSync(dir)) {
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ }
12
+
13
+ let state = {
14
+ phase: 'F0_DETECT',
15
+ activeContract: '',
16
+ stack: { core: [], databases: [] },
17
+ updatedAt: new Date().toISOString()
18
+ };
19
+
20
+ if (fs.existsSync(stateFilePath)) {
21
+ try {
22
+ state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8'));
23
+ } catch (e) {
24
+ // ignore
25
+ }
26
+ }
27
+
28
+ state.phase = 'F2_IMPLEMENTATION';
29
+ state.activeContract = state.activeContract || '.openspec/specs/fast-track/contract.json';
30
+ state.updatedAt = new Date().toISOString();
31
+
32
+ fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2), 'utf8');
33
+ console.log('✓ Fast Track: Fase forzada a F2_IMPLEMENTATION de forma atómica.');
34
+ } catch (error) {
35
+ console.error('Error al inicializar Fast Track:', error.message);
36
+ }
@@ -0,0 +1,625 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+ import { execSync } from "child_process"
5
+
6
+ // Constants
7
+ const BOOTSTRAP_STATUS_PATH = ".openspec/.sdd_bootstrap.json"
8
+ const TEMPLATE_VERSION = "1.0.0"
9
+ const MIN_NODE_VERSION = "v20.9.0" // Next.js 16 requirement
10
+ const MIN_PYTHON_VERSION = "3.11" // FastAPI modern syntax (X | Y unions, etc.)
11
+
12
+ // Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
13
+ const getRoot = (context: any) => {
14
+ if (context?.directory && context.directory !== "/") return context.directory;
15
+ if (context?.worktree && context.worktree !== "/") return context.worktree;
16
+ if (context?.cwd && context.cwd !== "/") return context.cwd;
17
+ return process.cwd();
18
+ };
19
+
20
+ function readBootstrapStatus(root: string): any | null {
21
+ const statusPath = path.resolve(root, BOOTSTRAP_STATUS_PATH)
22
+ try {
23
+ if (fs.existsSync(statusPath)) {
24
+ return JSON.parse(fs.readFileSync(statusPath, "utf8"))
25
+ }
26
+ } catch (e) { /* ignore */ }
27
+ return null
28
+ }
29
+
30
+ function writeBootstrapStatus(root: string, status: any): void {
31
+ const statusPath = path.resolve(root, BOOTSTRAP_STATUS_PATH)
32
+ const dir = path.dirname(statusPath)
33
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
34
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), "utf8")
35
+ }
36
+
37
+ function detectPackageManager(root: string): "pnpm" | "yarn" | "npm" {
38
+ if (fs.existsSync(path.resolve(root, "pnpm-lock.yaml"))) return "pnpm"
39
+ if (fs.existsSync(path.resolve(root, "yarn.lock"))) return "yarn"
40
+ return "npm"
41
+ }
42
+
43
+ function getNodeVersion(): string {
44
+ try {
45
+ return execSync("node --version", { encoding: "utf8" }).trim()
46
+ } catch {
47
+ return "unknown"
48
+ }
49
+ }
50
+
51
+ function semverMajor(version: string): number {
52
+ const m = version.replace(/^v/, "").match(/^(\d+)/)
53
+ return m ? parseInt(m[1], 10) : 0
54
+ }
55
+
56
+ function getPythonVersion(): string {
57
+ for (const cmd of ["python3 --version", "python --version"]) {
58
+ try {
59
+ const out = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
60
+ const m = out.match(/Python\s+(\d+\.\d+(?:\.\d+)?)/i)
61
+ if (m) return m[1]
62
+ } catch {
63
+ // ignore
64
+ }
65
+ }
66
+ return "unknown"
67
+ }
68
+
69
+ function checkPythonVersion(): { ok: boolean; version: string; message: string } {
70
+ const version = getPythonVersion()
71
+ if (version === "unknown") {
72
+ return {
73
+ ok: false,
74
+ version,
75
+ message: `No se detectó Python en PATH. Instala Python >= ${MIN_PYTHON_VERSION} antes de continuar.`,
76
+ }
77
+ }
78
+ const parts = version.split(".")
79
+ const major = parseInt(parts[0], 10)
80
+ const minor = parseInt(parts[1] || "0", 10)
81
+ const minParts = MIN_PYTHON_VERSION.split(".").map((n) => parseInt(n, 10))
82
+ const ok = major > minParts[0] || (major === minParts[0] && minor >= minParts[1])
83
+ if (!ok) {
84
+ return {
85
+ ok: false,
86
+ version,
87
+ message: `Python ${version} detectado. FastAPI moderno requiere >= ${MIN_PYTHON_VERSION}. Actualiza Python antes de continuar.`,
88
+ }
89
+ }
90
+ return { ok: true, version, message: "OK" }
91
+ }
92
+
93
+ function detectPythonPackageManager(root: string): "uv" | "pip" {
94
+ try {
95
+ execSync("uv --version", { stdio: "ignore" })
96
+ return "uv"
97
+ } catch {
98
+ return "pip"
99
+ }
100
+ }
101
+
102
+ // Tool: sdd_bootstrap_status
103
+ export const bootstrap_status = tool({
104
+ description: "Reporta el estado de bootstrap del proyecto (qué plantilla se usó, cuándo, qué componentes shadcn están instalados, versión de Node y package manager). Si el proyecto no está bootstrapped, retorna NOT_BOOTSTRAPPED. Útil para que el coder verifique antes de empezar a codear.",
105
+ args: {},
106
+ async execute(args, context) {
107
+ const root = getRoot(context)
108
+ const status = readBootstrapStatus(root)
109
+
110
+ if (!status) {
111
+ return JSON.stringify({
112
+ status: "NOT_BOOTSTRAPPED",
113
+ message: "El proyecto no ha sido bootstrapped. Llama sdd_bootstrap_nextjs_shadcn o sdd_bootstrap_fastapi primero.",
114
+ projectRoot: root,
115
+ hasNodeModules: fs.existsSync(path.resolve(root, "node_modules")),
116
+ hasPyproject: fs.existsSync(path.resolve(root, "pyproject.toml")),
117
+ }, null, 2)
118
+ }
119
+
120
+ const pm = status.packageManager || "npm"
121
+ const lockfile = pm === "pnpm" ? "pnpm-lock.yaml" : pm === "yarn" ? "yarn.lock" : "package-lock.json"
122
+ const lockfilePath = path.resolve(root, lockfile)
123
+ let needsRebootstrap = false
124
+ if (fs.existsSync(lockfilePath)) {
125
+ const lockMtime = fs.statSync(lockfilePath).mtimeMs
126
+ const lastBootstrap = new Date(status.lastBootstrappedAt).getTime()
127
+ if (lockMtime > lastBootstrap) needsRebootstrap = true
128
+ }
129
+
130
+ return JSON.stringify({
131
+ status: "BOOTSTRAPPED",
132
+ message: needsRebootstrap
133
+ ? "Bootstrap previo detectado pero el lockfile es más nuevo. Considera re-bootstrappear con force=true."
134
+ : "Bootstrap vigente.",
135
+ projectRoot: root,
136
+ currentNodeVersion: getNodeVersion(),
137
+ recommendedMinNode: MIN_NODE_VERSION,
138
+ ...status,
139
+ needsRebootstrap,
140
+ }, null, 2)
141
+ }
142
+ })
143
+
144
+ // Tool: sdd_bootstrap_nextjs_shadcn
145
+ export const bootstrap_nextjs_shadcn = tool({
146
+ description: "Inicializa un proyecto Next.js 16 + Shadcn UI + Tailwind v4 + Vitest a partir de la plantilla canónica en .opencode/templates/nextjs-shadcn/. Es IDEMPOTENTE: si el proyecto ya está inicializado y no se pasa force=true, no toca nada. Copia archivo-por-archivo (sin pisar los existentes), mergea package.json, y opcionalmente instala dependencias y shadcn components.",
147
+ args: {
148
+ components: tool.schema.array(tool.schema.string()).default([]).describe("Lista de shadcn components a instalar (ej: ['button','input','table']). Vacío = no instalar shadcn components."),
149
+ install: tool.schema.boolean().default(true).describe("Si true, ejecuta npm/pnpm/yarn install después de copiar. Si false, solo copia archivos."),
150
+ force: tool.schema.boolean().default(false).describe("Si true, sobrescribe archivos existentes. Si false, los salta (mergea package.json)."),
151
+ },
152
+ async execute(args, context) {
153
+ const root = getRoot(context)
154
+ const start = Date.now()
155
+ const templateDir = path.resolve(root, ".opencode/templates/nextjs-shadcn")
156
+
157
+ if (!fs.existsSync(templateDir)) {
158
+ return JSON.stringify({
159
+ status: "ERROR",
160
+ message: `No se encontró la plantilla en ${templateDir}. Verifica que .opencode/ esté intacto.`,
161
+ }, null, 2)
162
+ }
163
+
164
+ const nodeVersion = getNodeVersion()
165
+ if (nodeVersion === "unknown" || semverMajor(nodeVersion) < 20) {
166
+ return JSON.stringify({
167
+ status: "ERROR",
168
+ message: `Node ${nodeVersion} detectado. Next.js 16 requiere >= ${MIN_NODE_VERSION}. Instala/actualiza Node antes de continuar.`,
169
+ nodeVersion,
170
+ recommendedMinNode: MIN_NODE_VERSION,
171
+ }, null, 2)
172
+ }
173
+
174
+ const isInitialized =
175
+ fs.existsSync(path.resolve(root, "src/app/page.tsx")) &&
176
+ fs.existsSync(path.resolve(root, "package.json")) &&
177
+ fs.existsSync(path.resolve(root, "src/lib/utils.ts"))
178
+
179
+ if (isInitialized && !args.force) {
180
+ return JSON.stringify({
181
+ status: "SUCCESS",
182
+ initialized: false,
183
+ message: "Proyecto ya inicializado. Pasa force=true para re-bootstrappear (CUIDADO: sobrescribirá archivos existentes).",
184
+ nextSteps: "Llama sdd_bootstrap_status para ver el estado actual.",
185
+ }, null, 2)
186
+ }
187
+
188
+ const filesCopied: string[] = []
189
+ const filesSkipped: string[] = []
190
+ const copyFileSafe = (rel: string) => {
191
+ const src = path.join(templateDir, rel)
192
+ const dst = path.resolve(root, rel)
193
+ if (!fs.existsSync(src)) return
194
+ const dstExists = fs.existsSync(dst)
195
+ if (dstExists && !args.force) {
196
+ filesSkipped.push(rel)
197
+ return
198
+ }
199
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
200
+ fs.copyFileSync(src, dst)
201
+ filesCopied.push(rel)
202
+ }
203
+
204
+ const userPkgPath = path.resolve(root, "package.json")
205
+ let mergedPkg: any = null
206
+ if (fs.existsSync(userPkgPath) && !args.force) {
207
+ try {
208
+ const userPkg = JSON.parse(fs.readFileSync(userPkgPath, "utf8"))
209
+ const tmplPkg = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8"))
210
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
211
+ if (tmplPkg[key]) {
212
+ userPkg[key] = { ...tmplPkg[key], ...(userPkg[key] || {}) }
213
+ }
214
+ }
215
+ userPkg.scripts = { ...(tmplPkg.scripts || {}), ...(userPkg.scripts || {}) }
216
+ mergedPkg = userPkg
217
+ fs.writeFileSync(userPkgPath, JSON.stringify(userPkg, null, 2), "utf8")
218
+ filesCopied.push("package.json (merged)")
219
+ } catch (e) {
220
+ copyFileSafe("package.json")
221
+ }
222
+ } else {
223
+ copyFileSafe("package.json")
224
+ }
225
+
226
+ const userNextConfig = path.resolve(root, "next.config.ts")
227
+ if (fs.existsSync(userNextConfig) && !args.force) {
228
+ try {
229
+ const content = fs.readFileSync(userNextConfig, "utf8")
230
+ if (!content.includes("output: \"standalone\"") && !content.includes("output: 'standalone'")) {
231
+ const updated = content.replace(
232
+ /(const nextConfig[^{]*\{)/,
233
+ `$1\n output: "standalone",`
234
+ )
235
+ fs.writeFileSync(userNextConfig, updated, "utf8")
236
+ filesCopied.push("next.config.ts (added standalone)")
237
+ } else {
238
+ filesSkipped.push("next.config.ts")
239
+ }
240
+ } catch (e) {
241
+ copyFileSafe("next.config.ts")
242
+ }
243
+ } else {
244
+ copyFileSafe("next.config.ts")
245
+ }
246
+
247
+ const standardFiles = [
248
+ "tsconfig.json",
249
+ "eslint.config.mjs",
250
+ "vitest.config.ts",
251
+ "postcss.config.mjs",
252
+ "components.json",
253
+ "src/app/page.tsx",
254
+ "src/app/layout.tsx",
255
+ "src/app/globals.css",
256
+ "src/lib/utils.ts",
257
+ "src/components/theme-provider.tsx",
258
+ "src/test/setup.ts",
259
+ "public/.gitkeep",
260
+ ]
261
+ for (const f of standardFiles) copyFileSafe(f)
262
+
263
+ const pm = detectPackageManager(root)
264
+
265
+ let installDuration = 0
266
+ let installSkipped = false
267
+ let installError: string | null = null
268
+ const hasNodeModules = fs.existsSync(path.resolve(root, "node_modules"))
269
+ if (args.install && (args.force || !hasNodeModules)) {
270
+ const installStart = Date.now()
271
+ const installCmd = pm === "pnpm"
272
+ ? "pnpm install --prefer-offline --frozen-lockfile"
273
+ : pm === "yarn"
274
+ ? "yarn install --frozen-lockfile"
275
+ : "npm install --prefer-offline"
276
+ try {
277
+ execSync(installCmd, {
278
+ cwd: root,
279
+ stdio: "ignore",
280
+ timeout: 300_000,
281
+ })
282
+ installDuration = Date.now() - installStart
283
+ } catch (e: any) {
284
+ installError = e.message?.slice(0, 500) || "unknown error"
285
+ }
286
+ } else if (hasNodeModules && !args.force) {
287
+ installSkipped = true
288
+ }
289
+
290
+ const componentsInstalled: string[] = []
291
+ let componentsError: string | null = null
292
+ if (args.components && args.components.length > 0) {
293
+ try {
294
+ const cmd = `npx shadcn@latest add ${args.components.join(" ")} --yes --overwrite`
295
+ execSync(cmd, {
296
+ cwd: root,
297
+ stdio: "ignore",
298
+ timeout: 180_000,
299
+ })
300
+ componentsInstalled.push(...args.components)
301
+ } catch (e: any) {
302
+ componentsError = e.message?.slice(0, 500) || "unknown error"
303
+ }
304
+ }
305
+
306
+ const bootstrapRecord = {
307
+ template: "nextjs-shadcn",
308
+ version: TEMPLATE_VERSION,
309
+ lastBootstrappedAt: new Date().toISOString(),
310
+ packageManager: pm,
311
+ nodeVersion,
312
+ filesCopied,
313
+ filesSkipped,
314
+ componentsInstalled,
315
+ installDuration,
316
+ totalDuration: Date.now() - start,
317
+ }
318
+ writeBootstrapStatus(root, bootstrapRecord)
319
+
320
+ let finalPackageJson: any = null
321
+ try {
322
+ finalPackageJson = JSON.parse(fs.readFileSync(userPkgPath, "utf8"))
323
+ } catch (e) { /* ignore */ }
324
+
325
+ return JSON.stringify({
326
+ status: installError || componentsError ? "WARNING" : "SUCCESS",
327
+ initialized: true,
328
+ message: installError
329
+ ? `Bootstrap completo con warnings: install falló. ${installError}`
330
+ : componentsError
331
+ ? `Bootstrap completo con warnings: shadcn add falló. ${componentsError}`
332
+ : `Bootstrap completo en ${Date.now() - start}ms.`,
333
+ packageManager: pm,
334
+ nodeVersion,
335
+ filesCopied,
336
+ filesSkipped,
337
+ installSkipped,
338
+ installDuration,
339
+ componentsInstalled,
340
+ finalPackageJson,
341
+ nextSteps: "Run sdd_start_server({ command: '<pm> dev', port: 3000 }) para arrancar el dev server.",
342
+ _bootstrapRecord: bootstrapRecord,
343
+ }, null, 2)
344
+ }
345
+ })
346
+
347
+ // Tool: sdd_bootstrap_fastapi
348
+ export const bootstrap_fastapi = tool({
349
+ description: "Inicializa un proyecto FastAPI + Pydantic + Uvicorn + Pytest + Ruff a partir de la plantilla canónica en .opencode/templates/fastapi-sdd/. Es IDEMPOTENTE: si el proyecto ya está inicializado y no se pasa force=true, no toca nada. Copia archivo-por-archivo (sin pisar los existentes), opcionalmente instala dependencias con uv (fallback pip).",
350
+ args: {
351
+ extras: tool.schema.array(tool.schema.string()).default([]).describe("Lista de paquetes Python adicionales a instalar (ej: ['sqlalchemy','pydantic-settings','pytest-asyncio']). Vacío = no instalar extras extra."),
352
+ install: tool.schema.boolean().default(true).describe("Si true, ejecuta uv sync (o pip install -e '.[dev]') después de copiar. Si false, solo copia archivos."),
353
+ force: tool.schema.boolean().default(false).describe("Si true, sobrescribe archivos existentes. Si false, los salta."),
354
+ },
355
+ async execute(args, context) {
356
+ const root = getRoot(context)
357
+ const start = Date.now()
358
+ const templateDir = path.resolve(root, ".opencode/templates/fastapi-sdd")
359
+
360
+ if (!fs.existsSync(templateDir)) {
361
+ return JSON.stringify({
362
+ status: "ERROR",
363
+ message: `No se encontró la plantilla en ${templateDir}. Verifica que .opencode/ esté intacto.`,
364
+ }, null, 2)
365
+ }
366
+
367
+ const pythonCheck = checkPythonVersion()
368
+ if (!pythonCheck.ok) {
369
+ return JSON.stringify({
370
+ status: "ERROR",
371
+ message: pythonCheck.message,
372
+ pythonVersion: pythonCheck.version,
373
+ recommendedMinPython: MIN_PYTHON_VERSION,
374
+ }, null, 2)
375
+ }
376
+
377
+ const isInitialized =
378
+ fs.existsSync(path.resolve(root, "src/app/main.py")) &&
379
+ fs.existsSync(path.resolve(root, "pyproject.toml"))
380
+
381
+ if (isInitialized && !args.force) {
382
+ return JSON.stringify({
383
+ status: "SUCCESS",
384
+ initialized: false,
385
+ message: "Proyecto Python ya inicializado. Pasa force=true para re-bootstrappear (CUIDADO: sobrescribirá archivos existentes).",
386
+ nextSteps: "Llama sdd_bootstrap_status para ver el estado actual.",
387
+ }, null, 2)
388
+ }
389
+
390
+ const filesCopied: string[] = []
391
+ const filesSkipped: string[] = []
392
+ const copyFileSafe = (rel: string) => {
393
+ const src = path.join(templateDir, rel)
394
+ const dst = path.resolve(root, rel)
395
+ if (!fs.existsSync(src)) return
396
+ const dstExists = fs.existsSync(dst)
397
+ if (dstExists && !args.force) {
398
+ filesSkipped.push(rel)
399
+ return
400
+ }
401
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
402
+ fs.copyFileSync(src, dst)
403
+ filesCopied.push(rel)
404
+ }
405
+
406
+ const userPyprojectPath = path.resolve(root, "pyproject.toml")
407
+ let mergedPyproject: string | null = null
408
+ if (fs.existsSync(userPyprojectPath) && !args.force) {
409
+ try {
410
+ const userToml = fs.readFileSync(userPyprojectPath, "utf8")
411
+ const tmplToml = fs.readFileSync(path.join(templateDir, "pyproject.toml"), "utf8")
412
+ if (userToml.includes("[project]") || userToml.includes("[tool.")) {
413
+ filesSkipped.push("pyproject.toml (user-defined, not auto-merged)")
414
+ } else {
415
+ fs.writeFileSync(userPyprojectPath, tmplToml, "utf8")
416
+ mergedPyproject = tmplToml
417
+ filesCopied.push("pyproject.toml (template only)")
418
+ }
419
+ } catch (e) {
420
+ copyFileSafe("pyproject.toml")
421
+ }
422
+ } else {
423
+ copyFileSafe("pyproject.toml")
424
+ }
425
+
426
+ const standardFiles = [
427
+ "ruff.toml",
428
+ ".python-version",
429
+ "Dockerfile",
430
+ ".dockerignore",
431
+ "docker-compose.yml",
432
+ "README.md",
433
+ "src/app/__init__.py",
434
+ "src/app/main.py",
435
+ "src/app/core/__init__.py",
436
+ "src/app/core/config.py",
437
+ "src/app/routers/__init__.py",
438
+ "src/app/schemas/__init__.py",
439
+ "src/tests/__init__.py",
440
+ "src/tests/conftest.py",
441
+ "src/tests/test_main.py",
442
+ "tests/__init__.py",
443
+ ]
444
+ for (const f of standardFiles) copyFileSafe(f)
445
+
446
+ const pm = detectPythonPackageManager(root)
447
+
448
+ let installDuration = 0
449
+ let installSkipped = false
450
+ let installError: string | null = null
451
+ const hasVenv = fs.existsSync(path.resolve(root, ".venv"))
452
+ const hasInstalled = fs.existsSync(path.resolve(root, "uv.lock")) || hasVenv
453
+ if (args.install && (args.force || !hasInstalled)) {
454
+ const installStart = Date.now()
455
+ const installCmd = pm === "uv"
456
+ ? "uv sync"
457
+ : "pip install -e '.[dev]'"
458
+ try {
459
+ execSync(installCmd, {
460
+ cwd: root,
461
+ stdio: "ignore",
462
+ timeout: 300_000,
463
+ })
464
+ installDuration = Date.now() - installStart
465
+ } catch (e: any) {
466
+ installError = e.message?.slice(0, 500) || "unknown error"
467
+ }
468
+ } else if (hasInstalled && !args.force) {
469
+ installSkipped = true
470
+ }
471
+
472
+ const extrasInstalled: string[] = []
473
+ let extrasError: string | null = null
474
+ if (args.extras && args.extras.length > 0) {
475
+ const extrasCmd = pm === "uv"
476
+ ? `uv add ${args.extras.join(" ")}`
477
+ : `pip install ${args.extras.join(" ")}`
478
+ try {
479
+ execSync(extrasCmd, {
480
+ cwd: root,
481
+ stdio: "ignore",
482
+ timeout: 180_000,
483
+ })
484
+ extrasInstalled.push(...args.extras)
485
+ } catch (e: any) {
486
+ extrasError = e.message?.slice(0, 500) || "unknown error"
487
+ }
488
+ }
489
+
490
+ const bootstrapRecord = {
491
+ template: "fastapi-sdd",
492
+ version: TEMPLATE_VERSION,
493
+ lastBootstrappedAt: new Date().toISOString(),
494
+ packageManager: pm,
495
+ pythonVersion: pythonCheck.version,
496
+ filesCopied,
497
+ filesSkipped,
498
+ extrasInstalled,
499
+ installDuration,
500
+ totalDuration: Date.now() - start,
501
+ }
502
+ writeBootstrapStatus(root, bootstrapRecord)
503
+
504
+ let finalPyprojectToml: string | null = null
505
+ try {
506
+ finalPyprojectToml = fs.readFileSync(userPyprojectPath, "utf8")
507
+ } catch (e) { /* ignore */ }
508
+
509
+ return JSON.stringify({
510
+ status: installError || extrasError ? "WARNING" : "SUCCESS",
511
+ initialized: true,
512
+ message: installError
513
+ ? `Bootstrap completo con warnings: install falló. ${installError}`
514
+ : extrasError
515
+ ? `Bootstrap completo con warnings: extras add falló. ${extrasError}`
516
+ : `Bootstrap completo en ${Date.now() - start}ms.`,
517
+ packageManager: pm,
518
+ pythonVersion: pythonCheck.version,
519
+ filesCopied,
520
+ filesSkipped,
521
+ installSkipped,
522
+ installDuration,
523
+ extrasInstalled,
524
+ finalPyprojectToml,
525
+ nextSteps: "Run `uv run uvicorn app.main:app --reload --port 8000` (o `uvicorn ...` con venv activo) para arrancar el dev server.",
526
+ _bootstrapRecord: bootstrapRecord,
527
+ }, null, 2)
528
+ }
529
+ })
530
+
531
+ // Tool: sdd_bootstrap_agnostic
532
+ export const bootstrap_agnostic = tool({
533
+ description: "Inicializa un proyecto de tipo script, tooling o agnóstico de forma idempotente (crea un package.json básico, requirements.txt, o estructura plana de archivos según sea necesario). Evita la sobrecarga de frameworks pesados.",
534
+ args: {
535
+ language: tool.schema.enum(["javascript", "python", "bash", "google-apps-script", "plano"]).default("plano").describe("El lenguaje o entorno para inicializar"),
536
+ install: tool.schema.boolean().default(true).describe("Si true, ejecuta npm init -y o uv init de forma básica si aplica.")
537
+ },
538
+ async execute(args, context) {
539
+ const root = getRoot(context)
540
+ const start = Date.now()
541
+ const filesCopied: string[] = []
542
+
543
+ // Create src/ directory as best practice for escalabilidad
544
+ const srcDir = path.resolve(root, "src")
545
+ if (!fs.existsSync(srcDir)) {
546
+ fs.mkdirSync(srcDir, { recursive: true })
547
+ }
548
+
549
+ if (args.language === "javascript") {
550
+ const pkgPath = path.resolve(root, "package.json")
551
+ if (!fs.existsSync(pkgPath) && args.install) {
552
+ try {
553
+ execSync("npm init -y", { cwd: root, stdio: "ignore" })
554
+ filesCopied.push("package.json (auto-generated)")
555
+ } catch (e) {}
556
+ }
557
+ const indexJs = path.join(srcDir, "index.js")
558
+ if (!fs.existsSync(indexJs)) {
559
+ fs.writeFileSync(indexJs, "// Entry point for Javascript Agnostic Script\nconsole.log('Hello, world!');\n", "utf8")
560
+ filesCopied.push("src/index.js")
561
+ }
562
+ } else if (args.language === "python") {
563
+ const pyproject = path.resolve(root, "pyproject.toml")
564
+ const reqTxt = path.resolve(root, "requirements.txt")
565
+
566
+ if (!fs.existsSync(pyproject) && !fs.existsSync(reqTxt) && args.install) {
567
+ try {
568
+ execSync("uv init --lib", { cwd: root, stdio: "ignore" })
569
+ filesCopied.push("pyproject.toml (uv init)")
570
+ } catch (e) {
571
+ try {
572
+ fs.writeFileSync(reqTxt, "# Python requirements\n", "utf8")
573
+ filesCopied.push("requirements.txt")
574
+ } catch (err) {}
575
+ }
576
+ }
577
+ const mainPy = path.join(srcDir, "main.py")
578
+ if (!fs.existsSync(mainPy)) {
579
+ fs.writeFileSync(mainPy, "#!/usr/bin/env python3\n\ndef main():\n print('Hello from Agnostic Python Script')\n\nif __name__ == '__main__':\n main()\n", "utf8")
580
+ filesCopied.push("src/main.py")
581
+ }
582
+ } else if (args.language === "google-apps-script") {
583
+ const appScriptFile = path.join(srcDir, "codigo.gs")
584
+ if (!fs.existsSync(appScriptFile)) {
585
+ fs.writeFileSync(appScriptFile, "function myFunction() {\n Logger.log('Hello from Google Apps Script');\n}\n", "utf8")
586
+ filesCopied.push("src/codigo.gs")
587
+ }
588
+ } else if (args.language === "bash") {
589
+ const scriptSh = path.join(srcDir, "script.sh")
590
+ if (!fs.existsSync(scriptSh)) {
591
+ fs.writeFileSync(scriptSh, "#!/usr/bin/env bash\nset -euo pipefail\necho 'Hello from Bash script!'\n", "utf8")
592
+ filesCopied.push("src/script.sh")
593
+ try { fs.chmodSync(scriptSh, "755") } catch (e) {}
594
+ }
595
+ } else {
596
+ const mainTxt = path.join(srcDir, "README.md")
597
+ if (!fs.existsSync(mainTxt)) {
598
+ fs.writeFileSync(mainTxt, "# Agnostic Flat Script Workspace\n\nPlace your files here.\n", "utf8")
599
+ filesCopied.push("src/README.md")
600
+ }
601
+ }
602
+
603
+ const bootstrapRecord = {
604
+ template: "agnostic-fast",
605
+ version: TEMPLATE_VERSION,
606
+ lastBootstrappedAt: new Date().toISOString(),
607
+ packageManager: args.language === "python" ? "uv" : args.language === "javascript" ? "npm" : "none",
608
+ filesCopied,
609
+ filesSkipped: [],
610
+ componentsInstalled: [],
611
+ installDuration: 0,
612
+ totalDuration: Date.now() - start
613
+ }
614
+
615
+ writeBootstrapStatus(root, bootstrapRecord)
616
+
617
+ return JSON.stringify({
618
+ status: "SUCCESS",
619
+ initialized: true,
620
+ message: `Bootstrap de proyecto agnóstico (${args.language}) completado exitosamente.`,
621
+ filesCopied,
622
+ _bootstrapRecord: bootstrapRecord
623
+ }, null, 2)
624
+ }
625
+ })