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.
Files changed (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. 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)