@umbral/cli 0.0.2 → 0.0.4
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/dist/index.js +617 -24
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
+
// src/analyze.ts
|
|
7
|
+
import { readdirSync } from "fs";
|
|
8
|
+
import { join as join11 } from "path";
|
|
9
|
+
|
|
6
10
|
// src/detect/node.ts
|
|
7
11
|
import { existsSync, readFileSync } from "fs";
|
|
8
12
|
import { join } from "path";
|
|
@@ -258,17 +262,250 @@ var StylingDetector = class {
|
|
|
258
262
|
}
|
|
259
263
|
};
|
|
260
264
|
|
|
265
|
+
// src/detect/python.ts
|
|
266
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
|
|
267
|
+
import { join as join7 } from "path";
|
|
268
|
+
function readTextFile(path) {
|
|
269
|
+
try {
|
|
270
|
+
return readFileSync7(path, "utf-8");
|
|
271
|
+
} catch {
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function extractPythonDeps(projectPath) {
|
|
276
|
+
const deps = [];
|
|
277
|
+
const reqPath = join7(projectPath, "requirements.txt");
|
|
278
|
+
if (existsSync6(reqPath)) {
|
|
279
|
+
const content = readTextFile(reqPath);
|
|
280
|
+
for (const line of content.split("\n")) {
|
|
281
|
+
const trimmed = line.trim();
|
|
282
|
+
if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("-")) {
|
|
283
|
+
const name = trimmed.split(/[>=<!\[;]/)[0].trim().toLowerCase();
|
|
284
|
+
if (name) deps.push(name);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const pyprojectPath = join7(projectPath, "pyproject.toml");
|
|
289
|
+
if (existsSync6(pyprojectPath)) {
|
|
290
|
+
const content = readTextFile(pyprojectPath);
|
|
291
|
+
const depMatches = content.match(/["']([a-zA-Z0-9_-]+)(?:\[.*?\])?(?:[>=<~!].*?)?["']/g);
|
|
292
|
+
if (depMatches) {
|
|
293
|
+
for (const m of depMatches) {
|
|
294
|
+
const name = m.replace(/["']/g, "").split(/[>=<~!\[]/)[0].trim().toLowerCase();
|
|
295
|
+
if (name && name.length > 1) deps.push(name);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return [...new Set(deps)];
|
|
300
|
+
}
|
|
301
|
+
var PythonDetector = class {
|
|
302
|
+
detect(projectPath) {
|
|
303
|
+
const hasPyproject = existsSync6(join7(projectPath, "pyproject.toml"));
|
|
304
|
+
const hasRequirements = existsSync6(join7(projectPath, "requirements.txt"));
|
|
305
|
+
const hasSetupPy = existsSync6(join7(projectPath, "setup.py"));
|
|
306
|
+
const hasPipfile = existsSync6(join7(projectPath, "Pipfile"));
|
|
307
|
+
const hasPoetryLock = existsSync6(join7(projectPath, "poetry.lock"));
|
|
308
|
+
const hasManagePy = existsSync6(join7(projectPath, "manage.py"));
|
|
309
|
+
if (!hasPyproject && !hasRequirements && !hasSetupPy && !hasPipfile && !hasManagePy) return [];
|
|
310
|
+
const results = [];
|
|
311
|
+
const evidence = [];
|
|
312
|
+
if (hasPyproject) evidence.push("pyproject.toml");
|
|
313
|
+
if (hasRequirements) evidence.push("requirements.txt");
|
|
314
|
+
if (hasSetupPy) evidence.push("setup.py");
|
|
315
|
+
if (hasPipfile) evidence.push("Pipfile");
|
|
316
|
+
let pythonVersion = "";
|
|
317
|
+
if (existsSync6(join7(projectPath, ".python-version"))) {
|
|
318
|
+
pythonVersion = readTextFile(join7(projectPath, ".python-version")).trim();
|
|
319
|
+
}
|
|
320
|
+
results.push({
|
|
321
|
+
category: "runtime",
|
|
322
|
+
name: `Python${pythonVersion ? ` ${pythonVersion}` : ""}`,
|
|
323
|
+
slug: "python",
|
|
324
|
+
confidence: 1,
|
|
325
|
+
evidence,
|
|
326
|
+
metadata: { pythonVersion }
|
|
327
|
+
});
|
|
328
|
+
if (hasPoetryLock) {
|
|
329
|
+
results.push({ category: "package-manager", name: "Poetry", slug: "poetry", confidence: 1, evidence: ["poetry.lock"], metadata: {} });
|
|
330
|
+
} else if (hasPipfile) {
|
|
331
|
+
results.push({ category: "package-manager", name: "Pipenv", slug: "pipenv", confidence: 1, evidence: ["Pipfile"], metadata: {} });
|
|
332
|
+
} else if (hasPyproject) {
|
|
333
|
+
const content = readTextFile(join7(projectPath, "pyproject.toml"));
|
|
334
|
+
if (content.includes("uv")) {
|
|
335
|
+
results.push({ category: "package-manager", name: "uv", slug: "uv", confidence: 0.7, evidence: ["pyproject.toml referencia uv"], metadata: {} });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const deps = extractPythonDeps(projectPath);
|
|
339
|
+
if (deps.includes("fastapi") || deps.includes("fastapi[standard]")) {
|
|
340
|
+
results.push({ category: "framework", name: "FastAPI", slug: "fastapi", confidence: 1, evidence: ["fastapi en dependencies"], metadata: {} });
|
|
341
|
+
} else if (hasManagePy || deps.includes("django")) {
|
|
342
|
+
results.push({ category: "framework", name: "Django", slug: "django", confidence: 1, evidence: [hasManagePy ? "manage.py" : "django en dependencies"], metadata: {} });
|
|
343
|
+
} else if (deps.includes("flask")) {
|
|
344
|
+
results.push({ category: "framework", name: "Flask", slug: "flask", confidence: 1, evidence: ["flask en dependencies"], metadata: {} });
|
|
345
|
+
} else if (deps.includes("streamlit")) {
|
|
346
|
+
results.push({ category: "framework", name: "Streamlit", slug: "streamlit", confidence: 1, evidence: ["streamlit en dependencies"], metadata: {} });
|
|
347
|
+
}
|
|
348
|
+
if (deps.includes("sqlalchemy")) {
|
|
349
|
+
results.push({ category: "database", name: "SQLAlchemy", slug: "sqlalchemy", confidence: 1, evidence: ["sqlalchemy en dependencies"], metadata: {} });
|
|
350
|
+
} else if (deps.includes("supabase")) {
|
|
351
|
+
results.push({ category: "database", name: "Supabase (PostgreSQL)", slug: "supabase", confidence: 1, evidence: ["supabase en dependencies"], metadata: {} });
|
|
352
|
+
} else if (deps.includes("psycopg2") || deps.includes("psycopg2-binary") || deps.includes("asyncpg")) {
|
|
353
|
+
results.push({ category: "database", name: "PostgreSQL", slug: "postgresql-python", confidence: 0.9, evidence: ["driver PostgreSQL en dependencies"], metadata: {} });
|
|
354
|
+
}
|
|
355
|
+
if (deps.includes("pytest")) {
|
|
356
|
+
results.push({ category: "testing", name: "pytest", slug: "pytest", confidence: 1, evidence: ["pytest en dependencies"], metadata: {} });
|
|
357
|
+
} else if (deps.includes("unittest")) {
|
|
358
|
+
results.push({ category: "testing", name: "unittest", slug: "unittest", confidence: 0.8, evidence: ["unittest en dependencies"], metadata: {} });
|
|
359
|
+
}
|
|
360
|
+
if (deps.includes("langchain") || deps.includes("langchain-core") || deps.includes("langgraph")) {
|
|
361
|
+
const parts = [];
|
|
362
|
+
if (deps.includes("langchain") || deps.includes("langchain-core")) parts.push("LangChain");
|
|
363
|
+
if (deps.includes("langgraph")) parts.push("LangGraph");
|
|
364
|
+
results.push({ category: "framework", name: parts.join(" + "), slug: "langchain", confidence: 1, evidence: parts.map((p) => `${p.toLowerCase()} en dependencies`), metadata: {} });
|
|
365
|
+
}
|
|
366
|
+
return results;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// src/detect/go.ts
|
|
371
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
|
|
372
|
+
import { join as join8 } from "path";
|
|
373
|
+
var GoDetector = class {
|
|
374
|
+
detect(projectPath) {
|
|
375
|
+
const goModPath = join8(projectPath, "go.mod");
|
|
376
|
+
if (!existsSync7(goModPath)) return [];
|
|
377
|
+
const results = [];
|
|
378
|
+
let moduleName = "";
|
|
379
|
+
let goVersion = "";
|
|
380
|
+
try {
|
|
381
|
+
const content = readFileSync8(goModPath, "utf-8");
|
|
382
|
+
const moduleMatch = content.match(/^module\s+(\S+)/m);
|
|
383
|
+
const versionMatch = content.match(/^go\s+(\S+)/m);
|
|
384
|
+
if (moduleMatch) moduleName = moduleMatch[1];
|
|
385
|
+
if (versionMatch) goVersion = versionMatch[1];
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
results.push({
|
|
389
|
+
category: "runtime",
|
|
390
|
+
name: `Go${goVersion ? ` ${goVersion}` : ""}`,
|
|
391
|
+
slug: "golang",
|
|
392
|
+
confidence: 1,
|
|
393
|
+
evidence: ["go.mod"],
|
|
394
|
+
metadata: { moduleName, goVersion }
|
|
395
|
+
});
|
|
396
|
+
return results;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// src/detect/rust.ts
|
|
401
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
|
|
402
|
+
import { join as join9 } from "path";
|
|
403
|
+
var RustDetector = class {
|
|
404
|
+
detect(projectPath) {
|
|
405
|
+
const cargoPath = join9(projectPath, "Cargo.toml");
|
|
406
|
+
if (!existsSync8(cargoPath)) return [];
|
|
407
|
+
const results = [];
|
|
408
|
+
let edition = "";
|
|
409
|
+
try {
|
|
410
|
+
const content = readFileSync9(cargoPath, "utf-8");
|
|
411
|
+
const editionMatch = content.match(/edition\s*=\s*"(\d+)"/);
|
|
412
|
+
if (editionMatch) edition = editionMatch[1];
|
|
413
|
+
} catch {
|
|
414
|
+
}
|
|
415
|
+
results.push({
|
|
416
|
+
category: "runtime",
|
|
417
|
+
name: `Rust${edition ? ` (edition ${edition})` : ""}`,
|
|
418
|
+
slug: "rust",
|
|
419
|
+
confidence: 1,
|
|
420
|
+
evidence: ["Cargo.toml"],
|
|
421
|
+
metadata: { edition }
|
|
422
|
+
});
|
|
423
|
+
return results;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// src/detect/docker.ts
|
|
428
|
+
import { existsSync as existsSync9 } from "fs";
|
|
429
|
+
import { join as join10 } from "path";
|
|
430
|
+
var DockerDetector = class {
|
|
431
|
+
detect(projectPath) {
|
|
432
|
+
const results = [];
|
|
433
|
+
if (existsSync9(join10(projectPath, "Dockerfile"))) {
|
|
434
|
+
results.push({
|
|
435
|
+
category: "infra",
|
|
436
|
+
name: "Docker",
|
|
437
|
+
slug: "docker",
|
|
438
|
+
confidence: 1,
|
|
439
|
+
evidence: ["Dockerfile"],
|
|
440
|
+
metadata: {}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const composeNames = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
|
|
444
|
+
const found = composeNames.find((n) => existsSync9(join10(projectPath, n)));
|
|
445
|
+
if (found) {
|
|
446
|
+
results.push({
|
|
447
|
+
category: "infra",
|
|
448
|
+
name: "Docker Compose",
|
|
449
|
+
slug: "docker-compose",
|
|
450
|
+
confidence: 1,
|
|
451
|
+
evidence: [found],
|
|
452
|
+
metadata: {}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return results;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
261
459
|
// src/analyze.ts
|
|
262
460
|
var DETECTORS = [
|
|
263
461
|
new NodeDetector(),
|
|
462
|
+
new PythonDetector(),
|
|
463
|
+
new GoDetector(),
|
|
464
|
+
new RustDetector(),
|
|
264
465
|
new FrameworkDetector(),
|
|
265
466
|
new DatabaseDetector(),
|
|
266
467
|
new TestingDetector(),
|
|
267
468
|
new BuildDetector(),
|
|
268
|
-
new StylingDetector()
|
|
469
|
+
new StylingDetector(),
|
|
470
|
+
new DockerDetector()
|
|
269
471
|
];
|
|
472
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
473
|
+
"node_modules",
|
|
474
|
+
".git",
|
|
475
|
+
".next",
|
|
476
|
+
"dist",
|
|
477
|
+
"build",
|
|
478
|
+
"__pycache__",
|
|
479
|
+
".venv",
|
|
480
|
+
"venv",
|
|
481
|
+
"env",
|
|
482
|
+
".env",
|
|
483
|
+
".tox",
|
|
484
|
+
"coverage",
|
|
485
|
+
".turbo",
|
|
486
|
+
".cache",
|
|
487
|
+
"target",
|
|
488
|
+
"vendor"
|
|
489
|
+
]);
|
|
490
|
+
function runDetectors(dirPath) {
|
|
491
|
+
return DETECTORS.flatMap((d) => d.detect(dirPath));
|
|
492
|
+
}
|
|
270
493
|
function analyzeProject(projectPath) {
|
|
271
|
-
const detections =
|
|
494
|
+
const detections = runDetectors(projectPath);
|
|
495
|
+
try {
|
|
496
|
+
const entries = readdirSync(projectPath, { withFileTypes: true });
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
499
|
+
const subPath = join11(projectPath, entry.name);
|
|
500
|
+
const subDetections = runDetectors(subPath);
|
|
501
|
+
for (const d of subDetections) {
|
|
502
|
+
d.subdir = entry.name;
|
|
503
|
+
d.name = `${d.name} (${entry.name}/)`;
|
|
504
|
+
detections.push(d);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
272
509
|
detections.sort((a, b) => b.confidence - a.confidence);
|
|
273
510
|
return { projectPath, detections };
|
|
274
511
|
}
|
|
@@ -425,14 +662,347 @@ var TEMPLATES = [
|
|
|
425
662
|
"No saltarse el pipeline de Turbo ejecutando scripts directamente en paquetes interdependientes.",
|
|
426
663
|
"No ignorar los outputs de cache en turbo.json."
|
|
427
664
|
]
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
slugs: ["fastapi"],
|
|
668
|
+
cognitiveLevel: "navigator",
|
|
669
|
+
complexityTier: 2,
|
|
670
|
+
title: () => "FastAPI como framework backend",
|
|
671
|
+
decision: () => "El backend utiliza FastAPI para servir la API HTTP con validacion automatica y documentacion OpenAPI.",
|
|
672
|
+
mechanism: () => "Endpoints async definidos con decoradores. Pydantic para validacion de request/response. Uvicorn como servidor ASGI.",
|
|
673
|
+
rationale: () => "FastAPI combina rendimiento async con type-safety via Pydantic y genera documentacion OpenAPI automaticamente.",
|
|
674
|
+
alternatives: () => [
|
|
675
|
+
{ option: "Django REST Framework", rejectedBecause: "Mayor overhead para APIs puras sin necesidad de ORM integrado." },
|
|
676
|
+
{ option: "Flask", rejectedBecause: "Sin validacion nativa ni soporte async." }
|
|
677
|
+
],
|
|
678
|
+
antiPatterns: () => [
|
|
679
|
+
"No usar funciones sincronas bloqueantes en endpoints async.",
|
|
680
|
+
"No omitir modelos Pydantic para validacion de entrada.",
|
|
681
|
+
"No exponer excepciones internas sin un exception handler."
|
|
682
|
+
]
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
slugs: ["django"],
|
|
686
|
+
cognitiveLevel: "navigator",
|
|
687
|
+
complexityTier: 2,
|
|
688
|
+
title: () => "Django como framework backend",
|
|
689
|
+
decision: () => "El backend utiliza Django como framework web full-stack.",
|
|
690
|
+
mechanism: () => "Models definen el esquema de datos. Views procesan requests. URLs mapean rutas. Migraciones via manage.py migrate.",
|
|
691
|
+
rationale: () => "Django provee ORM, auth, admin y migraciones out-of-the-box. Ideal para aplicaciones con modelo de datos relacional complejo.",
|
|
692
|
+
alternatives: () => [
|
|
693
|
+
{ option: "FastAPI", rejectedBecause: "No incluye ORM ni admin panel integrados." }
|
|
694
|
+
],
|
|
695
|
+
antiPatterns: () => [
|
|
696
|
+
"No ejecutar queries N+1 en views sin select_related/prefetch_related.",
|
|
697
|
+
"No modificar modelos sin crear migraciones."
|
|
698
|
+
]
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
slugs: ["flask"],
|
|
702
|
+
cognitiveLevel: "explorer",
|
|
703
|
+
complexityTier: 1,
|
|
704
|
+
title: () => "Flask como framework backend",
|
|
705
|
+
decision: () => "El backend utiliza Flask como micro-framework HTTP.",
|
|
706
|
+
mechanism: () => "Rutas definidas con decoradores. Blueprints para modularizar. Extensions para funcionalidad adicional.",
|
|
707
|
+
rationale: () => "Flask es minimalista y flexible. Permite elegir cada componente (ORM, auth, etc.) independientemente.",
|
|
708
|
+
alternatives: () => [
|
|
709
|
+
{ option: "FastAPI", rejectedBecause: "Mayor complejidad inicial para APIs simples." }
|
|
710
|
+
],
|
|
711
|
+
antiPatterns: () => [
|
|
712
|
+
"No almacenar estado mutable en variables globales del modulo.",
|
|
713
|
+
"No omitir manejo de errores en endpoints."
|
|
714
|
+
]
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
slugs: ["langchain"],
|
|
718
|
+
cognitiveLevel: "anchor",
|
|
719
|
+
complexityTier: 3,
|
|
720
|
+
title: () => "LangChain/LangGraph como framework de agentes AI",
|
|
721
|
+
decision: () => "La orquestacion de agentes AI utiliza LangChain y/o LangGraph para flujos multi-paso con LLMs.",
|
|
722
|
+
mechanism: () => "LangGraph define grafos de estado con nodos y edges. Cada nodo ejecuta una accion (tool call, LLM call). El estado se propaga entre nodos.",
|
|
723
|
+
rationale: () => "LangGraph permite flujos de agentes complejos con ciclos, branching y estado persistente. LangChain provee abstracciones para tools, memory y prompts.",
|
|
724
|
+
alternatives: () => [
|
|
725
|
+
{ option: "Llamadas directas a la API del LLM", rejectedBecause: "Sin soporte para grafos de estado, reintentos ni tool calling estandarizado." },
|
|
726
|
+
{ option: "CrewAI", rejectedBecause: "Menor control sobre el flujo de ejecucion." }
|
|
727
|
+
],
|
|
728
|
+
antiPatterns: () => [
|
|
729
|
+
"No hardcodear prompts sin versionarlos o parametrizarlos.",
|
|
730
|
+
"No omitir manejo de errores en tool calls (timeouts, rate limits).",
|
|
731
|
+
"No ignorar el costo de tokens en flujos con multiples LLM calls."
|
|
732
|
+
]
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
slugs: ["sqlalchemy"],
|
|
736
|
+
cognitiveLevel: "anchor",
|
|
737
|
+
complexityTier: 2,
|
|
738
|
+
title: () => "SQLAlchemy como ORM Python",
|
|
739
|
+
decision: () => "La persistencia utiliza SQLAlchemy para modelado relacional con type-safety.",
|
|
740
|
+
mechanism: () => "Modelos declarativos con Column definitions. Sessions para transacciones. Alembic para migraciones.",
|
|
741
|
+
rationale: () => "SQLAlchemy es el ORM mas maduro de Python. Soporta multiples backends SQL y ofrece tanto ORM como Core para queries.",
|
|
742
|
+
alternatives: () => [
|
|
743
|
+
{ option: "Django ORM", rejectedBecause: "Acoplado al framework Django." }
|
|
744
|
+
],
|
|
745
|
+
antiPatterns: () => [
|
|
746
|
+
"No crear sesiones sin cerrarlas (usar context manager).",
|
|
747
|
+
"No ejecutar queries en loops sin batch/bulk operations."
|
|
748
|
+
]
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
slugs: ["supabase"],
|
|
752
|
+
cognitiveLevel: "anchor",
|
|
753
|
+
complexityTier: 2,
|
|
754
|
+
title: () => "Supabase como backend-as-a-service",
|
|
755
|
+
decision: () => "La persistencia y autenticacion utilizan Supabase (PostgreSQL + Auth + Storage + Realtime).",
|
|
756
|
+
mechanism: () => "Cliente Supabase conecta a PostgreSQL via REST/Realtime. Auth con JWT. Storage para archivos. Row Level Security para autorizacion.",
|
|
757
|
+
rationale: () => "Supabase provee PostgreSQL managed con auth, storage y realtime integrados. Reduce la necesidad de infraestructura propia.",
|
|
758
|
+
alternatives: () => [
|
|
759
|
+
{ option: "Firebase", rejectedBecause: "NoSQL (Firestore) vs SQL (PostgreSQL); vendor lock-in mas fuerte." },
|
|
760
|
+
{ option: "PostgreSQL autohosteado", rejectedBecause: "Requiere mantener infraestructura de auth, storage y realtime por separado." }
|
|
761
|
+
],
|
|
762
|
+
antiPatterns: () => [
|
|
763
|
+
"No omitir Row Level Security en tablas con datos de usuario.",
|
|
764
|
+
"No exponer la service_role key en el frontend.",
|
|
765
|
+
"No hacer queries complejas sin indices en las columnas filtradas."
|
|
766
|
+
]
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
slugs: ["pytest"],
|
|
770
|
+
cognitiveLevel: "explorer",
|
|
771
|
+
complexityTier: 1,
|
|
772
|
+
title: () => "pytest como framework de testing",
|
|
773
|
+
decision: () => "Los tests del proyecto Python utilizan pytest.",
|
|
774
|
+
mechanism: () => "Tests como funciones con prefijo test_. Fixtures para setup/teardown. Markers para categorizar. conftest.py para configuracion compartida.",
|
|
775
|
+
rationale: () => "pytest es el standard de facto en Python. Fixtures, parametrize y plugins lo hacen extensible sin boilerplate.",
|
|
776
|
+
alternatives: () => [
|
|
777
|
+
{ option: "unittest", rejectedBecause: "Requiere clases y mas boilerplate." }
|
|
778
|
+
],
|
|
779
|
+
antiPatterns: () => [
|
|
780
|
+
"No compartir estado mutable entre tests sin fixtures con scope adecuado.",
|
|
781
|
+
"No omitir fixtures de limpieza para recursos externos (DB, files)."
|
|
782
|
+
]
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
slugs: ["python"],
|
|
786
|
+
cognitiveLevel: "explorer",
|
|
787
|
+
complexityTier: 1,
|
|
788
|
+
title: () => "Python como runtime del backend",
|
|
789
|
+
decision: () => "El proyecto utiliza Python como lenguaje principal del backend.",
|
|
790
|
+
mechanism: () => "Entorno virtual para aislamiento de dependencias. pip/poetry/uv para gestion de paquetes.",
|
|
791
|
+
rationale: () => "Python tiene el ecosistema mas amplio para AI/ML, data processing y APIs web.",
|
|
792
|
+
alternatives: () => [
|
|
793
|
+
{ option: "Node.js", rejectedBecause: "Menor ecosistema de librerias AI/ML nativas." },
|
|
794
|
+
{ option: "Go", rejectedBecause: "Menor productividad para prototipado rapido." }
|
|
795
|
+
],
|
|
796
|
+
antiPatterns: () => [
|
|
797
|
+
"No instalar paquetes fuera del entorno virtual.",
|
|
798
|
+
"No commitear el entorno virtual (venv/) al repositorio."
|
|
799
|
+
]
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
slugs: ["golang"],
|
|
803
|
+
cognitiveLevel: "explorer",
|
|
804
|
+
complexityTier: 1,
|
|
805
|
+
title: () => "Go como runtime principal",
|
|
806
|
+
decision: () => "El proyecto utiliza Go como lenguaje de desarrollo.",
|
|
807
|
+
mechanism: () => "Go modules para gestion de dependencias. go build para compilacion. go test para testing.",
|
|
808
|
+
rationale: () => "Go ofrece compilacion rapida, binarios estaticos y concurrencia nativa con goroutines.",
|
|
809
|
+
alternatives: () => [
|
|
810
|
+
{ option: "Rust", rejectedBecause: "Curva de aprendizaje mas pronunciada." },
|
|
811
|
+
{ option: "Node.js", rejectedBecause: "Menor rendimiento en workloads CPU-bound." }
|
|
812
|
+
],
|
|
813
|
+
antiPatterns: () => [
|
|
814
|
+
"No ignorar errores retornados (no usar _ sin justificacion).",
|
|
815
|
+
"No compartir estado mutable entre goroutines sin sincronizacion."
|
|
816
|
+
]
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
slugs: ["rust"],
|
|
820
|
+
cognitiveLevel: "navigator",
|
|
821
|
+
complexityTier: 2,
|
|
822
|
+
title: () => "Rust como runtime principal",
|
|
823
|
+
decision: () => "El proyecto utiliza Rust para rendimiento y seguridad de memoria.",
|
|
824
|
+
mechanism: () => "Cargo para build y dependencias. Ownership system para seguridad de memoria sin GC. Crates.io como registry.",
|
|
825
|
+
rationale: () => "Rust garantiza seguridad de memoria en compile-time sin garbage collector. Ideal para sistemas de alto rendimiento.",
|
|
826
|
+
alternatives: () => [
|
|
827
|
+
{ option: "C++", rejectedBecause: "Sin garantias de seguridad de memoria en compile-time." },
|
|
828
|
+
{ option: "Go", rejectedBecause: "GC introduce latencia impredecible." }
|
|
829
|
+
],
|
|
830
|
+
antiPatterns: () => [
|
|
831
|
+
"No usar unsafe sin documentar la invariante de seguridad.",
|
|
832
|
+
"No ignorar warnings del compilador."
|
|
833
|
+
]
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
slugs: ["docker"],
|
|
837
|
+
cognitiveLevel: "explorer",
|
|
838
|
+
complexityTier: 1,
|
|
839
|
+
title: () => "Docker como entorno de containerizacion",
|
|
840
|
+
decision: () => "El proyecto utiliza Docker para empaquetar y desplegar la aplicacion.",
|
|
841
|
+
mechanism: () => "Dockerfile define la imagen. Multi-stage builds para optimizar tamano. .dockerignore para excluir archivos innecesarios.",
|
|
842
|
+
rationale: () => "Docker garantiza reproducibilidad del entorno entre desarrollo y produccion.",
|
|
843
|
+
alternatives: () => [
|
|
844
|
+
{ option: "Despliegue directo", rejectedBecause: "Sin aislamiento ni reproducibilidad del entorno." }
|
|
845
|
+
],
|
|
846
|
+
antiPatterns: () => [
|
|
847
|
+
"No instalar dependencias de desarrollo en la imagen de produccion.",
|
|
848
|
+
"No correr el proceso como root en el contenedor."
|
|
849
|
+
]
|
|
428
850
|
}
|
|
429
851
|
];
|
|
430
852
|
function getTemplate(detection) {
|
|
431
853
|
return TEMPLATES.find((t) => t.slugs.includes(detection.slug)) ?? null;
|
|
432
854
|
}
|
|
433
855
|
|
|
856
|
+
// src/claude-generate.ts
|
|
857
|
+
import { spawnSync } from "child_process";
|
|
858
|
+
function isClaudeAvailable() {
|
|
859
|
+
try {
|
|
860
|
+
const r = spawnSync("claude", ["--version"], {
|
|
861
|
+
timeout: 5e3,
|
|
862
|
+
shell: true,
|
|
863
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
864
|
+
});
|
|
865
|
+
return r.status === 0;
|
|
866
|
+
} catch {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
function buildPrompt(detections) {
|
|
871
|
+
const detectionsJson = JSON.stringify(
|
|
872
|
+
detections.map((d) => ({
|
|
873
|
+
name: d.name,
|
|
874
|
+
slug: d.slug,
|
|
875
|
+
category: d.category,
|
|
876
|
+
confidence: d.confidence,
|
|
877
|
+
evidence: d.evidence,
|
|
878
|
+
metadata: d.metadata,
|
|
879
|
+
subdir: d.subdir
|
|
880
|
+
})),
|
|
881
|
+
null,
|
|
882
|
+
2
|
|
883
|
+
);
|
|
884
|
+
return `Eres Umbral, un framework de gobernanza para proyectos de software. Bas\xE1ndote en las tecnolog\xEDas detectadas abajo, genera Estructuras de Decisi\xF3n Expl\xEDcitas (EDEs) \u2014 decisiones arquitect\xF3nicas formalizadas y espec\xEDficas para ESTE proyecto.
|
|
885
|
+
|
|
886
|
+
## Tecnolog\xEDas Detectadas
|
|
887
|
+
${detectionsJson}
|
|
888
|
+
|
|
889
|
+
## Esquema JSON de una EDE
|
|
890
|
+
Cada EDE debe seguir EXACTAMENTE esta estructura:
|
|
891
|
+
{
|
|
892
|
+
"id": "EDE-001-slug",
|
|
893
|
+
"title": "string",
|
|
894
|
+
"version": 1,
|
|
895
|
+
"status": "proposed",
|
|
896
|
+
"cognitiveLevel": "explorer" | "navigator" | "anchor",
|
|
897
|
+
"complexityTier": 1 | 2 | 3,
|
|
898
|
+
"whatAndHow": { "decision": "string", "mechanism": "string" },
|
|
899
|
+
"why": {
|
|
900
|
+
"rationale": "string",
|
|
901
|
+
"alternativesConsidered": [{ "option": "string", "rejectedBecause": "string" }],
|
|
902
|
+
"references": []
|
|
903
|
+
},
|
|
904
|
+
"whatNotToDo": { "antiPatterns": ["string"] },
|
|
905
|
+
"whatsNext": { "continuations": [], "openQuestions": [] },
|
|
906
|
+
"contracts": { "layerContracts": [], "verifiedBy": [] },
|
|
907
|
+
"tests": { "unitTests": [], "sadPaths": [], "coverageTarget": 0.8 },
|
|
908
|
+
"provenance": { "phase": "onboarding", "slice": null, "createdBy": "claude", "createdAt": null, "lastUpdated": null }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
## Instrucciones
|
|
912
|
+
- Genera una EDE por cada decisi\xF3n arquitect\xF3nica significativa
|
|
913
|
+
- S\xE9 ESPEC\xCDFICO para este proyecto: usa la evidencia detectada para personalizar cada decisi\xF3n
|
|
914
|
+
- Incluye anti-patrones relevantes a la COMBINACI\xD3N de tecnolog\xEDas (no gen\xE9ricos)
|
|
915
|
+
- IDs: EDE-001-slug, EDE-002-slug, etc.
|
|
916
|
+
- cognitiveLevel: "explorer" para simples, "navigator" para intermedias, "anchor" para invariantes
|
|
917
|
+
- complexityTier: 1 simple, 2 medio, 3 complejo
|
|
918
|
+
- Todo el contenido en espa\xF1ol
|
|
919
|
+
- Genera entre 3 y 10 EDEs seg\xFAn la complejidad del proyecto
|
|
920
|
+
|
|
921
|
+
Retorna \xDANICAMENTE un array JSON v\xE1lido. Sin markdown, sin backticks, sin texto adicional.`;
|
|
922
|
+
}
|
|
923
|
+
function extractJson(text) {
|
|
924
|
+
const codeBlock = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
925
|
+
if (codeBlock) return codeBlock[1].trim();
|
|
926
|
+
const arrayMatch = text.match(/\[[\s\S]*\]/);
|
|
927
|
+
if (arrayMatch) return arrayMatch[0];
|
|
928
|
+
return text.trim();
|
|
929
|
+
}
|
|
930
|
+
function normalizeEde(raw, index) {
|
|
931
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
932
|
+
return {
|
|
933
|
+
id: raw.id ?? `EDE-${String(index + 1).padStart(3, "0")}-generated`,
|
|
934
|
+
title: raw.title ?? "EDE generada por Claude",
|
|
935
|
+
version: 1,
|
|
936
|
+
status: "proposed",
|
|
937
|
+
cognitiveLevel: raw.cognitiveLevel ?? "explorer",
|
|
938
|
+
complexityTier: raw.complexityTier ?? 1,
|
|
939
|
+
whatAndHow: {
|
|
940
|
+
decision: raw.whatAndHow?.decision ?? "",
|
|
941
|
+
mechanism: raw.whatAndHow?.mechanism ?? ""
|
|
942
|
+
},
|
|
943
|
+
why: {
|
|
944
|
+
rationale: raw.why?.rationale ?? "",
|
|
945
|
+
alternativesConsidered: Array.isArray(raw.why?.alternativesConsidered) ? raw.why.alternativesConsidered : [],
|
|
946
|
+
references: []
|
|
947
|
+
},
|
|
948
|
+
whatNotToDo: {
|
|
949
|
+
antiPatterns: Array.isArray(raw.whatNotToDo?.antiPatterns) ? raw.whatNotToDo.antiPatterns : []
|
|
950
|
+
},
|
|
951
|
+
whatsNext: {
|
|
952
|
+
continuations: [],
|
|
953
|
+
openQuestions: Array.isArray(raw.whatsNext?.openQuestions) ? raw.whatsNext.openQuestions : []
|
|
954
|
+
},
|
|
955
|
+
contracts: {
|
|
956
|
+
layerContracts: Array.isArray(raw.contracts?.layerContracts) ? raw.contracts.layerContracts : [],
|
|
957
|
+
verifiedBy: []
|
|
958
|
+
},
|
|
959
|
+
tests: {
|
|
960
|
+
unitTests: [],
|
|
961
|
+
sadPaths: [],
|
|
962
|
+
coverageTarget: raw.tests?.coverageTarget ?? 0.8
|
|
963
|
+
},
|
|
964
|
+
provenance: {
|
|
965
|
+
phase: "onboarding",
|
|
966
|
+
slice: null,
|
|
967
|
+
createdBy: "claude",
|
|
968
|
+
createdAt: now,
|
|
969
|
+
lastUpdated: now
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function generateWithClaude(detections) {
|
|
974
|
+
if (detections.length === 0) return null;
|
|
975
|
+
const prompt = buildPrompt(detections);
|
|
976
|
+
try {
|
|
977
|
+
const result = spawnSync("claude", ["--print"], {
|
|
978
|
+
input: prompt,
|
|
979
|
+
encoding: "utf-8",
|
|
980
|
+
timeout: 12e4,
|
|
981
|
+
shell: true,
|
|
982
|
+
maxBuffer: 5 * 1024 * 1024
|
|
983
|
+
});
|
|
984
|
+
if (result.status !== 0 || !result.stdout) return null;
|
|
985
|
+
const jsonStr = extractJson(result.stdout);
|
|
986
|
+
const parsed = JSON.parse(jsonStr);
|
|
987
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
988
|
+
const edes = [];
|
|
989
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
990
|
+
try {
|
|
991
|
+
const ede = normalizeEde(parsed[i], i);
|
|
992
|
+
if (ede.whatAndHow.decision && ede.why.rationale) {
|
|
993
|
+
edes.push(ede);
|
|
994
|
+
}
|
|
995
|
+
} catch {
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return edes.length > 0 ? edes : null;
|
|
999
|
+
} catch {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
434
1004
|
// src/generate.ts
|
|
435
|
-
function
|
|
1005
|
+
function generateFromTemplates(detections) {
|
|
436
1006
|
const proposals = [];
|
|
437
1007
|
let counter = 1;
|
|
438
1008
|
for (const detection of detections) {
|
|
@@ -484,6 +1054,17 @@ function generateProposals(detections) {
|
|
|
484
1054
|
}
|
|
485
1055
|
return proposals;
|
|
486
1056
|
}
|
|
1057
|
+
function generateProposals(detections, onStatus) {
|
|
1058
|
+
if (isClaudeAvailable()) {
|
|
1059
|
+
onStatus?.("Generando EDEs con Claude Code...");
|
|
1060
|
+
const claudeEdes = generateWithClaude(detections);
|
|
1061
|
+
if (claudeEdes && claudeEdes.length > 0) {
|
|
1062
|
+
return { proposals: claudeEdes, source: "claude" };
|
|
1063
|
+
}
|
|
1064
|
+
onStatus?.("Claude no pudo generar EDEs, usando templates...");
|
|
1065
|
+
}
|
|
1066
|
+
return { proposals: generateFromTemplates(detections), source: "templates" };
|
|
1067
|
+
}
|
|
487
1068
|
|
|
488
1069
|
// src/ui.ts
|
|
489
1070
|
import { createInterface } from "readline/promises";
|
|
@@ -738,16 +1319,16 @@ function setupDatabase(edes) {
|
|
|
738
1319
|
}
|
|
739
1320
|
|
|
740
1321
|
// src/setup/hooks.ts
|
|
741
|
-
import { existsSync as
|
|
742
|
-
import { join as
|
|
1322
|
+
import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
1323
|
+
import { join as join12 } from "path";
|
|
743
1324
|
function setupHooks(projectPath) {
|
|
744
|
-
const claudeDir =
|
|
1325
|
+
const claudeDir = join12(projectPath, ".claude");
|
|
745
1326
|
mkdirSync2(claudeDir, { recursive: true });
|
|
746
|
-
const settingsPath =
|
|
1327
|
+
const settingsPath = join12(claudeDir, "settings.json");
|
|
747
1328
|
let existing = {};
|
|
748
|
-
if (
|
|
1329
|
+
if (existsSync10(settingsPath)) {
|
|
749
1330
|
try {
|
|
750
|
-
existing = JSON.parse(
|
|
1331
|
+
existing = JSON.parse(readFileSync10(settingsPath, "utf-8"));
|
|
751
1332
|
} catch {
|
|
752
1333
|
}
|
|
753
1334
|
}
|
|
@@ -794,7 +1375,7 @@ function setupHooks(projectPath) {
|
|
|
794
1375
|
|
|
795
1376
|
// src/setup/context.ts
|
|
796
1377
|
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
797
|
-
import { join as
|
|
1378
|
+
import { join as join13 } from "path";
|
|
798
1379
|
|
|
799
1380
|
// ../orchestrator/src/claude-context.ts
|
|
800
1381
|
function assembleClaudeContext(edes) {
|
|
@@ -897,9 +1478,9 @@ function setupContext(projectPath) {
|
|
|
897
1478
|
const edes = createEdeStore(db).getAll();
|
|
898
1479
|
db.close();
|
|
899
1480
|
const content = assembleClaudeContext(edes);
|
|
900
|
-
const claudeDir =
|
|
1481
|
+
const claudeDir = join13(projectPath, ".claude");
|
|
901
1482
|
mkdirSync3(claudeDir, { recursive: true });
|
|
902
|
-
writeFileSync2(
|
|
1483
|
+
writeFileSync2(join13(claudeDir, "CLAUDE.md"), content, "utf-8");
|
|
903
1484
|
}
|
|
904
1485
|
|
|
905
1486
|
// src/commands/init.ts
|
|
@@ -918,7 +1499,19 @@ async function initCommand(options) {
|
|
|
918
1499
|
`);
|
|
919
1500
|
}
|
|
920
1501
|
}
|
|
921
|
-
|
|
1502
|
+
w("\n");
|
|
1503
|
+
const { proposals, source } = generateProposals(
|
|
1504
|
+
analysis.detections,
|
|
1505
|
+
(msg) => w(` ${msg}
|
|
1506
|
+
`)
|
|
1507
|
+
);
|
|
1508
|
+
if (source === "claude") {
|
|
1509
|
+
w(` \u2713 ${proposals.length} EDEs generadas con Claude Code (IA)
|
|
1510
|
+
`);
|
|
1511
|
+
} else {
|
|
1512
|
+
w(` \u21B3 EDEs generadas desde templates locales
|
|
1513
|
+
`);
|
|
1514
|
+
}
|
|
922
1515
|
if (proposals.length === 0) {
|
|
923
1516
|
w("\n No hay propuestas de EDEs para generar.\n");
|
|
924
1517
|
w(" Configurando infraestructura base...\n\n");
|
|
@@ -1040,11 +1633,11 @@ async function hookCommand(_event) {
|
|
|
1040
1633
|
|
|
1041
1634
|
// src/commands/mcp.ts
|
|
1042
1635
|
import { execFileSync } from "child_process";
|
|
1043
|
-
import { join as
|
|
1636
|
+
import { join as join14, dirname as dirname2 } from "path";
|
|
1044
1637
|
import { fileURLToPath } from "url";
|
|
1045
1638
|
async function mcpCommand() {
|
|
1046
1639
|
const __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1047
|
-
const mcpEntry =
|
|
1640
|
+
const mcpEntry = join14(__dirname, "mcp-entry.js");
|
|
1048
1641
|
execFileSync(process.execPath, [mcpEntry], {
|
|
1049
1642
|
stdio: "inherit"
|
|
1050
1643
|
});
|
|
@@ -1053,11 +1646,11 @@ async function mcpCommand() {
|
|
|
1053
1646
|
// src/commands/start.ts
|
|
1054
1647
|
import { execSync, spawn } from "child_process";
|
|
1055
1648
|
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
|
|
1056
|
-
import { join as
|
|
1649
|
+
import { join as join15 } from "path";
|
|
1057
1650
|
import { homedir as homedir3 } from "os";
|
|
1058
1651
|
import { createServer } from "net";
|
|
1059
|
-
var UMBRAL_DIR =
|
|
1060
|
-
var COMPOSE_PATH =
|
|
1652
|
+
var UMBRAL_DIR = join15(homedir3(), ".umbral");
|
|
1653
|
+
var COMPOSE_PATH = join15(UMBRAL_DIR, "docker-compose.yml");
|
|
1061
1654
|
var DOCKER_IMAGE = process.env.UMBRAL_IMAGE ?? "ghcr.io/josephrobles23/umbral-web:latest";
|
|
1062
1655
|
function dockerInstalled() {
|
|
1063
1656
|
try {
|
|
@@ -1090,7 +1683,7 @@ function findFreePort(start) {
|
|
|
1090
1683
|
});
|
|
1091
1684
|
}
|
|
1092
1685
|
function generateCompose(webPort, wsPort) {
|
|
1093
|
-
const dbPath =
|
|
1686
|
+
const dbPath = join15(UMBRAL_DIR, "umbral.db").replace(/\\/g, "/");
|
|
1094
1687
|
const dbDir = UMBRAL_DIR.replace(/\\/g, "/");
|
|
1095
1688
|
return `# Auto-generated by Umbral CLI \u2014 do not edit manually
|
|
1096
1689
|
name: umbral
|
|
@@ -1189,14 +1782,14 @@ async function startCommand(options) {
|
|
|
1189
1782
|
|
|
1190
1783
|
// src/commands/stop.ts
|
|
1191
1784
|
import { execSync as execSync2 } from "child_process";
|
|
1192
|
-
import { existsSync as
|
|
1193
|
-
import { join as
|
|
1785
|
+
import { existsSync as existsSync12 } from "fs";
|
|
1786
|
+
import { join as join16 } from "path";
|
|
1194
1787
|
import { homedir as homedir4 } from "os";
|
|
1195
|
-
var COMPOSE_PATH2 =
|
|
1788
|
+
var COMPOSE_PATH2 = join16(homedir4(), ".umbral", "docker-compose.yml");
|
|
1196
1789
|
function stopCommand() {
|
|
1197
1790
|
const w = (s) => process.stdout.write(s);
|
|
1198
1791
|
w("\n Umbral \u2014 Deteniendo plataforma\n\n");
|
|
1199
|
-
if (!
|
|
1792
|
+
if (!existsSync12(COMPOSE_PATH2)) {
|
|
1200
1793
|
w(" \u2717 No se encontro docker-compose.yml en ~/.umbral/\n");
|
|
1201
1794
|
w(" Ejecuta 'umbral start' primero.\n\n");
|
|
1202
1795
|
process.exit(1);
|
|
@@ -1214,7 +1807,7 @@ function stopCommand() {
|
|
|
1214
1807
|
|
|
1215
1808
|
// src/index.ts
|
|
1216
1809
|
var program = new Command();
|
|
1217
|
-
program.name("umbral").description("Umbral \u2014 Framework de gobernanza para proyectos con Claude Code").version("0.0.
|
|
1810
|
+
program.name("umbral").description("Umbral \u2014 Framework de gobernanza para proyectos con Claude Code").version("0.0.4");
|
|
1218
1811
|
program.command("init").description("Inicializar Umbral en el proyecto actual").option("--yes", "Aceptar todas las propuestas sin preguntar").option("--path <path>", "Ruta al proyecto (default: directorio actual)").action(initCommand);
|
|
1219
1812
|
program.command("start").description("Levantar la plataforma Umbral (Neo4j + Dashboard web)").option("--port <port>", "Puerto para el dashboard (default: auto)").option("--no-detach", "Correr en primer plano (sin -d)").action(startCommand);
|
|
1220
1813
|
program.command("stop").description("Detener la plataforma Umbral").action(stopCommand);
|