@cristiancorreau/forge 2.9.6 → 2.9.8
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/README.md +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate.js +1 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/lib/generators/codex.d.ts.map +1 -1
- package/dist/lib/generators/codex.js +10 -8
- package/dist/lib/generators/codex.js.map +1 -1
- package/dist/lib/generators/kiro.d.ts +1 -1
- package/dist/lib/generators/kiro.d.ts.map +1 -1
- package/dist/lib/generators/kiro.js +15 -16
- package/dist/lib/generators/kiro.js.map +1 -1
- package/dist/lib/generators/opencode.d.ts.map +1 -1
- package/dist/lib/generators/opencode.js +13 -12
- package/dist/lib/generators/opencode.js.map +1 -1
- package/dist/lib/paths.d.ts +1 -2
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +12 -16
- package/dist/lib/paths.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/assets/adapters/claude-code/generate-claude-md.py +0 -304
- package/assets/adapters/codex/generate-codex-config.py +0 -269
- package/assets/adapters/codex/hooks/codex.yaml.tpl +0 -43
- package/assets/adapters/codex/hooks/forge-codex-finish.sh +0 -158
- package/assets/adapters/codex/hooks/forge-codex-start.sh +0 -186
- package/assets/adapters/kiro/generate-steering.py +0 -367
- package/assets/adapters/opencode/generate-agents-md.py +0 -262
- package/assets/core/hooks/pre-bash-check.py +0 -202
- package/assets/core/hooks/pre-edit-check.py +0 -317
- package/assets/forge.py +0 -1265
- package/assets/requirements.txt +0 -2
- package/assets/scripts/aitmpl-search.py +0 -808
- package/assets/scripts/forge-add-opportunities.py +0 -92
- package/assets/scripts/forge-audit.py +0 -1061
- package/assets/scripts/forge-generate-all.py +0 -283
- package/assets/scripts/forge-init.py +0 -900
- package/assets/scripts/forge-migrate-project-yaml.py +0 -397
- package/assets/scripts/forge-scaffold-profile.py +0 -181
- package/assets/scripts/forge-teardown.py +0 -193
- package/assets/scripts/forge-validate-project-yaml.py +0 -457
- package/assets/scripts/forge-wizard.py +0 -1003
- package/assets/scripts/setup-codex.sh +0 -229
- package/assets/scripts/team-install.sh +0 -147
- package/assets/scripts/token-stats.py +0 -201
- package/dist/lib/python.d.ts +0 -4
- package/dist/lib/python.d.ts.map +0 -1
- package/dist/lib/python.js +0 -46
- package/dist/lib/python.js.map +0 -1
package/assets/forge.py
DELETED
|
@@ -1,1265 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# Copyright 2026 Cristian Correa — Apache License 2.0
|
|
3
|
-
# https://github.com/cristiancorreau/forge
|
|
4
|
-
"""
|
|
5
|
-
forge — CLI principal del framework de desarrollo con agentes IA.
|
|
6
|
-
|
|
7
|
-
Uso:
|
|
8
|
-
python3 .agentic/forge.py
|
|
9
|
-
python3 .agentic/forge.py --help
|
|
10
|
-
"""
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import importlib.util
|
|
14
|
-
import json
|
|
15
|
-
import os
|
|
16
|
-
import re
|
|
17
|
-
import sys
|
|
18
|
-
import subprocess
|
|
19
|
-
import textwrap
|
|
20
|
-
try:
|
|
21
|
-
import termios
|
|
22
|
-
import tty
|
|
23
|
-
except ImportError:
|
|
24
|
-
print(
|
|
25
|
-
"ERROR: forge requiere Unix o macOS.\n"
|
|
26
|
-
" Los módulos 'termios' y 'tty' no están disponibles en Windows.\n"
|
|
27
|
-
" Alternativas: WSL (Windows Subsystem for Linux) o ejecutar en macOS/Linux.",
|
|
28
|
-
file=sys.stderr,
|
|
29
|
-
)
|
|
30
|
-
sys.exit(1)
|
|
31
|
-
from pathlib import Path
|
|
32
|
-
from typing import Optional
|
|
33
|
-
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
# Versión y rutas
|
|
36
|
-
# ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
VERSION = "2.0.1"
|
|
39
|
-
FORGE_DIR = Path(__file__).parent
|
|
40
|
-
SCRIPTS = FORGE_DIR / "scripts"
|
|
41
|
-
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
# Terminal helpers
|
|
44
|
-
# ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
IS_TTY = sys.stdin.isatty() and sys.stdout.isatty()
|
|
47
|
-
|
|
48
|
-
HIDE_CURSOR = "\033[?25l"
|
|
49
|
-
SHOW_CURSOR = "\033[?25h"
|
|
50
|
-
RESET = "\033[0m"
|
|
51
|
-
BOLD = "\033[1m"
|
|
52
|
-
DIM = "\033[2m"
|
|
53
|
-
CYAN = "\033[36m"
|
|
54
|
-
GREEN = "\033[32m"
|
|
55
|
-
YELLOW = "\033[33m"
|
|
56
|
-
RED = "\033[31m"
|
|
57
|
-
MAGENTA = "\033[35m"
|
|
58
|
-
BG_SEL = "\033[48;5;235m" # selección: fondo gris oscuro
|
|
59
|
-
FG_DESC = "\033[38;5;252m" # descripción: gris claro
|
|
60
|
-
ACCENT = "\033[38;5;75m" # acento: azul claro (key hints)
|
|
61
|
-
FG_MUTED = "\033[38;5;240m" # separadores y texto secundario
|
|
62
|
-
|
|
63
|
-
# Pills de categoría: (bg_color, fg_color, label_4chars)
|
|
64
|
-
_CAT_PILL: dict[str, tuple[str, str, str]] = {
|
|
65
|
-
"framework": ("\033[48;5;55m", "\033[97m", " FW "),
|
|
66
|
-
"mcp-server": ("\033[48;5;23m", "\033[97m", "MCP "),
|
|
67
|
-
"profile": ("\033[48;5;22m", "\033[97m", "PRF "),
|
|
68
|
-
"tool": ("\033[48;5;130m", "\033[97m", " TL "),
|
|
69
|
-
"resource": ("\033[48;5;17m", "\033[97m", "DOC "),
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
_ANSI_ESC = re.compile(r'\x1b\[[0-9;?]*[a-zA-Z]')
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def clr() -> None:
|
|
76
|
-
os.system("clear")
|
|
77
|
-
|
|
78
|
-
def write(text: str) -> None:
|
|
79
|
-
sys.stdout.write(text)
|
|
80
|
-
sys.stdout.flush()
|
|
81
|
-
|
|
82
|
-
def b(t: str) -> str: return f"{BOLD}{t}{RESET}"
|
|
83
|
-
def d(t: str) -> str: return f"{DIM}{t}{RESET}"
|
|
84
|
-
def c(t: str) -> str: return f"{CYAN}{t}{RESET}"
|
|
85
|
-
def g(t: str) -> str: return f"{GREEN}{t}{RESET}"
|
|
86
|
-
def y(t: str) -> str: return f"{YELLOW}{t}{RESET}"
|
|
87
|
-
def r(t: str) -> str: return f"{RED}{t}{RESET}"
|
|
88
|
-
|
|
89
|
-
def _strip_ansi(s: str) -> str:
|
|
90
|
-
return _ANSI_ESC.sub("", s)
|
|
91
|
-
|
|
92
|
-
def _padded(s: str, width: int) -> str:
|
|
93
|
-
"""Pad s to visible width, ignorando códigos ANSI."""
|
|
94
|
-
return s + " " * max(0, width - len(_strip_ansi(s)))
|
|
95
|
-
|
|
96
|
-
def _hint(key: str, action: str) -> str:
|
|
97
|
-
return f"{ACCENT}{key}{RESET} {DIM}{action}{RESET}"
|
|
98
|
-
|
|
99
|
-
def _cat_pill(category: str) -> str:
|
|
100
|
-
bg, fg, label = _CAT_PILL.get(category, ("\033[48;5;238m", "\033[97m", " ?? "))
|
|
101
|
-
return f"{bg}{fg}{label}{RESET}"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def getch() -> str:
|
|
105
|
-
fd = sys.stdin.fileno()
|
|
106
|
-
old = termios.tcgetattr(fd)
|
|
107
|
-
try:
|
|
108
|
-
tty.setraw(fd)
|
|
109
|
-
ch = sys.stdin.read(1)
|
|
110
|
-
if ch == "\x1b":
|
|
111
|
-
ch2 = sys.stdin.read(1)
|
|
112
|
-
ch3 = sys.stdin.read(1)
|
|
113
|
-
return f"\x1b{ch2}{ch3}"
|
|
114
|
-
return ch
|
|
115
|
-
finally:
|
|
116
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
KEY_UP = "\x1b[A"
|
|
120
|
-
KEY_DOWN = "\x1b[B"
|
|
121
|
-
KEY_ENTER = "\r"
|
|
122
|
-
KEY_Q = "q"
|
|
123
|
-
KEY_ESC = "\x1b"
|
|
124
|
-
KEY_CTRL_C = "\x03"
|
|
125
|
-
|
|
126
|
-
# ---------------------------------------------------------------------------
|
|
127
|
-
# MenuItem
|
|
128
|
-
# ---------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
class MenuItem:
|
|
131
|
-
def __init__(
|
|
132
|
-
self,
|
|
133
|
-
label: str,
|
|
134
|
-
key: Optional[str] = None,
|
|
135
|
-
separator: bool = False,
|
|
136
|
-
description: str = "",
|
|
137
|
-
):
|
|
138
|
-
self.label = label
|
|
139
|
-
self.key = key
|
|
140
|
-
self.separator = separator
|
|
141
|
-
self.description = description # texto explicativo que aparece al seleccionar
|
|
142
|
-
|
|
143
|
-
# ---------------------------------------------------------------------------
|
|
144
|
-
# Panel de descripción
|
|
145
|
-
# ---------------------------------------------------------------------------
|
|
146
|
-
|
|
147
|
-
DESC_WIDTH = 50 # ancho interior del panel
|
|
148
|
-
|
|
149
|
-
def _draw_description(text: str) -> None:
|
|
150
|
-
if not text:
|
|
151
|
-
print()
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
border = "─" * DESC_WIDTH
|
|
155
|
-
wrapped = textwrap.wrap(text, width=DESC_WIDTH - 2)
|
|
156
|
-
|
|
157
|
-
print(f" {FG_MUTED}╭{border}╮{RESET}")
|
|
158
|
-
for line in wrapped:
|
|
159
|
-
padding = DESC_WIDTH - 2 - len(line)
|
|
160
|
-
print(f" {FG_MUTED}│{RESET} {FG_DESC}{line}{' ' * padding}{RESET} {FG_MUTED}│{RESET}")
|
|
161
|
-
print(f" {FG_MUTED}╰{border}╯{RESET}")
|
|
162
|
-
|
|
163
|
-
# ---------------------------------------------------------------------------
|
|
164
|
-
# Menú genérico con flechas + panel de descripción
|
|
165
|
-
# ---------------------------------------------------------------------------
|
|
166
|
-
|
|
167
|
-
def show_menu(
|
|
168
|
-
title: str,
|
|
169
|
-
items: list[MenuItem],
|
|
170
|
-
subtitle: str = "",
|
|
171
|
-
initial: int = 0,
|
|
172
|
-
) -> Optional[str]:
|
|
173
|
-
"""
|
|
174
|
-
Menú navegable con ↑↓ Enter. Muestra la descripción del ítem activo
|
|
175
|
-
en un panel debajo de la lista. Retorna el key seleccionado o None.
|
|
176
|
-
"""
|
|
177
|
-
selectable = [i for i, it in enumerate(items) if not it.separator]
|
|
178
|
-
if not selectable:
|
|
179
|
-
return None
|
|
180
|
-
|
|
181
|
-
cursor = initial
|
|
182
|
-
if cursor not in selectable:
|
|
183
|
-
cursor = selectable[0]
|
|
184
|
-
|
|
185
|
-
write(HIDE_CURSOR)
|
|
186
|
-
try:
|
|
187
|
-
while True:
|
|
188
|
-
clr()
|
|
189
|
-
_draw_header()
|
|
190
|
-
print(f"\n {b(title)}")
|
|
191
|
-
if subtitle:
|
|
192
|
-
print(f" {d(subtitle)}")
|
|
193
|
-
print()
|
|
194
|
-
|
|
195
|
-
for idx, item in enumerate(items):
|
|
196
|
-
if item.separator:
|
|
197
|
-
print(f" {FG_MUTED}{'─' * 40}{RESET}")
|
|
198
|
-
continue
|
|
199
|
-
if idx == cursor:
|
|
200
|
-
marker = f"{ACCENT}❯{RESET}"
|
|
201
|
-
label = f"{BG_SEL} {_padded(item.label, 43)}{RESET}"
|
|
202
|
-
else:
|
|
203
|
-
marker = " "
|
|
204
|
-
label = f" {item.label}"
|
|
205
|
-
print(f" {marker}{label}")
|
|
206
|
-
|
|
207
|
-
print()
|
|
208
|
-
current_desc = items[cursor].description if not items[cursor].separator else ""
|
|
209
|
-
_draw_description(current_desc)
|
|
210
|
-
print()
|
|
211
|
-
print(f" {_hint('↑↓', 'navegar')} {_hint('⏎', 'seleccionar')} {_hint('q', 'salir')}")
|
|
212
|
-
|
|
213
|
-
ch = getch()
|
|
214
|
-
|
|
215
|
-
if ch in (KEY_CTRL_C, KEY_Q, KEY_ESC):
|
|
216
|
-
return None
|
|
217
|
-
if ch == KEY_UP:
|
|
218
|
-
pos = selectable.index(cursor)
|
|
219
|
-
cursor = selectable[(pos - 1) % len(selectable)]
|
|
220
|
-
elif ch == KEY_DOWN:
|
|
221
|
-
pos = selectable.index(cursor)
|
|
222
|
-
cursor = selectable[(pos + 1) % len(selectable)]
|
|
223
|
-
elif ch == KEY_ENTER:
|
|
224
|
-
return items[cursor].key
|
|
225
|
-
finally:
|
|
226
|
-
write(SHOW_CURSOR)
|
|
227
|
-
|
|
228
|
-
# ---------------------------------------------------------------------------
|
|
229
|
-
# Header
|
|
230
|
-
# ---------------------------------------------------------------------------
|
|
231
|
-
|
|
232
|
-
def _draw_header() -> None:
|
|
233
|
-
W = 54
|
|
234
|
-
inner = "─" * W
|
|
235
|
-
badge = f"\033[48;5;238m\033[97m v{VERSION} {RESET}" # pill de versión
|
|
236
|
-
name = f"{CYAN}{BOLD}forge{RESET}"
|
|
237
|
-
dot = f" {FG_MUTED}·{RESET} "
|
|
238
|
-
tag = f"{DIM}Agentic Development Framework{RESET}"
|
|
239
|
-
content = f" {name} {badge}{dot}{tag}"
|
|
240
|
-
print(f" {CYAN}╭{inner}╮{RESET}")
|
|
241
|
-
print(f" {CYAN}│{RESET}{_padded(content, W)}{CYAN}│{RESET}")
|
|
242
|
-
print(f" {CYAN}╰{inner}╯{RESET}")
|
|
243
|
-
|
|
244
|
-
# ---------------------------------------------------------------------------
|
|
245
|
-
# Utilidades
|
|
246
|
-
# ---------------------------------------------------------------------------
|
|
247
|
-
|
|
248
|
-
def pause(msg: str = "Presiona Enter para volver al menú…") -> None:
|
|
249
|
-
write(SHOW_CURSOR)
|
|
250
|
-
print(f"\n {d(msg)}", flush=True)
|
|
251
|
-
try:
|
|
252
|
-
# Limpiar stdin residual del subprocess antes de leer
|
|
253
|
-
import termios as _t
|
|
254
|
-
_t.tcflush(sys.stdin.fileno(), _t.TCIFLUSH)
|
|
255
|
-
except Exception:
|
|
256
|
-
pass
|
|
257
|
-
try:
|
|
258
|
-
input()
|
|
259
|
-
except (KeyboardInterrupt, EOFError):
|
|
260
|
-
pass
|
|
261
|
-
write(HIDE_CURSOR)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def run_script(script: Path, *args: str) -> int:
|
|
265
|
-
clr()
|
|
266
|
-
_draw_header()
|
|
267
|
-
print()
|
|
268
|
-
cmd = [sys.executable, str(script)] + list(args)
|
|
269
|
-
print(f" {d('$ ' + ' '.join(cmd))}\n")
|
|
270
|
-
result = subprocess.run(cmd)
|
|
271
|
-
return result.returncode
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _ask_input(prompt: str, default: str = "") -> str:
|
|
275
|
-
write(SHOW_CURSOR)
|
|
276
|
-
hint = f" {d(f'[{default}]')}" if default else ""
|
|
277
|
-
try:
|
|
278
|
-
val = input(f" {c('?')} {prompt}{hint}: ").strip()
|
|
279
|
-
except (KeyboardInterrupt, EOFError):
|
|
280
|
-
return default
|
|
281
|
-
finally:
|
|
282
|
-
write(HIDE_CURSOR)
|
|
283
|
-
return val if val else default
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _ask_yes_no(prompt: str, default: bool = True) -> bool:
|
|
287
|
-
write(SHOW_CURSOR)
|
|
288
|
-
hint = "S/n" if default else "s/N"
|
|
289
|
-
try:
|
|
290
|
-
raw = input(f" {c('?')} {prompt} {d(f'[{hint}]')}: ").strip().lower()
|
|
291
|
-
except (KeyboardInterrupt, EOFError):
|
|
292
|
-
return default
|
|
293
|
-
finally:
|
|
294
|
-
write(HIDE_CURSOR)
|
|
295
|
-
if not raw:
|
|
296
|
-
return default
|
|
297
|
-
return raw in ("s", "si", "sí", "y", "yes")
|
|
298
|
-
|
|
299
|
-
# ---------------------------------------------------------------------------
|
|
300
|
-
# Catálogo — helpers de integración
|
|
301
|
-
# ---------------------------------------------------------------------------
|
|
302
|
-
|
|
303
|
-
_AITMPL_MOD = None # cache del módulo cargado
|
|
304
|
-
|
|
305
|
-
def _load_aitmpl_mod():
|
|
306
|
-
global _AITMPL_MOD
|
|
307
|
-
if _AITMPL_MOD is not None:
|
|
308
|
-
return _AITMPL_MOD
|
|
309
|
-
script = SCRIPTS / "aitmpl-search.py"
|
|
310
|
-
spec = importlib.util.spec_from_file_location("aitmpl_search", script)
|
|
311
|
-
mod = importlib.util.module_from_spec(spec)
|
|
312
|
-
spec.loader.exec_module(mod)
|
|
313
|
-
_AITMPL_MOD = mod
|
|
314
|
-
return mod
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
CATEGORY_LABELS_MENU = {
|
|
318
|
-
"framework": "Framework",
|
|
319
|
-
"mcp-server": "MCP Server",
|
|
320
|
-
"profile": "Profile forge",
|
|
321
|
-
"tool": "Herramienta",
|
|
322
|
-
"resource": "Recurso",
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _mcp_slug(item: dict) -> str:
|
|
327
|
-
install = item.get("install")
|
|
328
|
-
if install:
|
|
329
|
-
return install["slug"]
|
|
330
|
-
name = item.get("name", "")
|
|
331
|
-
if "—" in name:
|
|
332
|
-
return name.split("—", 1)[1].strip().split()[0].lower()
|
|
333
|
-
return name.lower().replace(" ", "-")
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _profile_slug(item: dict) -> str:
|
|
337
|
-
name = item.get("name", "")
|
|
338
|
-
if "—" in name:
|
|
339
|
-
return name.split("—", 1)[1].strip().lower()
|
|
340
|
-
return name.lower().replace(" ", "-")
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _copy_to_clipboard(text: str) -> None:
|
|
344
|
-
try:
|
|
345
|
-
if sys.platform == "darwin":
|
|
346
|
-
subprocess.run(["pbcopy"], input=text.encode(), check=True, timeout=3)
|
|
347
|
-
else:
|
|
348
|
-
try:
|
|
349
|
-
subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode(), check=True, timeout=3)
|
|
350
|
-
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
351
|
-
subprocess.run(["xsel", "--clipboard", "--input"], input=text.encode(), check=True, timeout=3)
|
|
352
|
-
clr()
|
|
353
|
-
_draw_header()
|
|
354
|
-
print(f"\n {g('Copiado al portapapeles.')}\n {d(text)}")
|
|
355
|
-
except Exception:
|
|
356
|
-
clr()
|
|
357
|
-
_draw_header()
|
|
358
|
-
print(f"\n URL: {text}")
|
|
359
|
-
pause()
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def _load_settings() -> dict:
|
|
363
|
-
f = Path.cwd() / ".claude" / "settings.json"
|
|
364
|
-
if not f.exists():
|
|
365
|
-
return {}
|
|
366
|
-
try:
|
|
367
|
-
with open(f) as fh:
|
|
368
|
-
return json.load(fh)
|
|
369
|
-
except (json.JSONDecodeError, OSError):
|
|
370
|
-
return {}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def _save_settings(data: dict) -> None:
|
|
374
|
-
f = Path.cwd() / ".claude" / "settings.json"
|
|
375
|
-
f.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
-
with open(f, "w") as fh:
|
|
377
|
-
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
378
|
-
fh.write("\n")
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
def _is_mcp_installed(slug: str) -> bool:
|
|
382
|
-
return slug in _load_settings().get("mcpServers", {})
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def _install_mcp_server(item: dict) -> None:
|
|
386
|
-
install = item["install"]
|
|
387
|
-
slug = install["slug"]
|
|
388
|
-
params = install.get("params", [])
|
|
389
|
-
env_defs = install.get("env", [])
|
|
390
|
-
|
|
391
|
-
clr()
|
|
392
|
-
_draw_header()
|
|
393
|
-
print(f"\n Instalando {b(item['name'])}\n")
|
|
394
|
-
|
|
395
|
-
# Prompts para parámetros en args
|
|
396
|
-
values: dict[str, str] = {}
|
|
397
|
-
for p in params:
|
|
398
|
-
default = p.get("default", "")
|
|
399
|
-
val = _ask_input(p["label"], default)
|
|
400
|
-
values[p["key"]] = val if val else default
|
|
401
|
-
|
|
402
|
-
# Construir args finales sustituyendo placeholders
|
|
403
|
-
final_args = []
|
|
404
|
-
for a in install.get("args", []):
|
|
405
|
-
try:
|
|
406
|
-
final_args.append(a.format_map(values))
|
|
407
|
-
except KeyError:
|
|
408
|
-
final_args.append(a)
|
|
409
|
-
|
|
410
|
-
# Prompts para variables de entorno
|
|
411
|
-
env_block: dict[str, str] = {}
|
|
412
|
-
for e in env_defs:
|
|
413
|
-
val = _ask_input(e["label"], e.get("default", ""))
|
|
414
|
-
if val:
|
|
415
|
-
env_block[e["key"]] = val
|
|
416
|
-
|
|
417
|
-
# Construir config del server
|
|
418
|
-
server_cfg: dict = {
|
|
419
|
-
"command": install["command"],
|
|
420
|
-
"args": final_args,
|
|
421
|
-
}
|
|
422
|
-
if env_block:
|
|
423
|
-
server_cfg["env"] = env_block
|
|
424
|
-
|
|
425
|
-
# Merge en settings.json
|
|
426
|
-
settings = _load_settings()
|
|
427
|
-
already = slug in settings.get("mcpServers", {})
|
|
428
|
-
if "mcpServers" not in settings:
|
|
429
|
-
settings["mcpServers"] = {}
|
|
430
|
-
settings["mcpServers"][slug] = server_cfg
|
|
431
|
-
_save_settings(settings)
|
|
432
|
-
|
|
433
|
-
print()
|
|
434
|
-
if already:
|
|
435
|
-
print(f" {y('Actualizado')} — '{slug}' ya existía en .claude/settings.json (reemplazado).")
|
|
436
|
-
else:
|
|
437
|
-
print(f" {g('Instalado')} — '{slug}' agregado a .claude/settings.json.")
|
|
438
|
-
print(f"\n Reinicia Claude Code para activar el servidor MCP.")
|
|
439
|
-
pause()
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def _find_project_yaml() -> Optional[Path]:
|
|
443
|
-
cwd = Path.cwd()
|
|
444
|
-
for candidate in [cwd / "project.yaml", cwd / ".claude" / "project.yaml"]:
|
|
445
|
-
if candidate.exists():
|
|
446
|
-
return candidate
|
|
447
|
-
return None
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def _is_profile_in_project(slug: str) -> bool:
|
|
451
|
-
yaml_file = _find_project_yaml()
|
|
452
|
-
if not yaml_file:
|
|
453
|
-
return False
|
|
454
|
-
try:
|
|
455
|
-
import yaml # type: ignore
|
|
456
|
-
with open(yaml_file) as f:
|
|
457
|
-
data = yaml.safe_load(f)
|
|
458
|
-
return slug in (data or {}).get("agents", {}).get("profiles", [])
|
|
459
|
-
except Exception:
|
|
460
|
-
return False
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _add_profile_to_project(slug: str) -> None:
|
|
464
|
-
yaml_file = _find_project_yaml()
|
|
465
|
-
if not yaml_file:
|
|
466
|
-
clr()
|
|
467
|
-
_draw_header()
|
|
468
|
-
print(f"\n {r('No se encontró project.yaml')} en el directorio actual.")
|
|
469
|
-
print(f" Ejecuta primero el wizard para crear el proyecto.")
|
|
470
|
-
pause()
|
|
471
|
-
return
|
|
472
|
-
|
|
473
|
-
try:
|
|
474
|
-
import yaml # type: ignore
|
|
475
|
-
except ImportError:
|
|
476
|
-
clr()
|
|
477
|
-
_draw_header()
|
|
478
|
-
print(f"\n {r('PyYAML no está instalado.')} Instálalo con: pip install pyyaml")
|
|
479
|
-
pause()
|
|
480
|
-
return
|
|
481
|
-
|
|
482
|
-
try:
|
|
483
|
-
with open(yaml_file) as f:
|
|
484
|
-
data = yaml.safe_load(f) or {}
|
|
485
|
-
|
|
486
|
-
agents = data.setdefault("agents", {})
|
|
487
|
-
profiles = agents.setdefault("profiles", [])
|
|
488
|
-
|
|
489
|
-
clr()
|
|
490
|
-
_draw_header()
|
|
491
|
-
if slug in profiles:
|
|
492
|
-
print(f"\n El profile '{b(slug)}' ya está en project.yaml.")
|
|
493
|
-
else:
|
|
494
|
-
profiles.append(slug)
|
|
495
|
-
with open(yaml_file, "w") as f:
|
|
496
|
-
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
|
497
|
-
print(f"\n {g('Agregado')} — profile '{b(slug)}' añadido a project.yaml.")
|
|
498
|
-
print(f"\n Ejecuta 'Inicializar agentes' para instalar el agente en el proyecto.")
|
|
499
|
-
pause()
|
|
500
|
-
except Exception as exc:
|
|
501
|
-
clr()
|
|
502
|
-
_draw_header()
|
|
503
|
-
print(f"\n {r('Error al modificar project.yaml:')} {exc}")
|
|
504
|
-
pause()
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
def _build_result_menu(results: list[dict]) -> list[MenuItem]:
|
|
508
|
-
items = []
|
|
509
|
-
for i, r in enumerate(results):
|
|
510
|
-
cat = r.get("category", "")
|
|
511
|
-
pill = _cat_pill(cat) # 4 visible chars
|
|
512
|
-
name = r.get("name", "")
|
|
513
|
-
display = name.split("—", 1)[1].strip() if "—" in name else name
|
|
514
|
-
if len(display) > 37:
|
|
515
|
-
display = display[:34] + "…"
|
|
516
|
-
label = f"{pill} {display}" # pill(4) + space(1) + display(≤37) = ≤42 visible
|
|
517
|
-
items.append(MenuItem(label, key=str(i), description=r.get("description", "")))
|
|
518
|
-
items.append(MenuItem("", separator=True))
|
|
519
|
-
items.append(MenuItem("← Volver", key="back", description="Regresa al menú de búsqueda."))
|
|
520
|
-
return items
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def _action_menu_item(item: dict) -> None:
|
|
524
|
-
category = item.get("category", "")
|
|
525
|
-
name = item.get("name", "")
|
|
526
|
-
url = item.get("url", "")
|
|
527
|
-
|
|
528
|
-
actions: list[MenuItem] = []
|
|
529
|
-
|
|
530
|
-
# MCP server: instalar
|
|
531
|
-
if category == "mcp-server" and item.get("install"):
|
|
532
|
-
slug = _mcp_slug(item)
|
|
533
|
-
already = _is_mcp_installed(slug)
|
|
534
|
-
if already:
|
|
535
|
-
label = f"{g('✓')} Instalado — reinstalar ({slug})"
|
|
536
|
-
desc = f"'{slug}' ya está en .claude/settings.json. Reinstalar sobreescribirá la config."
|
|
537
|
-
else:
|
|
538
|
-
label = f"+ Instalar en proyecto (.claude/settings.json)"
|
|
539
|
-
desc = "Agrega este MCP server a .claude/settings.json con los parámetros que indiques."
|
|
540
|
-
actions.append(MenuItem(label, key="install", description=desc))
|
|
541
|
-
|
|
542
|
-
# Profile: agregar a project.yaml
|
|
543
|
-
if category == "profile":
|
|
544
|
-
slug = _profile_slug(item)
|
|
545
|
-
already = _is_profile_in_project(slug)
|
|
546
|
-
if already:
|
|
547
|
-
actions.append(MenuItem(
|
|
548
|
-
f"{g('✓')} En project.yaml ({slug})", key="noop",
|
|
549
|
-
description=f"El profile '{slug}' ya está en agents.profiles de project.yaml.",
|
|
550
|
-
))
|
|
551
|
-
else:
|
|
552
|
-
actions.append(MenuItem(
|
|
553
|
-
f"+ Agregar a project.yaml ({slug})", key="add-profile",
|
|
554
|
-
description=f"Añade '{slug}' a agents.profiles en project.yaml. Luego ejecuta 'Inicializar agentes'.",
|
|
555
|
-
))
|
|
556
|
-
|
|
557
|
-
# Abrir en browser / copiar URL
|
|
558
|
-
if url:
|
|
559
|
-
actions.append(MenuItem(
|
|
560
|
-
"↗ Abrir en browser", key="open",
|
|
561
|
-
description=f"Abre {url[:60]} en el navegador predeterminado.",
|
|
562
|
-
))
|
|
563
|
-
actions.append(MenuItem(
|
|
564
|
-
"⎘ Copiar URL", key="copy",
|
|
565
|
-
description=url,
|
|
566
|
-
))
|
|
567
|
-
|
|
568
|
-
actions.append(MenuItem("", separator=True))
|
|
569
|
-
actions.append(MenuItem("← Volver", key="back", description="Regresa a la lista de resultados."))
|
|
570
|
-
|
|
571
|
-
title_short = name if len(name) <= 52 else name[:49] + "…"
|
|
572
|
-
key = show_menu(title_short, actions)
|
|
573
|
-
|
|
574
|
-
if key == "install":
|
|
575
|
-
_install_mcp_server(item)
|
|
576
|
-
elif key == "add-profile":
|
|
577
|
-
_add_profile_to_project(_profile_slug(item))
|
|
578
|
-
elif key == "open":
|
|
579
|
-
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
580
|
-
subprocess.Popen([opener, url])
|
|
581
|
-
elif key == "copy":
|
|
582
|
-
_copy_to_clipboard(url)
|
|
583
|
-
|
|
584
|
-
# ---------------------------------------------------------------------------
|
|
585
|
-
# Submenús
|
|
586
|
-
# ---------------------------------------------------------------------------
|
|
587
|
-
|
|
588
|
-
def menu_wizard() -> None:
|
|
589
|
-
items = [
|
|
590
|
-
MenuItem(
|
|
591
|
-
"Automático — detecta el modo según equipo", key="auto",
|
|
592
|
-
description=(
|
|
593
|
-
"Pregunta cuántas personas hay en el equipo y elige el modo correcto: "
|
|
594
|
-
"Startup (1-2 personas), Standard (3-8) o Enterprise (9+). "
|
|
595
|
-
"Luego guía la selección de stack, deploy, runtime y compliance."
|
|
596
|
-
),
|
|
597
|
-
),
|
|
598
|
-
MenuItem(
|
|
599
|
-
"Startup — 1-2 personas", key="startup",
|
|
600
|
-
description=(
|
|
601
|
-
"Configuración mínima para equipos pequeños o proyectos en exploración. "
|
|
602
|
-
"Un solo agente de implementación, sin fases de sprint formales y SDD opcional. "
|
|
603
|
-
"Ideal para prototipos y MVPs donde el overhead debe ser mínimo."
|
|
604
|
-
),
|
|
605
|
-
),
|
|
606
|
-
MenuItem(
|
|
607
|
-
"Standard — 3-8 personas", key="standard",
|
|
608
|
-
description=(
|
|
609
|
-
"Configuración completa con roster de agentes, fases de sprint A/B, "
|
|
610
|
-
"skills de seguridad y features. Pensado para equipos de producto "
|
|
611
|
-
"que trabajan con Claude Code de forma activa en el loop de desarrollo."
|
|
612
|
-
),
|
|
613
|
-
),
|
|
614
|
-
MenuItem(
|
|
615
|
-
"Enterprise — 9+ personas", key="enterprise",
|
|
616
|
-
description=(
|
|
617
|
-
"Configuración con compliance activo (GDPR, Ley 21719, etc.), "
|
|
618
|
-
"audit logs, security-auditor obligatorio y 4 fases de sprint. "
|
|
619
|
-
"Incluye hint para integrar forge-audit --json en pipelines de CI."
|
|
620
|
-
),
|
|
621
|
-
),
|
|
622
|
-
MenuItem("", separator=True),
|
|
623
|
-
MenuItem("← Volver", key="back", description="Regresa al menú principal."),
|
|
624
|
-
]
|
|
625
|
-
key = show_menu(
|
|
626
|
-
"Nuevo proyecto — elegir modo",
|
|
627
|
-
items,
|
|
628
|
-
subtitle="El wizard genera project.yaml y opcionalmente ejecuta forge-init",
|
|
629
|
-
)
|
|
630
|
-
if not key or key == "back":
|
|
631
|
-
return
|
|
632
|
-
args: list[str] = [] if key == "auto" else [f"--mode={key}"]
|
|
633
|
-
run_script(SCRIPTS / "forge-wizard.py", *args)
|
|
634
|
-
pause()
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def menu_init() -> None:
|
|
638
|
-
items = [
|
|
639
|
-
MenuItem(
|
|
640
|
-
"Claude Code → .claude/agents/ + CLAUDE.md", key="claude-code",
|
|
641
|
-
description=(
|
|
642
|
-
"Instala los agentes de forge en .claude/agents/ con scope inyectado "
|
|
643
|
-
"en el frontmatter. Genera también: CLAUDE.md (tabla agente/scope/trigger), "
|
|
644
|
-
".claude/settings.json (permisos según el stack) y slash commands "
|
|
645
|
-
"/new-feature, /deploy-check, /review en .claude/commands/."
|
|
646
|
-
),
|
|
647
|
-
),
|
|
648
|
-
MenuItem(
|
|
649
|
-
"OpenCode → AGENTS.md", key="opencode",
|
|
650
|
-
description=(
|
|
651
|
-
"Genera AGENTS.md con el roster y descripción de cada agente "
|
|
652
|
-
"en el formato que OpenCode y Codex esperan. "
|
|
653
|
-
"No toca .claude/ ni otros archivos del proyecto."
|
|
654
|
-
),
|
|
655
|
-
),
|
|
656
|
-
MenuItem(
|
|
657
|
-
"Kiro → .kiro/steering/", key="kiro",
|
|
658
|
-
description=(
|
|
659
|
-
"Genera los steering files de Kiro: product.md, structure.md, "
|
|
660
|
-
"agents.md y compliance.md (si hay frameworks configurados). "
|
|
661
|
-
"Las reglas de compliance se propagan automáticamente desde project.yaml."
|
|
662
|
-
),
|
|
663
|
-
),
|
|
664
|
-
MenuItem(
|
|
665
|
-
"Todos → genera los tres formatos", key="all",
|
|
666
|
-
description=(
|
|
667
|
-
"Ejecuta los tres adapters en secuencia: Claude Code, OpenCode y Kiro. "
|
|
668
|
-
"Útil cuando el equipo usa más de un runtime o quiere tener "
|
|
669
|
-
"todos los formatos disponibles sin pasos adicionales."
|
|
670
|
-
),
|
|
671
|
-
),
|
|
672
|
-
MenuItem("", separator=True),
|
|
673
|
-
MenuItem(
|
|
674
|
-
"Regenerar CLAUDE.md — sin reinstalar agentes", key="claude-md",
|
|
675
|
-
description=(
|
|
676
|
-
"Regenera solo el CLAUDE.md desde project.yaml (tabla de agentes con scope, "
|
|
677
|
-
"comandos del stack, fases del sprint). Útil cuando actualizaste project.yaml "
|
|
678
|
-
"y quieres refrescar el contexto sin re-instalar todos los agentes."
|
|
679
|
-
),
|
|
680
|
-
),
|
|
681
|
-
MenuItem("← Volver", key="back", description="Regresa al menú principal."),
|
|
682
|
-
]
|
|
683
|
-
key = show_menu(
|
|
684
|
-
"Inicializar agentes — elegir runtime",
|
|
685
|
-
items,
|
|
686
|
-
subtitle="Lee project.yaml e instala los agentes del proyecto",
|
|
687
|
-
)
|
|
688
|
-
if not key or key == "back":
|
|
689
|
-
return
|
|
690
|
-
if key == "claude-md":
|
|
691
|
-
run_script(FORGE_DIR / "adapters" / "claude-code" / "generate-claude-md.py", "--force")
|
|
692
|
-
pause()
|
|
693
|
-
return
|
|
694
|
-
clr()
|
|
695
|
-
_draw_header()
|
|
696
|
-
print(f"\n Runtime: {b(key)}\n")
|
|
697
|
-
force = _ask_yes_no("¿Sobreescribir agentes existentes? (--force)")
|
|
698
|
-
extra = ["--force"] if force else []
|
|
699
|
-
run_script(SCRIPTS / "forge-init.py", f"--tool={key}", *extra)
|
|
700
|
-
pause()
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
def menu_audit() -> None:
|
|
704
|
-
items = [
|
|
705
|
-
MenuItem(
|
|
706
|
-
"Auditoría completa", key="full",
|
|
707
|
-
description=(
|
|
708
|
-
"Compara todos los agentes instalados en .claude/agents/ contra "
|
|
709
|
-
"los archivos de referencia en forge. Muestra similitud, errores "
|
|
710
|
-
"de frontmatter, campos faltantes y acciones correctivas con el "
|
|
711
|
-
"comando exacto para corregir cada problema."
|
|
712
|
-
),
|
|
713
|
-
),
|
|
714
|
-
MenuItem(
|
|
715
|
-
"Agente específico (--only)", key="only",
|
|
716
|
-
description=(
|
|
717
|
-
"Audita un solo agente por nombre. Útil cuando actualizaste "
|
|
718
|
-
"un agente y quieres verificar que está sincronizado sin "
|
|
719
|
-
"revisar el roster completo."
|
|
720
|
-
),
|
|
721
|
-
),
|
|
722
|
-
MenuItem(
|
|
723
|
-
"Salida JSON — para CI/CD", key="json",
|
|
724
|
-
description=(
|
|
725
|
-
"Imprime el resultado en JSON estructurado. Retorna exit code 1 "
|
|
726
|
-
"si hay errores de severidad 'error' o 'critical'. "
|
|
727
|
-
"Integrar en pipelines con: forge-audit.py --json | jq '.summary'"
|
|
728
|
-
),
|
|
729
|
-
),
|
|
730
|
-
MenuItem("", separator=True),
|
|
731
|
-
MenuItem("← Volver", key="back", description="Regresa al menú principal."),
|
|
732
|
-
]
|
|
733
|
-
key = show_menu("Auditar proyecto", items)
|
|
734
|
-
if not key or key == "back":
|
|
735
|
-
return
|
|
736
|
-
if key == "full":
|
|
737
|
-
run_script(SCRIPTS / "forge-audit.py")
|
|
738
|
-
elif key == "only":
|
|
739
|
-
clr()
|
|
740
|
-
_draw_header()
|
|
741
|
-
print()
|
|
742
|
-
agent = _ask_input("Nombre del agente a auditar", "backend-engineer")
|
|
743
|
-
run_script(SCRIPTS / "forge-audit.py", f"--only={agent}")
|
|
744
|
-
elif key == "json":
|
|
745
|
-
run_script(SCRIPTS / "forge-audit.py", "--json")
|
|
746
|
-
pause()
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
def menu_aitmpl() -> None:
|
|
750
|
-
mod = _load_aitmpl_mod()
|
|
751
|
-
|
|
752
|
-
while True:
|
|
753
|
-
items_mode = [
|
|
754
|
-
MenuItem(
|
|
755
|
-
"Buscar por palabra clave", key="query",
|
|
756
|
-
description=(
|
|
757
|
-
"Busca en el catálogo por nombre, descripción o tecnología. "
|
|
758
|
-
"Ejemplos: 'postgres', 'rails', 'nextjs typescript', 'playwright'."
|
|
759
|
-
),
|
|
760
|
-
),
|
|
761
|
-
MenuItem(
|
|
762
|
-
"Ver por categoría framework · mcp-server · profile · tool", key="category",
|
|
763
|
-
description=(
|
|
764
|
-
"Muestra todos los items de una categoría: frameworks de agentes, "
|
|
765
|
-
"MCP servers (20 disponibles), profiles de forge (15), herramientas y recursos."
|
|
766
|
-
),
|
|
767
|
-
),
|
|
768
|
-
MenuItem("", separator=True),
|
|
769
|
-
MenuItem("← Volver", key="back", description="Regresa al menú principal."),
|
|
770
|
-
]
|
|
771
|
-
mode = show_menu("Buscar templates y recursos", items_mode,
|
|
772
|
-
subtitle="Catálogo curado — instala MCP servers y profiles directamente")
|
|
773
|
-
if not mode or mode == "back":
|
|
774
|
-
return
|
|
775
|
-
|
|
776
|
-
results: list[dict] = []
|
|
777
|
-
|
|
778
|
-
if mode == "category":
|
|
779
|
-
cat_items = [
|
|
780
|
-
MenuItem("framework Frameworks de agentes IA", key="framework",
|
|
781
|
-
description="forge, aider, micro-agent, anthropic-quickstarts, claude-code-action."),
|
|
782
|
-
MenuItem("mcp-server Servidores MCP instalables", key="mcp-server",
|
|
783
|
-
description="20 servers con instalación directa: filesystem, git, github, postgres, slack, playwright, docker, cloudflare, vercel y más."),
|
|
784
|
-
MenuItem("profile Profiles de stack para forge", key="profile",
|
|
785
|
-
description="15 profiles: hono-drizzle, nextjs-admin, astro, fastapi, rails, nestjs, express, expo, playwright-crawler, django, vuenuxt, go-gin, sveltekit, laravel, wordpress."),
|
|
786
|
-
MenuItem("tool Herramientas CLI", key="tool",
|
|
787
|
-
description="Claude Code CLI, MCP Inspector."),
|
|
788
|
-
MenuItem("resource Documentación y listas", key="resource",
|
|
789
|
-
description="Docs oficiales MCP, docs Claude Code, awesome-mcp-servers."),
|
|
790
|
-
MenuItem("", separator=True),
|
|
791
|
-
MenuItem("← Volver", key="back", description="Regresa al menú anterior."),
|
|
792
|
-
]
|
|
793
|
-
cat = show_menu("Seleccionar categoría", cat_items)
|
|
794
|
-
if not cat or cat == "back":
|
|
795
|
-
continue
|
|
796
|
-
results = mod._search_local("", category=cat)
|
|
797
|
-
else:
|
|
798
|
-
clr()
|
|
799
|
-
_draw_header()
|
|
800
|
-
print()
|
|
801
|
-
query = _ask_input("¿Qué buscas?", "mcp postgres")
|
|
802
|
-
if not query:
|
|
803
|
-
continue
|
|
804
|
-
results = mod._search_local(query)
|
|
805
|
-
|
|
806
|
-
if not results:
|
|
807
|
-
clr()
|
|
808
|
-
_draw_header()
|
|
809
|
-
print(f"\n {y('Sin resultados.')} Prueba con otro término o cambia la categoría.\n")
|
|
810
|
-
pause()
|
|
811
|
-
continue
|
|
812
|
-
|
|
813
|
-
# Mostrar resultados navegables
|
|
814
|
-
while True:
|
|
815
|
-
result_items = _build_result_menu(results)
|
|
816
|
-
n = len(results)
|
|
817
|
-
sel = show_menu(
|
|
818
|
-
f"Resultados — {n} encontrado{'s' if n != 1 else ''}",
|
|
819
|
-
result_items,
|
|
820
|
-
subtitle="Enter para ver acciones · instalar MCP · agregar profile · abrir URL",
|
|
821
|
-
)
|
|
822
|
-
if not sel or sel == "back":
|
|
823
|
-
break
|
|
824
|
-
_action_menu_item(results[int(sel)])
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
def menu_scaffold() -> None:
|
|
828
|
-
clr()
|
|
829
|
-
_draw_header()
|
|
830
|
-
print(f"\n {b('Crear nuevo profile Tier 2')}\n")
|
|
831
|
-
print(textwrap.fill(
|
|
832
|
-
"Un profile Tier 2 es un agente especializado para un stack tecnológico "
|
|
833
|
-
"específico. Por ejemplo: un api-engineer para Django conoce los modelos, "
|
|
834
|
-
"las migraciones y DRF — cosas que el agente genérico no sabe.",
|
|
835
|
-
width=60, initial_indent=" ", subsequent_indent=" ",
|
|
836
|
-
))
|
|
837
|
-
print()
|
|
838
|
-
print(f" {d('Profiles disponibles: hono-drizzle · nextjs-admin · astro · expo · playwright-crawler')}")
|
|
839
|
-
print(f" {d(' fastapi · express · rails · nestjs · django · go-gin · sveltekit · vuenuxt')}")
|
|
840
|
-
print(f" {d(' laravel · wordpress')}\n")
|
|
841
|
-
|
|
842
|
-
name = _ask_input("Nombre del stack nuevo (ej: django, laravel, gin)")
|
|
843
|
-
if not name:
|
|
844
|
-
return
|
|
845
|
-
engineer = _ask_input("Nombre del agente (ej: api-engineer)", "api-engineer")
|
|
846
|
-
desc = _ask_input("Descripción breve (Enter = generar automático)")
|
|
847
|
-
details = _ask_input("Tecnologías del stack (ej: Django 4.2 + PostgreSQL + DRF)")
|
|
848
|
-
|
|
849
|
-
args = ["--name", name, "--engineer", engineer]
|
|
850
|
-
if desc:
|
|
851
|
-
args += ["--description", desc]
|
|
852
|
-
if details:
|
|
853
|
-
args += ["--stack-details", details]
|
|
854
|
-
print()
|
|
855
|
-
run_script(SCRIPTS / "forge-scaffold-profile.py", *args)
|
|
856
|
-
pause()
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
def wiki_status() -> None:
|
|
860
|
-
clr()
|
|
861
|
-
_draw_header()
|
|
862
|
-
print(f"\n {b('Wiki — estado actual')}\n")
|
|
863
|
-
|
|
864
|
-
wiki_root = Path.cwd() / "docs" / "wiki"
|
|
865
|
-
if not wiki_root.exists():
|
|
866
|
-
print(f" {y('docs/wiki/ no existe.')} Ejecuta 'forge wiki ingest' para inicializarlo.")
|
|
867
|
-
pause()
|
|
868
|
-
return
|
|
869
|
-
|
|
870
|
-
subdirs = ["raw", "concepts", "entities", "sources", "synthesis"]
|
|
871
|
-
W_DIR = 14
|
|
872
|
-
W_COUNT = 8
|
|
873
|
-
W_MOD = 22
|
|
874
|
-
|
|
875
|
-
header = (
|
|
876
|
-
f" {b(_padded('Directorio', W_DIR))}"
|
|
877
|
-
f" {b(_padded('Archivos', W_COUNT))}"
|
|
878
|
-
f" {b('Última modificación')}"
|
|
879
|
-
)
|
|
880
|
-
print(header)
|
|
881
|
-
print(f" {FG_MUTED}{'─' * (W_DIR + W_COUNT + W_MOD + 4)}{RESET}")
|
|
882
|
-
|
|
883
|
-
for sub in subdirs:
|
|
884
|
-
d_path = wiki_root / sub
|
|
885
|
-
if d_path.exists():
|
|
886
|
-
files = list(d_path.iterdir())
|
|
887
|
-
count = len(files)
|
|
888
|
-
if files:
|
|
889
|
-
latest = max(f.stat().st_mtime for f in files)
|
|
890
|
-
import datetime
|
|
891
|
-
mod_str = datetime.datetime.fromtimestamp(latest).strftime("%Y-%m-%d %H:%M")
|
|
892
|
-
else:
|
|
893
|
-
mod_str = "—"
|
|
894
|
-
else:
|
|
895
|
-
count = 0
|
|
896
|
-
mod_str = "—"
|
|
897
|
-
print(
|
|
898
|
-
f" {_padded(sub, W_DIR)}"
|
|
899
|
-
f" {_padded(str(count), W_COUNT)}"
|
|
900
|
-
f" {d(mod_str)}"
|
|
901
|
-
)
|
|
902
|
-
|
|
903
|
-
print(f"\n {b('Archivos de control:')}")
|
|
904
|
-
for fname in ("index.md", "log.md"):
|
|
905
|
-
fpath = wiki_root / fname
|
|
906
|
-
mark = g("ok") if fpath.exists() else r("falta")
|
|
907
|
-
print(f" docs/wiki/{fname} [{mark}]")
|
|
908
|
-
|
|
909
|
-
pause()
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
def wiki_ingest(source: Optional[str] = None) -> None:
|
|
913
|
-
clr()
|
|
914
|
-
_draw_header()
|
|
915
|
-
print(f"\n {b('Wiki — ingestar fuente')}\n")
|
|
916
|
-
|
|
917
|
-
wiki_root = Path.cwd() / "docs" / "wiki"
|
|
918
|
-
if not wiki_root.exists():
|
|
919
|
-
print(f" Inicializando estructura docs/wiki/ …")
|
|
920
|
-
for sub in ["raw", "concepts", "entities", "sources", "synthesis"]:
|
|
921
|
-
(wiki_root / sub).mkdir(parents=True, exist_ok=True)
|
|
922
|
-
for fname, content in [
|
|
923
|
-
("index.md", "# Wiki Index\n\n<!-- generado por forge wiki ingest -->\n"),
|
|
924
|
-
("log.md", "# Wiki Log\n\n<!-- ingesta registrada aquí -->\n"),
|
|
925
|
-
]:
|
|
926
|
-
fpath = wiki_root / fname
|
|
927
|
-
if not fpath.exists():
|
|
928
|
-
fpath.write_text(content, encoding="utf-8")
|
|
929
|
-
print(f" {g('Estructura creada.')} docs/wiki/ inicializado con index.md y log.md.")
|
|
930
|
-
print()
|
|
931
|
-
|
|
932
|
-
if source:
|
|
933
|
-
print(f" Fuente: {c(source)}")
|
|
934
|
-
else:
|
|
935
|
-
print(f" {d('No se especificó fuente. En el slash command puedes pasar URL, ruta o texto.')}")
|
|
936
|
-
|
|
937
|
-
print()
|
|
938
|
-
print(f" {b('Siguiente paso:')}")
|
|
939
|
-
print(f" En Claude Code ejecuta el slash command:")
|
|
940
|
-
print(f" {c('/wiki-ingest')}{' ' + source if source else ''}")
|
|
941
|
-
print()
|
|
942
|
-
print(f" El skill procesa la fuente y actualiza raw/, pages, index.md y log.md.")
|
|
943
|
-
pause()
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
def wiki_query(question: Optional[str] = None) -> None:
|
|
947
|
-
clr()
|
|
948
|
-
_draw_header()
|
|
949
|
-
print(f"\n {b('Wiki — consultar')}\n")
|
|
950
|
-
|
|
951
|
-
if question:
|
|
952
|
-
print(f" Pregunta: {c(question)}")
|
|
953
|
-
print()
|
|
954
|
-
|
|
955
|
-
print(f" {b('Siguiente paso:')}")
|
|
956
|
-
print(f" En Claude Code ejecuta el slash command:")
|
|
957
|
-
if question:
|
|
958
|
-
print(f" {c('/wiki-query')} {question}")
|
|
959
|
-
else:
|
|
960
|
-
print(f" {c('/wiki-query')} <tu pregunta>")
|
|
961
|
-
print()
|
|
962
|
-
print(f" El skill busca en docs/wiki/ y responde con citas a las páginas fuente.")
|
|
963
|
-
pause()
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
def wiki_lint() -> None:
|
|
967
|
-
clr()
|
|
968
|
-
_draw_header()
|
|
969
|
-
print(f"\n {b('Wiki — health check')}\n")
|
|
970
|
-
print(f" {b('Siguiente paso:')}")
|
|
971
|
-
print(f" En Claude Code ejecuta el slash command:")
|
|
972
|
-
print(f" {c('/wiki-lint')}")
|
|
973
|
-
print()
|
|
974
|
-
print(f" El skill verifica integridad del wiki: links, huérfanos, frontmatter, log.")
|
|
975
|
-
print(f" Repara automáticamente lo que es seguro; lista el resto para revisión manual.")
|
|
976
|
-
pause()
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
def menu_wiki() -> None:
|
|
980
|
-
items = [
|
|
981
|
-
MenuItem(
|
|
982
|
-
"status — resumen de la estructura del wiki", key="status",
|
|
983
|
-
description=(
|
|
984
|
-
"Muestra el estado actual de docs/wiki/: subdirectorios (raw, concepts, "
|
|
985
|
-
"entities, sources, synthesis), cantidad de archivos en cada uno, "
|
|
986
|
-
"última modificación, y si existen index.md y log.md."
|
|
987
|
-
),
|
|
988
|
-
),
|
|
989
|
-
MenuItem(
|
|
990
|
-
"ingest — indexar una fuente en el wiki", key="ingest",
|
|
991
|
-
description=(
|
|
992
|
-
"Inicializa docs/wiki/ si no existe (crea subdirectorios, index.md y log.md). "
|
|
993
|
-
"Luego guía para ejecutar el slash command /wiki-ingest con la fuente "
|
|
994
|
-
"(URL, ruta de archivo o texto libre). El skill AI hace el trabajo de ingesta."
|
|
995
|
-
),
|
|
996
|
-
),
|
|
997
|
-
MenuItem(
|
|
998
|
-
"query — consultar el wiki", key="query",
|
|
999
|
-
description=(
|
|
1000
|
-
"Guía para ejecutar /wiki-query con tu pregunta. "
|
|
1001
|
-
"El skill busca en docs/wiki/ y responde citando las páginas fuente."
|
|
1002
|
-
),
|
|
1003
|
-
),
|
|
1004
|
-
MenuItem(
|
|
1005
|
-
"lint — verificar integridad del wiki", key="lint",
|
|
1006
|
-
description=(
|
|
1007
|
-
"Guía para ejecutar /wiki-lint. El skill verifica links rotos, "
|
|
1008
|
-
"páginas huérfanas, frontmatter inválido y consistencia del log. "
|
|
1009
|
-
"Repara automáticamente lo seguro y lista el resto."
|
|
1010
|
-
),
|
|
1011
|
-
),
|
|
1012
|
-
MenuItem("", separator=True),
|
|
1013
|
-
MenuItem("← Volver", key="back", description="Regresa al menú principal."),
|
|
1014
|
-
]
|
|
1015
|
-
while True:
|
|
1016
|
-
key = show_menu(
|
|
1017
|
-
"Wiki — gestión del knowledge base",
|
|
1018
|
-
items,
|
|
1019
|
-
subtitle="docs/wiki/ · /wiki-ingest /wiki-query /wiki-lint",
|
|
1020
|
-
)
|
|
1021
|
-
if not key or key == "back":
|
|
1022
|
-
return
|
|
1023
|
-
if key == "status":
|
|
1024
|
-
wiki_status()
|
|
1025
|
-
elif key == "ingest":
|
|
1026
|
-
clr()
|
|
1027
|
-
_draw_header()
|
|
1028
|
-
print()
|
|
1029
|
-
src = _ask_input("Fuente a ingestar (URL, ruta o Enter para omitir)")
|
|
1030
|
-
wiki_ingest(src if src else None)
|
|
1031
|
-
elif key == "query":
|
|
1032
|
-
clr()
|
|
1033
|
-
_draw_header()
|
|
1034
|
-
print()
|
|
1035
|
-
q = _ask_input("Pregunta para el wiki (Enter para omitir)")
|
|
1036
|
-
wiki_query(q if q else None)
|
|
1037
|
-
elif key == "lint":
|
|
1038
|
-
wiki_lint()
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
def menu_teardown() -> None:
|
|
1042
|
-
items = [
|
|
1043
|
-
MenuItem(
|
|
1044
|
-
"Vista previa (dry-run)", key="dry",
|
|
1045
|
-
description=(
|
|
1046
|
-
"Muestra qué archivos serían eliminados sin borrar nada. "
|
|
1047
|
-
"Siempre es recomendable ejecutar esto primero para verificar "
|
|
1048
|
-
"que el teardown no toca archivos propios del proyecto."
|
|
1049
|
-
),
|
|
1050
|
-
),
|
|
1051
|
-
MenuItem(
|
|
1052
|
-
"Ejecutar teardown", key="confirm",
|
|
1053
|
-
description=(
|
|
1054
|
-
"Elimina los agentes instalados por forge de .claude/agents/, "
|
|
1055
|
-
"preservando los agentes Tier 3 (propios del proyecto) y los "
|
|
1056
|
-
"archivos de trabajo como CLAUDE.md y project.yaml. "
|
|
1057
|
-
"Pedirá confirmación antes de proceder."
|
|
1058
|
-
),
|
|
1059
|
-
),
|
|
1060
|
-
MenuItem("", separator=True),
|
|
1061
|
-
MenuItem("← Volver", key="back", description="Regresa al menú principal."),
|
|
1062
|
-
]
|
|
1063
|
-
key = show_menu(
|
|
1064
|
-
"Teardown del proyecto",
|
|
1065
|
-
items,
|
|
1066
|
-
subtitle="Revierte la instalación de forge preservando el trabajo del proyecto",
|
|
1067
|
-
)
|
|
1068
|
-
if not key or key == "back":
|
|
1069
|
-
return
|
|
1070
|
-
if key == "dry":
|
|
1071
|
-
run_script(SCRIPTS / "forge-teardown.py")
|
|
1072
|
-
elif key == "confirm":
|
|
1073
|
-
clr()
|
|
1074
|
-
_draw_header()
|
|
1075
|
-
print(f"\n {r(b('ATENCIÓN'))} — Esta operación elimina agentes instalados por forge.\n")
|
|
1076
|
-
if _ask_yes_no("¿Confirmar teardown?", default=False):
|
|
1077
|
-
run_script(SCRIPTS / "forge-teardown.py", "--confirm")
|
|
1078
|
-
else:
|
|
1079
|
-
print(f"\n Cancelado.")
|
|
1080
|
-
pause()
|
|
1081
|
-
|
|
1082
|
-
# ---------------------------------------------------------------------------
|
|
1083
|
-
# Menú principal
|
|
1084
|
-
# ---------------------------------------------------------------------------
|
|
1085
|
-
|
|
1086
|
-
MAIN_ITEMS = [
|
|
1087
|
-
MenuItem(
|
|
1088
|
-
"Nuevo proyecto wizard interactivo", key="wizard",
|
|
1089
|
-
description=(
|
|
1090
|
-
"Genera project.yaml paso a paso eligiendo tipo de proyecto, "
|
|
1091
|
-
"framework frontend/backend, base de datos, plataforma de deploy "
|
|
1092
|
-
"y compliance. Al terminar instala los agentes automáticamente."
|
|
1093
|
-
),
|
|
1094
|
-
),
|
|
1095
|
-
MenuItem(
|
|
1096
|
-
"Inicializar agentes forge-init", key="init",
|
|
1097
|
-
description=(
|
|
1098
|
-
"Lee el project.yaml existente e instala los agentes de forge "
|
|
1099
|
-
"en el runtime elegido. Para Claude Code también genera CLAUDE.md, "
|
|
1100
|
-
".claude/settings.json y slash commands /new-feature /deploy-check /review. "
|
|
1101
|
-
"Por defecto no sobreescribe agentes ya existentes."
|
|
1102
|
-
),
|
|
1103
|
-
),
|
|
1104
|
-
MenuItem(
|
|
1105
|
-
"Auditar proyecto forge-audit", key="audit",
|
|
1106
|
-
description=(
|
|
1107
|
-
"Compara los agentes instalados contra los de forge para detectar "
|
|
1108
|
-
"campos faltantes, versiones desactualizadas o agentes que ya no "
|
|
1109
|
-
"están en el roster activo. Soporta salida JSON para CI/CD."
|
|
1110
|
-
),
|
|
1111
|
-
),
|
|
1112
|
-
MenuItem(
|
|
1113
|
-
"Wiki knowledge base del proyecto", key="wiki",
|
|
1114
|
-
description=(
|
|
1115
|
-
"Gestiona el wiki del proyecto en docs/wiki/. "
|
|
1116
|
-
"Opciones: status (resumen de estructura), ingest (indexar fuentes), "
|
|
1117
|
-
"query (consultar con citas) y lint (verificar integridad). "
|
|
1118
|
-
"La ingesta y consulta usan los slash commands /wiki-ingest y /wiki-query de Claude Code."
|
|
1119
|
-
),
|
|
1120
|
-
),
|
|
1121
|
-
MenuItem(
|
|
1122
|
-
"Buscar templates frameworks · MCP · profiles", key="aitmpl",
|
|
1123
|
-
description=(
|
|
1124
|
-
"Catálogo curado de 40+ recursos: frameworks (forge, aider), "
|
|
1125
|
-
"20 MCP servers (postgres, github, slack, playwright...), "
|
|
1126
|
-
"profiles de stack y herramientas. Filtrable por categoría. "
|
|
1127
|
-
"Funciona offline — sin dependencias de red."
|
|
1128
|
-
),
|
|
1129
|
-
),
|
|
1130
|
-
MenuItem(
|
|
1131
|
-
"Nuevo profile Tier 2 scaffold", key="scaffold",
|
|
1132
|
-
description=(
|
|
1133
|
-
"Crea el esqueleto de un agente especializado para un stack no cubierto "
|
|
1134
|
-
"por los profiles actuales (django, laravel, gin, sveltekit, etc.). "
|
|
1135
|
-
"Genera el .md con frontmatter correcto y todas las secciones obligatorias."
|
|
1136
|
-
),
|
|
1137
|
-
),
|
|
1138
|
-
MenuItem(
|
|
1139
|
-
"Teardown revertir instalación", key="teardown",
|
|
1140
|
-
description=(
|
|
1141
|
-
"Elimina los artefactos que forge instaló en el proyecto: agentes en "
|
|
1142
|
-
".claude/agents/, AGENTS.md, steering files de Kiro. "
|
|
1143
|
-
"Preserva tu trabajo: CLAUDE.md, project.yaml y agentes Tier 3 propios."
|
|
1144
|
-
),
|
|
1145
|
-
),
|
|
1146
|
-
MenuItem("", separator=True),
|
|
1147
|
-
MenuItem(
|
|
1148
|
-
"Salir", key="quit",
|
|
1149
|
-
description="Cierra el CLI de forge.",
|
|
1150
|
-
),
|
|
1151
|
-
]
|
|
1152
|
-
|
|
1153
|
-
ACTIONS = {
|
|
1154
|
-
"wizard": menu_wizard,
|
|
1155
|
-
"init": menu_init,
|
|
1156
|
-
"audit": menu_audit,
|
|
1157
|
-
"wiki": menu_wiki,
|
|
1158
|
-
"aitmpl": menu_aitmpl,
|
|
1159
|
-
"scaffold": menu_scaffold,
|
|
1160
|
-
"teardown": menu_teardown,
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
def _dispatch_wiki_cli(args: list[str]) -> None:
|
|
1165
|
-
"""Dispatch `forge wiki <subcommand>` from CLI (non-interactive)."""
|
|
1166
|
-
sub = args[0] if args else "status"
|
|
1167
|
-
if sub == "status":
|
|
1168
|
-
wiki_status()
|
|
1169
|
-
elif sub == "ingest":
|
|
1170
|
-
source_flag = next((a for a in args[1:] if not a.startswith("--")), None)
|
|
1171
|
-
if not source_flag:
|
|
1172
|
-
# look for --source <path>
|
|
1173
|
-
for i, a in enumerate(args[1:], 1):
|
|
1174
|
-
if a == "--source" and i + 1 < len(args):
|
|
1175
|
-
source_flag = args[i + 1]
|
|
1176
|
-
break
|
|
1177
|
-
if a.startswith("--source="):
|
|
1178
|
-
source_flag = a.split("=", 1)[1]
|
|
1179
|
-
break
|
|
1180
|
-
wiki_ingest(source_flag)
|
|
1181
|
-
elif sub == "query":
|
|
1182
|
-
question = " ".join(a for a in args[1:] if not a.startswith("--")) or None
|
|
1183
|
-
wiki_query(question)
|
|
1184
|
-
elif sub == "lint":
|
|
1185
|
-
wiki_lint()
|
|
1186
|
-
else:
|
|
1187
|
-
print(f"forge wiki: subcomando desconocido '{sub}'. Usa: status | ingest | query | lint", file=sys.stderr)
|
|
1188
|
-
sys.exit(1)
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
def main() -> None:
|
|
1192
|
-
if "--help" in sys.argv or "-h" in sys.argv:
|
|
1193
|
-
print(textwrap.dedent(f"""\
|
|
1194
|
-
forge v{VERSION} — Framework de desarrollo con agentes IA
|
|
1195
|
-
|
|
1196
|
-
Uso:
|
|
1197
|
-
python3 .agentic/forge.py Abre el CLI interactivo
|
|
1198
|
-
python3 .agentic/forge.py --help Muestra esta ayuda
|
|
1199
|
-
python3 .agentic/forge.py --batch Muestra scripts para CI/no-interactivo
|
|
1200
|
-
|
|
1201
|
-
Subcomandos:
|
|
1202
|
-
python3 .agentic/forge.py wiki status Estado del wiki
|
|
1203
|
-
python3 .agentic/forge.py wiki ingest [--source <ruta>] Ingestar fuente
|
|
1204
|
-
python3 .agentic/forge.py wiki query "<pregunta>" Consultar el wiki
|
|
1205
|
-
python3 .agentic/forge.py wiki lint Health check del wiki
|
|
1206
|
-
|
|
1207
|
-
Scripts disponibles directamente:
|
|
1208
|
-
scripts/forge-wizard.py Wizard de nuevo proyecto
|
|
1209
|
-
scripts/forge-init.py Instala agentes
|
|
1210
|
-
scripts/forge-audit.py Audita el proyecto
|
|
1211
|
-
scripts/forge-scaffold-profile.py Crea un profile Tier 2
|
|
1212
|
-
scripts/forge-teardown.py Revierte la instalación
|
|
1213
|
-
scripts/aitmpl-search.py Busca templates y recursos de agentes IA
|
|
1214
|
-
"""))
|
|
1215
|
-
return
|
|
1216
|
-
|
|
1217
|
-
if "--batch" in sys.argv:
|
|
1218
|
-
print(textwrap.dedent(f"""\
|
|
1219
|
-
forge v{VERSION} — modo no-interactivo
|
|
1220
|
-
|
|
1221
|
-
Usa los scripts directamente:
|
|
1222
|
-
python3 .agentic/scripts/forge-wizard.py --mode=startup Crear project.yaml
|
|
1223
|
-
python3 .agentic/scripts/forge-init.py --tool=claude-code Instalar agentes
|
|
1224
|
-
python3 .agentic/scripts/forge-audit.py --json Auditar (JSON para CI)
|
|
1225
|
-
python3 .agentic/scripts/forge-audit.py --json | jq '.summary.errors'
|
|
1226
|
-
python3 .agentic/scripts/forge-teardown.py --confirm Teardown
|
|
1227
|
-
|
|
1228
|
-
Wiki (no-interactivo):
|
|
1229
|
-
python3 .agentic/forge.py wiki status
|
|
1230
|
-
python3 .agentic/forge.py wiki ingest --source <ruta>
|
|
1231
|
-
python3 .agentic/forge.py wiki query "<pregunta>"
|
|
1232
|
-
python3 .agentic/forge.py wiki lint
|
|
1233
|
-
"""))
|
|
1234
|
-
return
|
|
1235
|
-
|
|
1236
|
-
# Subcomando wiki — funciona en modo non-TTY también
|
|
1237
|
-
if len(sys.argv) > 1 and sys.argv[1] == "wiki":
|
|
1238
|
-
_dispatch_wiki_cli(sys.argv[2:])
|
|
1239
|
-
return
|
|
1240
|
-
|
|
1241
|
-
if not IS_TTY:
|
|
1242
|
-
print("forge: terminal interactivo requerido. Usar --help para ver opciones.", file=sys.stderr)
|
|
1243
|
-
print("Tip: usa --batch para ver los scripts equivalentes para CI.", file=sys.stderr)
|
|
1244
|
-
sys.exit(1)
|
|
1245
|
-
|
|
1246
|
-
import shutil as _shutil
|
|
1247
|
-
cols = _shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
1248
|
-
if cols < 58:
|
|
1249
|
-
print(f"forge: terminal demasiado estrecha ({cols} cols, mínimo 58).", file=sys.stderr)
|
|
1250
|
-
print(f" Amplía la ventana o usa --batch para ver scripts directos.", file=sys.stderr)
|
|
1251
|
-
sys.exit(1)
|
|
1252
|
-
|
|
1253
|
-
while True:
|
|
1254
|
-
key = show_menu("¿Qué quieres hacer?", MAIN_ITEMS)
|
|
1255
|
-
if key is None or key == "quit":
|
|
1256
|
-
clr()
|
|
1257
|
-
print(f"\n {d('forge — hasta luego.')}\n")
|
|
1258
|
-
break
|
|
1259
|
-
action = ACTIONS.get(key)
|
|
1260
|
-
if action:
|
|
1261
|
-
action()
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
if __name__ == "__main__":
|
|
1265
|
-
main()
|