zugzbot 1.0.18 → 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.
- package/.opencode/agents/sdd-coder.md +37 -5
- package/.opencode/agents/sdd-deployer.md +4 -1
- package/.opencode/agents/sdd-orchestrator.md +33 -12
- package/.opencode/agents/sdd-reviewer.md +53 -0
- package/.opencode/agents/sdd-spec-writer.md +14 -2
- package/.opencode/agents/sdd-tester.md +27 -4
- package/.opencode/commands/fast.md +19 -0
- package/.opencode/commands/reset.md +1 -1
- package/.opencode/commands/review.md +18 -0
- package/.opencode/contract-schema.json +8 -3
- package/.opencode/plugins/sdd-bridge.ts +96 -1
- package/.opencode/tools/brain.ts +26 -15
- package/.opencode/tools/fast-track-init.js +36 -0
- package/.opencode/tools/sdd_bootstrap.ts +625 -0
- package/.opencode/tools/sdd_core.ts +673 -0
- package/.opencode/tools/sdd_design.ts +303 -0
- package/.opencode/tools/sdd_docker.ts +254 -0
- package/.opencode/tools/sdd_network.ts +152 -0
- package/.opencode/tools/sdd_testing.ts +362 -0
- package/opencode.json +0 -108
- package/package.json +1 -1
- package/tui.json +9 -1
- package/.opencode/tools/sdd.ts +0 -2072
|
@@ -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
|
+
})
|