code-atlas-forge 1.1.0__py3-none-any.whl
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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
code_atlas/ui/kit.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""Stdlib interactive UI kit — ANSI, zero-dependency, TTY-gated.
|
|
2
|
+
|
|
3
|
+
Everything degrades honestly:
|
|
4
|
+
|
|
5
|
+
- Non-TTY (CI, pipes, SSH without terminal): interactive functions raise
|
|
6
|
+
``NonInteractive`` — callers convert to a clear error or JSON output.
|
|
7
|
+
- No color (``NO_COLOR``, ``TERM=dumb``): still navigable, no escapes.
|
|
8
|
+
- cp1252/narrow terminals: ASCII-only glyphs; content never truncated
|
|
9
|
+
without an ellipsis.
|
|
10
|
+
|
|
11
|
+
Key handling: ``msvcrt`` on Windows, ``termios``+``tty`` elsewhere.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, TextIO
|
|
22
|
+
|
|
23
|
+
from code_atlas.ui.i18n import t
|
|
24
|
+
|
|
25
|
+
if sys.platform != "win32": # pragma: no cover - platform
|
|
26
|
+
import termios
|
|
27
|
+
import tty
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NonInteractive(Exception):
|
|
31
|
+
"""Raised when an interactive prompt is requested without a TTY."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, kw_only=True)
|
|
35
|
+
class UIContext:
|
|
36
|
+
"""Terminal capability snapshot — detected once, passed around."""
|
|
37
|
+
|
|
38
|
+
interactive: bool
|
|
39
|
+
color: bool
|
|
40
|
+
unicode: bool
|
|
41
|
+
width: int
|
|
42
|
+
height: int = 24
|
|
43
|
+
ansi: bool = True
|
|
44
|
+
language: str = "en"
|
|
45
|
+
# glyphs chosen by capability
|
|
46
|
+
cursor: str = ">"
|
|
47
|
+
checked: str = "x"
|
|
48
|
+
unchecked: str = " "
|
|
49
|
+
ok: str = "OK"
|
|
50
|
+
fail: str = "FAIL"
|
|
51
|
+
arrow: str = "->"
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def detect(*, force_plain: bool = False, language: str | None = None) -> UIContext:
|
|
55
|
+
from code_atlas.ui.i18n import lang
|
|
56
|
+
|
|
57
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty() and not force_plain
|
|
58
|
+
term = os.environ.get("TERM", "")
|
|
59
|
+
# cursor-addressing escapes are unsafe on dumb terminals even when a
|
|
60
|
+
# tty is present — `ansi` gates \x1b[.. moves, `color` gates SGR.
|
|
61
|
+
ansi = interactive and term != "dumb"
|
|
62
|
+
no_color = bool(os.environ.get("NO_COLOR")) or not ansi
|
|
63
|
+
encoding = (sys.stdout.encoding or "").lower()
|
|
64
|
+
uni = _supports_unicode(encoding)
|
|
65
|
+
size = terminal_size()
|
|
66
|
+
return UIContext(
|
|
67
|
+
interactive=interactive,
|
|
68
|
+
color=not no_color,
|
|
69
|
+
unicode=uni,
|
|
70
|
+
width=size[0],
|
|
71
|
+
height=size[1],
|
|
72
|
+
ansi=ansi,
|
|
73
|
+
language=language or lang(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def terminal_size(
|
|
78
|
+
*,
|
|
79
|
+
stream: TextIO | None = None,
|
|
80
|
+
fallback: tuple[int, int] = (80, 24),
|
|
81
|
+
) -> tuple[int, int]:
|
|
82
|
+
"""Return usable terminal cells, preferring active stream and clamping bad probes."""
|
|
83
|
+
try:
|
|
84
|
+
if stream is not None:
|
|
85
|
+
size = os.get_terminal_size(stream.fileno())
|
|
86
|
+
raw = (size.columns, size.lines)
|
|
87
|
+
else:
|
|
88
|
+
size = shutil.get_terminal_size(fallback)
|
|
89
|
+
raw = (size.columns, size.lines)
|
|
90
|
+
except (AttributeError, OSError, ValueError):
|
|
91
|
+
raw = fallback
|
|
92
|
+
return max(1, int(raw[0])), max(1, int(raw[1]))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _c(ctx: UIContext, code: str, text: str) -> str:
|
|
96
|
+
return f"\x1b[{code}m{text}\x1b[0m" if ctx.color else text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _supports_unicode(encoding: str) -> bool:
|
|
103
|
+
"""Recognize UTF-8 aliases used by Windows Terminal and POSIX shells."""
|
|
104
|
+
|
|
105
|
+
normalized = encoding.lower().replace("-", "").replace("_", "")
|
|
106
|
+
return "utf" in normalized or normalized.endswith("65001")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cell_width(text: str) -> int:
|
|
110
|
+
"""Terminal cell width — not len(): CJK/W chars take 2, combining 0."""
|
|
111
|
+
import unicodedata
|
|
112
|
+
|
|
113
|
+
w = 0
|
|
114
|
+
for ch in _ANSI_RE.sub("", text):
|
|
115
|
+
if unicodedata.combining(ch):
|
|
116
|
+
continue
|
|
117
|
+
w += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
118
|
+
return w
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pad_visible(text: str, width: int) -> str:
|
|
122
|
+
"""Pad to a visible width — ANSI escapes don't count."""
|
|
123
|
+
return text + " " * max(0, width - cell_width(text))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ellipsize(ctx: UIContext, text: str, width: int | None = None) -> str:
|
|
127
|
+
w = min(width if width is not None else ctx.width - 2, ctx.width - 2)
|
|
128
|
+
if w <= 0:
|
|
129
|
+
return ""
|
|
130
|
+
if cell_width(text) <= w:
|
|
131
|
+
return text
|
|
132
|
+
out, used = [], 0
|
|
133
|
+
for ch in text:
|
|
134
|
+
cw = cell_width(ch)
|
|
135
|
+
if used + cw > w - 1:
|
|
136
|
+
break
|
|
137
|
+
out.append(ch)
|
|
138
|
+
used += cw
|
|
139
|
+
return "".join(out) + ("…" if ctx.unicode else ".")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# -- key reading -------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
_UP = "up"
|
|
145
|
+
_DOWN = "down"
|
|
146
|
+
_ENTER = "enter"
|
|
147
|
+
_SPACE = "space"
|
|
148
|
+
_ESC = "esc"
|
|
149
|
+
_CTRL_C = "ctrl-c"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_key() -> str:
|
|
153
|
+
"""Blocking read of one normalized key press."""
|
|
154
|
+
if sys.platform == "win32":
|
|
155
|
+
import msvcrt
|
|
156
|
+
|
|
157
|
+
ch = msvcrt.getwch()
|
|
158
|
+
if ch in ("\x00", "\xe0"):
|
|
159
|
+
code = msvcrt.getwch()
|
|
160
|
+
return {"H": _UP, "P": _DOWN}.get(code, "other")
|
|
161
|
+
if ch in ("\r", "\n"):
|
|
162
|
+
return _ENTER
|
|
163
|
+
if ch == " ":
|
|
164
|
+
return _SPACE
|
|
165
|
+
if ch == "\x1b":
|
|
166
|
+
return _ESC
|
|
167
|
+
if ch == "\x03":
|
|
168
|
+
return _CTRL_C
|
|
169
|
+
if ch == "\x0b":
|
|
170
|
+
return "ctrl-k"
|
|
171
|
+
return f"char:{ch}"
|
|
172
|
+
ch = sys.stdin.read(1) # pragma: no cover - platform
|
|
173
|
+
if not ch: # pragma: no cover - platform
|
|
174
|
+
return "ctrl-d"
|
|
175
|
+
if ch == "\x1b": # pragma: no cover - platform
|
|
176
|
+
# A bare Esc must not block: the CSI suffix may never arrive. Poll
|
|
177
|
+
# briefly for "[A"-style sequences; timeout means it was just Esc.
|
|
178
|
+
import select
|
|
179
|
+
|
|
180
|
+
seq = ""
|
|
181
|
+
for _ in range(2):
|
|
182
|
+
if select.select([sys.stdin], [], [], 0.06)[0]:
|
|
183
|
+
seq += sys.stdin.read(1)
|
|
184
|
+
else:
|
|
185
|
+
break
|
|
186
|
+
return {"[A": _UP, "[B": _DOWN}.get(seq, _ESC)
|
|
187
|
+
if ch in ("\r", "\n"):
|
|
188
|
+
return _ENTER
|
|
189
|
+
if ch == " ":
|
|
190
|
+
return _SPACE
|
|
191
|
+
if ch == "\x03":
|
|
192
|
+
return _CTRL_C
|
|
193
|
+
if ch == "\x0b":
|
|
194
|
+
return "ctrl-k"
|
|
195
|
+
return f"char:{ch}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _RawMode:
|
|
199
|
+
def __enter__(self) -> _RawMode:
|
|
200
|
+
if sys.platform != "win32": # pragma: no cover - platform
|
|
201
|
+
self._fd = sys.stdin.fileno()
|
|
202
|
+
self._saved = termios.tcgetattr(self._fd)
|
|
203
|
+
tty.setraw(self._fd)
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *exc: object) -> None:
|
|
207
|
+
if sys.platform != "win32": # pragma: no cover - platform
|
|
208
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _rewrite_lines(stream: TextIO, n: int) -> None:
|
|
212
|
+
"""Move up n lines and clear to end — redraw a menu in place."""
|
|
213
|
+
if n > 0:
|
|
214
|
+
stream.write(f"\x1b[{n}A")
|
|
215
|
+
stream.write("\x1b[J")
|
|
216
|
+
stream.flush()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _redraw(ctx: UIContext, stream: TextIO, lines_drawn: int, out: list[str]) -> int:
|
|
220
|
+
"""Redraw the frame. Dumb terminals get a full re-print instead of
|
|
221
|
+
cursor-addressing escapes they cannot honor."""
|
|
222
|
+
if ctx.ansi:
|
|
223
|
+
_rewrite_lines(stream, lines_drawn)
|
|
224
|
+
else:
|
|
225
|
+
stream.write("\n")
|
|
226
|
+
rendered = "\n".join(out) + "\n"
|
|
227
|
+
stream.write(rendered)
|
|
228
|
+
stream.flush()
|
|
229
|
+
return rendered.count("\n")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _option_window(ctx: UIContext, n: int, idx: int, extra: int = 0) -> tuple[int, int]:
|
|
233
|
+
"""Visible slice of an option list — never draw past the screen or the
|
|
234
|
+
in-place redraw corrupts. Keeps the cursor inside the window."""
|
|
235
|
+
avail = max(3, ctx.height - 5 - extra) # title + hint + blank + margin
|
|
236
|
+
if n <= avail:
|
|
237
|
+
return 0, n
|
|
238
|
+
half = avail // 2
|
|
239
|
+
start = min(max(0, idx - half), n - avail)
|
|
240
|
+
return start, start + avail
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _more_marker(ctx: UIContext, direction: str, count: int) -> str:
|
|
244
|
+
arrow = (
|
|
245
|
+
("↑" if direction == "up" else "↓") if ctx.unicode else ("^" if direction == "up" else "v")
|
|
246
|
+
)
|
|
247
|
+
return _c(ctx, "2", f" {arrow} {count} more")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# -- primitives ---------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def select(
|
|
254
|
+
title: str,
|
|
255
|
+
options: list[str],
|
|
256
|
+
*,
|
|
257
|
+
ctx: UIContext | None = None,
|
|
258
|
+
descriptions: list[str] | None = None,
|
|
259
|
+
) -> int | None:
|
|
260
|
+
"""Arrow-key single select. Returns index, or None on cancel."""
|
|
261
|
+
ctx = ctx or UIContext.detect()
|
|
262
|
+
if not ctx.interactive:
|
|
263
|
+
raise NonInteractive("select requires a TTY")
|
|
264
|
+
if not options:
|
|
265
|
+
return None
|
|
266
|
+
idx = 0
|
|
267
|
+
lines_drawn = 0
|
|
268
|
+
with _RawMode():
|
|
269
|
+
while True:
|
|
270
|
+
out = [
|
|
271
|
+
f"{_c(ctx, '1', title)}",
|
|
272
|
+
f" {_c(ctx, '2', t('select_hint', ctx.language))}",
|
|
273
|
+
"",
|
|
274
|
+
]
|
|
275
|
+
start, end = _option_window(ctx, len(options), idx, extra=1 if descriptions else 0)
|
|
276
|
+
if start:
|
|
277
|
+
out.append(_more_marker(ctx, "up", start))
|
|
278
|
+
for i in range(start, end):
|
|
279
|
+
opt = options[i]
|
|
280
|
+
mark = ctx.cursor if i == idx else " "
|
|
281
|
+
line = f" {mark} {_ellipsize(ctx, opt, ctx.width - 6)}"
|
|
282
|
+
out.append(_c(ctx, "7", line) if i == idx and ctx.color else line)
|
|
283
|
+
if descriptions and i == idx and descriptions[i]:
|
|
284
|
+
out.append(
|
|
285
|
+
f" {_c(ctx, '2', _ellipsize(ctx, descriptions[i], ctx.width - 10))}"
|
|
286
|
+
)
|
|
287
|
+
if end < len(options):
|
|
288
|
+
out.append(_more_marker(ctx, "down", len(options) - end))
|
|
289
|
+
lines_drawn = _redraw(ctx, sys.stdout, lines_drawn, out)
|
|
290
|
+
|
|
291
|
+
key = _read_key()
|
|
292
|
+
if key == _UP or key == "char:k":
|
|
293
|
+
idx = (idx - 1) % len(options)
|
|
294
|
+
elif key == _DOWN or key == "char:j":
|
|
295
|
+
idx = (idx + 1) % len(options)
|
|
296
|
+
elif key == _ENTER:
|
|
297
|
+
return idx
|
|
298
|
+
elif key in (_ESC, _CTRL_C) or key in ("char:q", "char:Q"):
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def multi_select(
|
|
303
|
+
title: str,
|
|
304
|
+
options: list[str],
|
|
305
|
+
*,
|
|
306
|
+
checked: set[int] | None = None,
|
|
307
|
+
ctx: UIContext | None = None,
|
|
308
|
+
) -> set[int] | None:
|
|
309
|
+
"""Space-toggle multi select. Returns chosen indices or None."""
|
|
310
|
+
ctx = ctx or UIContext.detect()
|
|
311
|
+
if not ctx.interactive:
|
|
312
|
+
raise NonInteractive("multi_select requires a TTY")
|
|
313
|
+
chosen = set(checked or set())
|
|
314
|
+
idx = 0
|
|
315
|
+
lines_drawn = 0
|
|
316
|
+
if not options:
|
|
317
|
+
return set()
|
|
318
|
+
with _RawMode():
|
|
319
|
+
while True:
|
|
320
|
+
out = [
|
|
321
|
+
f"{_c(ctx, '1', title)}",
|
|
322
|
+
f" {_c(ctx, '2', t('toggle_hint', ctx.language))}",
|
|
323
|
+
"",
|
|
324
|
+
]
|
|
325
|
+
start, end = _option_window(ctx, len(options), idx)
|
|
326
|
+
if start:
|
|
327
|
+
out.append(_more_marker(ctx, "up", start))
|
|
328
|
+
for i in range(start, end):
|
|
329
|
+
opt = options[i]
|
|
330
|
+
mark = ctx.cursor if i == idx else " "
|
|
331
|
+
box = ctx.checked if i in chosen else ctx.unchecked
|
|
332
|
+
line = f" {mark} [{box}] {_ellipsize(ctx, opt, ctx.width - 10)}"
|
|
333
|
+
out.append(_c(ctx, "7", line) if i == idx and ctx.color else line)
|
|
334
|
+
if end < len(options):
|
|
335
|
+
out.append(_more_marker(ctx, "down", len(options) - end))
|
|
336
|
+
lines_drawn = _redraw(ctx, sys.stdout, lines_drawn, out)
|
|
337
|
+
|
|
338
|
+
key = _read_key()
|
|
339
|
+
if key == _UP or key == "char:k":
|
|
340
|
+
idx = (idx - 1) % len(options)
|
|
341
|
+
elif key == _DOWN or key == "char:j":
|
|
342
|
+
idx = (idx + 1) % len(options)
|
|
343
|
+
elif key == _SPACE:
|
|
344
|
+
chosen ^= {idx}
|
|
345
|
+
elif key == _ENTER:
|
|
346
|
+
return chosen
|
|
347
|
+
elif key in (_ESC, _CTRL_C) or key in ("char:q", "char:Q"):
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def prompt(label: str, *, default: str = "", ctx: UIContext | None = None) -> str | None:
|
|
352
|
+
"""Single-line text prompt (cooked mode — safe for paste)."""
|
|
353
|
+
ctx = ctx or UIContext.detect()
|
|
354
|
+
if not ctx.interactive:
|
|
355
|
+
raise NonInteractive("prompt requires a TTY")
|
|
356
|
+
suffix = f" [{default}]" if default else ""
|
|
357
|
+
try:
|
|
358
|
+
answer = input(f"{label}{suffix}: ")
|
|
359
|
+
except (EOFError, KeyboardInterrupt):
|
|
360
|
+
return None
|
|
361
|
+
return answer.strip() or default or None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def confirm(question: str, *, default: bool = False, ctx: UIContext | None = None) -> bool:
|
|
365
|
+
"""y/N confirm — cooked mode, forgiving input."""
|
|
366
|
+
ctx = ctx or UIContext.detect()
|
|
367
|
+
if not ctx.interactive:
|
|
368
|
+
raise NonInteractive("confirm requires a TTY")
|
|
369
|
+
hint = "[Y/n]" if default else "[y/N]"
|
|
370
|
+
try:
|
|
371
|
+
answer = input(f"{question} {hint} ").strip().lower()
|
|
372
|
+
except (EOFError, KeyboardInterrupt):
|
|
373
|
+
return False
|
|
374
|
+
if not answer:
|
|
375
|
+
return default
|
|
376
|
+
return answer in ("y", "yes", "s", "sim")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# -- static rendering ---------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _state_icon(ctx: UIContext, state: str) -> str:
|
|
383
|
+
good = {"OK", "HEALTHY", "READY", "ACTIVE_NOW", "COMPLETED", "DETECTED", "PASS"}
|
|
384
|
+
bad = {"FAIL", "FAILED", "DEGRADED", "BLOCKED", "UNSUPPORTED"}
|
|
385
|
+
if state.upper() in good:
|
|
386
|
+
return _c(ctx, "32", ctx.ok)
|
|
387
|
+
if state.upper() in bad:
|
|
388
|
+
return _c(ctx, "31", ctx.fail)
|
|
389
|
+
return _c(ctx, "33", state.upper()[:8])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(kw_only=True)
|
|
393
|
+
class Section:
|
|
394
|
+
title: str
|
|
395
|
+
rows: list[tuple[str, str]] = field(default_factory=list)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def dashboard(
|
|
399
|
+
title: str,
|
|
400
|
+
sections: list[Section | tuple[str, list[tuple[str, str]]]],
|
|
401
|
+
*,
|
|
402
|
+
ctx: UIContext | None = None,
|
|
403
|
+
) -> str:
|
|
404
|
+
"""Render a one-shot status dashboard; returns the text written."""
|
|
405
|
+
ctx = ctx or UIContext.detect()
|
|
406
|
+
out = [""]
|
|
407
|
+
bar = "─" if ctx.unicode else "-"
|
|
408
|
+
out.append(_c(ctx, "1", f" {title.upper()}"))
|
|
409
|
+
out.append(_c(ctx, "2", bar * min(ctx.width - 1, max(10, cell_width(title) + 8))))
|
|
410
|
+
for sec in sections:
|
|
411
|
+
stitle, rows = (sec.title, sec.rows) if isinstance(sec, Section) else sec
|
|
412
|
+
out.append("")
|
|
413
|
+
out.append(_c(ctx, "36", stitle))
|
|
414
|
+
for label, value in rows:
|
|
415
|
+
val = _ellipsize(ctx, value, ctx.width - 26)
|
|
416
|
+
out.append(f" {_pad_visible(label, 22)} {val}")
|
|
417
|
+
out.append("")
|
|
418
|
+
rendered = "\n".join(out)
|
|
419
|
+
sys.stdout.write(rendered + "\n")
|
|
420
|
+
sys.stdout.flush()
|
|
421
|
+
return rendered
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def status_table(
|
|
425
|
+
rows: list[tuple[str, str, str]],
|
|
426
|
+
*,
|
|
427
|
+
ctx: UIContext | None = None,
|
|
428
|
+
headers: tuple[str, str, str] = ("", "", ""),
|
|
429
|
+
) -> str:
|
|
430
|
+
"""``(name, state, detail)`` rows with state icon; returns text."""
|
|
431
|
+
ctx = ctx or UIContext.detect()
|
|
432
|
+
w_name = max([cell_width(r[0]) for r in rows] + [cell_width(headers[0]), 8])
|
|
433
|
+
out = []
|
|
434
|
+
if any(headers):
|
|
435
|
+
out.append(_c(ctx, "2", f" {headers[0]:<{w_name}} {headers[1]:<10} {headers[2]}"))
|
|
436
|
+
for name, state, detail in rows:
|
|
437
|
+
icon = _pad_visible(_state_icon(ctx, state), 10)
|
|
438
|
+
line = f" {_pad_visible(name, w_name)} {icon} {_ellipsize(ctx, detail)}"
|
|
439
|
+
out.append(line)
|
|
440
|
+
rendered = "\n".join(out)
|
|
441
|
+
sys.stdout.write(rendered + "\n")
|
|
442
|
+
sys.stdout.flush()
|
|
443
|
+
return rendered
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# -- forge/* document rendering ----------------------------------------------
|
|
447
|
+
|
|
448
|
+
_GOOD = {
|
|
449
|
+
"OK",
|
|
450
|
+
"PASS",
|
|
451
|
+
"HEALTHY",
|
|
452
|
+
"COMPLETED",
|
|
453
|
+
"INSTALLED",
|
|
454
|
+
"DETECTED",
|
|
455
|
+
"READY",
|
|
456
|
+
"ACTIVE_NOW",
|
|
457
|
+
"DONE",
|
|
458
|
+
"SUPPORTED",
|
|
459
|
+
}
|
|
460
|
+
_BAD = {
|
|
461
|
+
"FAIL",
|
|
462
|
+
"FAILED",
|
|
463
|
+
"BROKEN",
|
|
464
|
+
"MISSING",
|
|
465
|
+
"UNSUPPORTED",
|
|
466
|
+
"REFUSED",
|
|
467
|
+
"BLOCKED",
|
|
468
|
+
}
|
|
469
|
+
_WARN = {
|
|
470
|
+
"DEGRADED",
|
|
471
|
+
"UNVERIFIED",
|
|
472
|
+
"PLANNED",
|
|
473
|
+
"SKIPPED",
|
|
474
|
+
"VENDORED",
|
|
475
|
+
"NOT_INSTALLED",
|
|
476
|
+
"PENDING",
|
|
477
|
+
"MODIFIED",
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def state_color(ctx: UIContext, state: str, text: str | None = None) -> str:
|
|
482
|
+
"""Color a lifecycle/doctor state; unknown states stay dim."""
|
|
483
|
+
label = text if text is not None else state
|
|
484
|
+
s = state.upper()
|
|
485
|
+
if s in _GOOD:
|
|
486
|
+
return _c(ctx, "32", label)
|
|
487
|
+
if s in _BAD:
|
|
488
|
+
return _c(ctx, "31", label)
|
|
489
|
+
if s in _WARN:
|
|
490
|
+
return _c(ctx, "33", label)
|
|
491
|
+
return _c(ctx, "2", label)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _check_icon(ctx: UIContext, status: str) -> str:
|
|
495
|
+
s = (status or "").upper()
|
|
496
|
+
if s in _GOOD:
|
|
497
|
+
mark = "✓" if ctx.unicode else "v"
|
|
498
|
+
return _c(ctx, "32", mark)
|
|
499
|
+
if s in _BAD:
|
|
500
|
+
mark = "✗" if ctx.unicode else "x"
|
|
501
|
+
return _c(ctx, "31", mark)
|
|
502
|
+
if s == "NOT_APPLICABLE":
|
|
503
|
+
return _c(ctx, "2", "—" if ctx.unicode else "-")
|
|
504
|
+
mark = "?" if not ctx.unicode else "◇"
|
|
505
|
+
return _c(ctx, "33", mark)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _kv(ctx: UIContext, label: str, value: object, width: int = 12) -> str | None:
|
|
509
|
+
if value in (None, "", [], {}):
|
|
510
|
+
return None
|
|
511
|
+
return f" {_pad_visible(label, width)} {_ellipsize(ctx, str(value))}"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def format_status_doc(doc: dict[str, Any], *, ctx: UIContext | None = None) -> str:
|
|
515
|
+
"""Render any ``forge/*`` install/health/receipt doc for humans.
|
|
516
|
+
|
|
517
|
+
Pure — returns the text; callers write it. Honors ``ctx`` color /
|
|
518
|
+
unicode / width so pipes and ``NO_COLOR`` stay clean. Only sections
|
|
519
|
+
with real data are emitted; unknown statuses render dim, never red.
|
|
520
|
+
"""
|
|
521
|
+
ctx = ctx or UIContext.detect()
|
|
522
|
+
out: list[str] = [""]
|
|
523
|
+
bar = "─" if ctx.unicode else "-"
|
|
524
|
+
op = doc.get("operation") or doc.get("command") or "status"
|
|
525
|
+
title = f" {doc.get('forge_id', 'forge')} · {op}".upper()
|
|
526
|
+
out.append(_c(ctx, "1", title))
|
|
527
|
+
out.append(_c(ctx, "2", bar * min(ctx.width - 1, max(10, cell_width(title) + 4))))
|
|
528
|
+
|
|
529
|
+
meta = [
|
|
530
|
+
_kv(
|
|
531
|
+
ctx,
|
|
532
|
+
t("doc_status", ctx.language),
|
|
533
|
+
doc.get("status")
|
|
534
|
+
and state_color(ctx, str(doc["status"]), str(doc["status"]).upper())
|
|
535
|
+
or None,
|
|
536
|
+
),
|
|
537
|
+
_kv(ctx, t("doc_target", ctx.language), doc.get("target_root")),
|
|
538
|
+
_kv(ctx, t("doc_scope", ctx.language), doc.get("scope")),
|
|
539
|
+
_kv(ctx, t("doc_version", ctx.language), doc.get("version")),
|
|
540
|
+
_kv(
|
|
541
|
+
ctx,
|
|
542
|
+
t("doc_profile", ctx.language),
|
|
543
|
+
(doc.get("context") or {}).get("profile") or doc.get("profile"),
|
|
544
|
+
),
|
|
545
|
+
_kv(ctx, t("doc_state_dir", ctx.language), doc.get("state_dir")),
|
|
546
|
+
]
|
|
547
|
+
if doc.get("ledger_entries") is not None:
|
|
548
|
+
meta.append(
|
|
549
|
+
_kv(
|
|
550
|
+
ctx,
|
|
551
|
+
t("doc_managed", ctx.language),
|
|
552
|
+
f"{doc['ledger_entries']} {t('doc_entries', ctx.language)}",
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
for line in meta:
|
|
556
|
+
if line:
|
|
557
|
+
out.append(line)
|
|
558
|
+
|
|
559
|
+
checks = doc.get("checks") or []
|
|
560
|
+
if checks:
|
|
561
|
+
out.append("")
|
|
562
|
+
out.append(_c(ctx, "36", t("doc_checks", ctx.language)))
|
|
563
|
+
w_id = min(28, max(cell_width(str(c.get("id", ""))) for c in checks) + 1)
|
|
564
|
+
for c in checks:
|
|
565
|
+
icon = _check_icon(ctx, str(c.get("status", "")))
|
|
566
|
+
detail = str(c.get("detail") or "")
|
|
567
|
+
if c.get("repairable"):
|
|
568
|
+
detail = (detail + " " if detail else "") + _c(
|
|
569
|
+
ctx, "36", t("doc_repairable", ctx.language)
|
|
570
|
+
)
|
|
571
|
+
line = f" {icon} {_pad_visible(str(c.get('id', '?')), w_id)}"
|
|
572
|
+
if detail:
|
|
573
|
+
line += f" {_ellipsize(ctx, detail, ctx.width - w_id - 8)}"
|
|
574
|
+
out.append(line)
|
|
575
|
+
|
|
576
|
+
drift = doc.get("drift")
|
|
577
|
+
if isinstance(drift, dict) and (drift.get("ok") is not None):
|
|
578
|
+
modified = drift.get("modified") or []
|
|
579
|
+
missing = drift.get("missing") or []
|
|
580
|
+
out.append("")
|
|
581
|
+
out.append(_c(ctx, "36", t("doc_drift", ctx.language)))
|
|
582
|
+
ok_n = drift.get("ok", 0)
|
|
583
|
+
mod_lbl = f"{t('doc_modified', ctx.language)} {len(modified)}"
|
|
584
|
+
miss_lbl = f"{t('doc_missing', ctx.language)} {len(missing)}"
|
|
585
|
+
line = (
|
|
586
|
+
f" {_c(ctx, '32', 'ok ' + str(ok_n))} "
|
|
587
|
+
f"{state_color(ctx, 'MODIFIED', mod_lbl)} "
|
|
588
|
+
f"{state_color(ctx, 'MISSING', miss_lbl)}"
|
|
589
|
+
)
|
|
590
|
+
out.append(line)
|
|
591
|
+
for entry in (modified + missing)[:6]:
|
|
592
|
+
path = entry.get("path") if isinstance(entry, dict) else str(entry)
|
|
593
|
+
kind = entry.get("status") if isinstance(entry, dict) else "?"
|
|
594
|
+
out.append(
|
|
595
|
+
f" {_c(ctx, '2', '·')} {state_color(ctx, str(kind or '?'), str(kind))} "
|
|
596
|
+
f"{_ellipsize(ctx, str(path), ctx.width - 12)}"
|
|
597
|
+
)
|
|
598
|
+
if len(modified + missing) > 6:
|
|
599
|
+
out.append(_c(ctx, "2", f" … {len(modified + missing) - 6} more"))
|
|
600
|
+
|
|
601
|
+
files = doc.get("managed_files") or doc.get("planned_files")
|
|
602
|
+
if files:
|
|
603
|
+
label = "doc_managed_files" if doc.get("managed_files") is not None else "doc_planned_files"
|
|
604
|
+
out.append("")
|
|
605
|
+
out.append(_c(ctx, "36", t(label, ctx.language)))
|
|
606
|
+
out.append(f" {len(files)}")
|
|
607
|
+
for f in files[:5]:
|
|
608
|
+
path = f.get("path") if isinstance(f, dict) else str(f)
|
|
609
|
+
out.append(f" {_c(ctx, '2', '·')} {_ellipsize(ctx, str(path), ctx.width - 8)}")
|
|
610
|
+
if len(files) > 5:
|
|
611
|
+
out.append(_c(ctx, "2", f" … {len(files) - 5} more"))
|
|
612
|
+
|
|
613
|
+
portal = doc.get("forge_portal")
|
|
614
|
+
if isinstance(portal, dict):
|
|
615
|
+
out.append("")
|
|
616
|
+
out.append(_c(ctx, "36", "FORGE PORTAL"))
|
|
617
|
+
state = str(portal.get("state", "?"))
|
|
618
|
+
where = portal.get("install_root") or portal.get("manifest") or ""
|
|
619
|
+
detail = state_color(ctx, state)
|
|
620
|
+
if where:
|
|
621
|
+
detail += f" {_ellipsize(ctx, str(where), ctx.width - 18)}"
|
|
622
|
+
elif state == "vendored":
|
|
623
|
+
detail += _c(ctx, "2", f" {t('doc_portal_vendored', ctx.language)}")
|
|
624
|
+
out.append(f" {detail}")
|
|
625
|
+
|
|
626
|
+
ws = doc.get("workspace")
|
|
627
|
+
if isinstance(ws, dict) and ws.get("members"):
|
|
628
|
+
out.append("")
|
|
629
|
+
out.append(_c(ctx, "36", t("doc_workspace", ctx.language)))
|
|
630
|
+
out.append(f" {len(ws['members'])} {t('doc_members', ctx.language)}")
|
|
631
|
+
for m in ws.get("members") or []:
|
|
632
|
+
path = m.get("path") if isinstance(m, dict) else str(m)
|
|
633
|
+
out.append(f" {_c(ctx, '2', '·')} {_ellipsize(ctx, str(path), ctx.width - 8)}")
|
|
634
|
+
for c in ws.get("conflicts") or []:
|
|
635
|
+
member = c.get("member", "?") if isinstance(c, dict) else str(c)
|
|
636
|
+
fid = c.get("forge_id") if isinstance(c, dict) else None
|
|
637
|
+
decision = c.get("decision") if isinstance(c, dict) else None
|
|
638
|
+
lbl = f"{member}/{fid}" if fid else str(member)
|
|
639
|
+
if decision:
|
|
640
|
+
lbl += f": {decision}"
|
|
641
|
+
out.append(f" {_c(ctx, '33', '▲')} {_ellipsize(ctx, lbl, ctx.width - 10)}")
|
|
642
|
+
for m in ws.get("unknown_members") or []:
|
|
643
|
+
out.append(
|
|
644
|
+
f" {_c(ctx, '33', '·')} {m} "
|
|
645
|
+
f"{_c(ctx, '2', t('doc_unknown_member', ctx.language))}"
|
|
646
|
+
)
|
|
647
|
+
for r in ws.get("member_results") or []:
|
|
648
|
+
out.append(
|
|
649
|
+
f" {_c(ctx, '2', '·')} {r.get('member', '?')}: "
|
|
650
|
+
f"{state_color(ctx, str(r.get('status', '?')))}"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
verification = doc.get("verification")
|
|
654
|
+
if isinstance(verification, dict) and verification.get("status"):
|
|
655
|
+
out.append("")
|
|
656
|
+
line = f" {t('doc_verification', ctx.language):<13} "
|
|
657
|
+
line += state_color(ctx, str(verification["status"]), str(verification["status"]).upper())
|
|
658
|
+
out.append(line)
|
|
659
|
+
|
|
660
|
+
error = doc.get("error")
|
|
661
|
+
if isinstance(error, dict) and (error.get("kind") or error.get("detail")):
|
|
662
|
+
out.append("")
|
|
663
|
+
out.append(
|
|
664
|
+
f" {_c(ctx, '31', t('doc_refused', ctx.language))} "
|
|
665
|
+
f"{_ellipsize(ctx, str(error.get('kind', '')))}"
|
|
666
|
+
)
|
|
667
|
+
if error.get("detail"):
|
|
668
|
+
out.append(f" {_ellipsize(ctx, str(error['detail']), ctx.width - 8)}")
|
|
669
|
+
if error.get("unlock"):
|
|
670
|
+
out.append(
|
|
671
|
+
f" {_c(ctx, '36', t('doc_unlock', ctx.language))} "
|
|
672
|
+
f"{_ellipsize(ctx, str(error['unlock']), ctx.width - 12)}"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
if doc.get("repair_hint"):
|
|
676
|
+
out.append("")
|
|
677
|
+
out.append(f" {_c(ctx, '36', t('doc_repair', ctx.language))} {doc['repair_hint']}")
|
|
678
|
+
out.append("")
|
|
679
|
+
return "\n".join(out)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def approve_plan(doc: dict[str, Any], *, ctx: UIContext | None = None) -> bool:
|
|
683
|
+
"""Print a planned install doc and ask to apply it. TTY only."""
|
|
684
|
+
ctx = ctx or UIContext.detect()
|
|
685
|
+
if not ctx.interactive:
|
|
686
|
+
raise NonInteractive("approve_plan requires a TTY")
|
|
687
|
+
sys.stdout.write(format_status_doc(doc, ctx=ctx) + "\n")
|
|
688
|
+
sys.stdout.flush()
|
|
689
|
+
return confirm(t("apply_q", ctx.language), default=True, ctx=ctx)
|