Skip to content

Commit 63f42d1

Browse files
n-papaioannouSebastianGoznerclaude
authored
feat(cli): iMe Core conclusion, TUI banner, and output cleanup (#2)
* feat(cli): wire iMe Core conclusion, TUI banner, and output cleanup Adds the startup banner (logo + version + accent color) and per-category progress TUI from the new _branding module, gated by --quiet. Replaces the post-run email-signup prompt with a styled iMe Core conclusion block and removes the noisy Run-manifest and Inconclusive lines from stdout. Adds an "Access your Full Report here:" header before the report paths and inlines the below-minimum annotation next to Overall Score so the warning lives with the score it describes. Restyles the insufficient-evidence summary to "X out of Y tests have failed" wording for end-user clarity. * fix(cli): prevent absolute fixture paths from poisoning report filenames When --test was run with a fixture passed as an absolute path, the single-test path in execute_tests stored the raw path string in TestRunResult.fixture_name. save_reports then lower-cased it and used it verbatim as part of the output filename, producing nested non-existent directories like ifixai-results/ifixai-openrouter-/users/.../fixture.yaml.json and crashing on write. Use the loaded fixture's metadata.name instead — matching what the strategic and full paths in runner.py already do. Add a defensive _slugify helper in reports.py so any future caller passing a path can't produce traversing filenames. * fix(cli): drop unused supports_color import in _imecore_prompt Ruff F401 surfaced this on the diagnostic CI matrix; the symbol is imported from _branding but never referenced in the module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: SebastianGozner <sebastian.gozner@dazzlerlabs.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 84efb08 commit 63f42d1

5 files changed

Lines changed: 371 additions & 25 deletions

File tree

‎ifixai/cli/_branding.py‎

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import threading
6+
import time
7+
from dataclasses import dataclass, field
8+
from typing import Iterable
9+
10+
import click
11+
12+
from ifixai.types import InspectionCategory
13+
14+
15+
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
16+
17+
18+
class _Spinner:
19+
def __init__(self, message: str) -> None:
20+
self._message = message
21+
self._stop_event = threading.Event()
22+
self._thread: threading.Thread | None = None
23+
self._frame = 0
24+
self._start_time = 0.0
25+
26+
def start(self) -> None:
27+
if self._thread is not None:
28+
return
29+
self._start_time = time.monotonic()
30+
sys.stdout.write(self._render() + "\n")
31+
sys.stdout.flush()
32+
self._thread = threading.Thread(target=self._run, daemon=True)
33+
self._thread.start()
34+
35+
def stop(self) -> None:
36+
if self._thread is None:
37+
return
38+
self._stop_event.set()
39+
self._thread.join(timeout=0.5)
40+
self._thread = None
41+
42+
def _render(self) -> str:
43+
glyph = _SPINNER_FRAMES[self._frame % len(_SPINNER_FRAMES)]
44+
elapsed = int(time.monotonic() - self._start_time) if self._start_time else 0
45+
suffix = f" ({elapsed}s)" if elapsed >= 3 else ""
46+
return _truecolor(f" {glyph} {self._message}{suffix}", _DIM_RGB)
47+
48+
def _run(self) -> None:
49+
while not self._stop_event.wait(0.12):
50+
self._frame += 1
51+
sys.stdout.write("\033[1F\033[2K" + self._render() + "\n")
52+
sys.stdout.flush()
53+
54+
55+
_LOGO_LINES: tuple[str, ...] = (
56+
"██ ███████ ██ ██ ██ █████ ██",
57+
"██ ██ ██ ██ ██ ██ ██ ██",
58+
"██ █████ ██ ███ ███████ ██",
59+
"██ ██ ██ ██ ██ ██ ██ ██",
60+
"██ ██ ██ ██ ██ ██ ██ ██",
61+
)
62+
63+
_ACCENT_RGB = (232, 99, 42)
64+
_DIM_RGB = (110, 110, 117)
65+
66+
_CATEGORY_COLORS: dict[InspectionCategory, tuple[int, int, int]] = {
67+
InspectionCategory.FABRICATION: (255, 139, 92),
68+
InspectionCategory.MANIPULATION: (255, 99, 99),
69+
InspectionCategory.DECEPTION: (167, 139, 250),
70+
InspectionCategory.UNPREDICTABILITY: (251, 191, 36),
71+
InspectionCategory.OPACITY: (96, 165, 250),
72+
}
73+
74+
_CATEGORY_ORDER: tuple[InspectionCategory, ...] = (
75+
InspectionCategory.FABRICATION,
76+
InspectionCategory.MANIPULATION,
77+
InspectionCategory.DECEPTION,
78+
InspectionCategory.UNPREDICTABILITY,
79+
InspectionCategory.OPACITY,
80+
)
81+
82+
_BAR_WIDTH = 26
83+
_BLOCK_FULL = "█"
84+
_BLOCK_EMPTY = "·"
85+
86+
87+
def supports_color(stream=None) -> bool:
88+
s = stream or sys.stdout
89+
if os.environ.get("NO_COLOR"):
90+
return False
91+
if not hasattr(s, "isatty"):
92+
return False
93+
return bool(s.isatty())
94+
95+
96+
def _truecolor(text: str, rgb: tuple[int, int, int], bold: bool = False) -> str:
97+
if not supports_color():
98+
return text
99+
r, g, b = rgb
100+
prefix = f"\033[38;2;{r};{g};{b}m"
101+
if bold:
102+
prefix = "\033[1m" + prefix
103+
return f"{prefix}{text}\033[0m"
104+
105+
106+
def print_startup_banner(version: str, *, quiet: bool = False) -> None:
107+
if quiet or not supports_color():
108+
return
109+
click.echo()
110+
for line in _LOGO_LINES:
111+
click.echo(" " + _truecolor(line, _ACCENT_RGB, bold=True))
112+
click.echo()
113+
click.echo(_truecolor(f" ™ · v{version} · powered by iMe", _DIM_RGB))
114+
click.echo()
115+
116+
117+
@dataclass
118+
class _CategoryRow:
119+
category: InspectionCategory
120+
total: int = 0
121+
done: int = 0
122+
failed: int = 0
123+
124+
125+
@dataclass
126+
class CategoryProgress:
127+
rows: dict[InspectionCategory, _CategoryRow] = field(default_factory=dict)
128+
_printed_lines: int = 0
129+
_started: bool = False
130+
_interactive: bool = False
131+
_spinner: _Spinner | None = None
132+
133+
@classmethod
134+
def from_totals(cls, totals: dict[InspectionCategory, int]) -> "CategoryProgress":
135+
rows = {cat: _CategoryRow(category=cat, total=totals.get(cat, 0)) for cat in _CATEGORY_ORDER}
136+
return cls(rows=rows)
137+
138+
def start(self) -> None:
139+
if self._started:
140+
return
141+
self._started = True
142+
self._interactive = supports_color()
143+
if not self._interactive:
144+
return
145+
total = sum(r.total for r in self.rows.values())
146+
if total == 0:
147+
return
148+
self._spinner = _Spinner(f"Running {total} tests")
149+
self._spinner.start()
150+
self._printed_lines = 1
151+
152+
def record(self, category: InspectionCategory, passing: bool) -> None:
153+
row = self.rows.get(category)
154+
if row is None:
155+
return
156+
if row.total == 0:
157+
row.total = max(1, row.total)
158+
row.done += 1
159+
if not passing:
160+
row.failed += 1
161+
self._redraw()
162+
163+
def finalize(self) -> None:
164+
if self._spinner is not None:
165+
self._spinner.stop()
166+
self._spinner = None
167+
self._redraw(final=True)
168+
if self._interactive and self._printed_lines:
169+
click.echo()
170+
171+
def _redraw(self, *, final: bool = False) -> None:
172+
if not self._started:
173+
return
174+
rendered = self._render()
175+
if not rendered:
176+
return
177+
if self._spinner is not None:
178+
self._spinner.stop()
179+
self._spinner = None
180+
if self._interactive and self._printed_lines:
181+
sys.stdout.write(f"\033[{self._printed_lines}F")
182+
for line in rendered:
183+
sys.stdout.write("\033[2K")
184+
sys.stdout.write(line + "\n")
185+
sys.stdout.flush()
186+
self._printed_lines = len(rendered)
187+
elif self._interactive and not self._printed_lines:
188+
for line in rendered:
189+
sys.stdout.write(line + "\n")
190+
sys.stdout.flush()
191+
self._printed_lines = len(rendered)
192+
elif not self._interactive and final:
193+
for line in rendered:
194+
click.echo(line)
195+
196+
def _render(self) -> list[str]:
197+
out: list[str] = []
198+
for cat in _CATEGORY_ORDER:
199+
row = self.rows[cat]
200+
if row.total == 0:
201+
continue
202+
label = cat.value.upper().ljust(16)
203+
ratio = row.done / row.total if row.total else 0.0
204+
filled = int(round(ratio * _BAR_WIDTH))
205+
bar = _BLOCK_FULL * filled + _BLOCK_EMPTY * (_BAR_WIDTH - filled)
206+
colored_bar = _truecolor(bar, _CATEGORY_COLORS[cat], bold=True)
207+
count = f"{row.done}/{row.total}".rjust(7)
208+
tail = ""
209+
if row.done >= row.total:
210+
if row.failed == 0:
211+
tail = " " + _truecolor("✓", (74, 222, 128))
212+
else:
213+
tail = " " + _truecolor(f"✗ {row.failed} failed", (239, 68, 68))
214+
out.append(f" {label} {colored_bar} {count}{tail}")
215+
return out
216+
217+
218+
def category_totals_from_specs(specs: Iterable[object]) -> dict[InspectionCategory, int]:
219+
counts: dict[InspectionCategory, int] = {cat: 0 for cat in _CATEGORY_ORDER}
220+
for spec in specs:
221+
cat = getattr(spec, "category", None)
222+
if isinstance(cat, InspectionCategory):
223+
counts[cat] = counts.get(cat, 0) + 1
224+
return counts

‎ifixai/cli/_imecore_prompt.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
6+
import click
7+
8+
from ifixai.cli._branding import _ACCENT_RGB, _DIM_RGB, _truecolor
9+
10+
ENV_NO_PROMPT = "IFIXAI_NO_PROMPT"
11+
12+
13+
def print_imecore_conclusion(*, quiet: bool) -> None:
14+
if quiet:
15+
return
16+
if os.environ.get(ENV_NO_PROMPT):
17+
return
18+
if not sys.stdout.isatty():
19+
_print_plain_conclusion()
20+
return
21+
22+
click.echo()
23+
click.echo(click.style("Conclusion", bold=True))
24+
click.echo(
25+
" The report above isn't a bug list. It's the absence of an alignment layer."
26+
)
27+
click.echo()
28+
click.echo(" " + _truecolor("iFixAi measures it. iMe ends it.", _ACCENT_RGB, bold=True))
29+
click.echo()
30+
click.echo(
31+
" iMe is the deterministic alignment runtime: non-LLM, six constitutional"
32+
)
33+
click.echo(" rules, five-stage pipeline.")
34+
click.echo()
35+
click.echo(
36+
" " + _truecolor("Probabilistic guardrails fail. Deterministic rules don't.", _ACCENT_RGB, bold=True)
37+
)
38+
click.echo()
39+
click.echo(" " + _truecolor("Limited release. Selected deployments.", _DIM_RGB))
40+
click.echo(
41+
" Request access → " + _truecolor("https://ifixai.ai/ime", _ACCENT_RGB, bold=True)
42+
)
43+
click.echo()
44+
45+
46+
def _print_plain_conclusion() -> None:
47+
click.echo()
48+
click.echo("Conclusion")
49+
click.echo(" The report above isn't a bug list. It's the absence of an alignment layer.")
50+
click.echo()
51+
click.echo(" iFixAi measures it. iMe ends it.")
52+
click.echo()
53+
click.echo(" iMe is the deterministic alignment runtime: non-LLM, six constitutional")
54+
click.echo(" rules, five-stage pipeline.")
55+
click.echo()
56+
click.echo(" Probabilistic guardrails fail. Deterministic rules don't.")
57+
click.echo()
58+
click.echo(" Limited release. Selected deployments.")
59+
click.echo(" Request access → https://ifixai.ai/ime")
60+
click.echo()

‎ifixai/cli/orchestrator.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,13 @@ def _print_insufficient_evidence_summary(result: TestRunResult) -> None:
185185
insufficient = [br for br in result.test_results if br.insufficient_evidence]
186186
total = len(result.test_results)
187187
if not insufficient:
188-
click.echo(click.style(f"Insufficient evidence: 0/{total} inspections.", fg="green"))
188+
click.echo(click.style(f"0 out of {total} tests have failed.", fg="green"))
189189
return
190190
inspection_ids = ", ".join(sorted(br.test_id for br in insufficient))
191191
click.echo(
192192
click.style(
193-
f"Insufficient evidence: {len(insufficient)}/{total} inspections excluded "
194-
f"from aggregate ({inspection_ids}). Wrap your provider in a governance layer "
193+
f"{len(insufficient)} out of {total} tests have failed "
194+
f"({inspection_ids}). Wrap your provider in a governance layer "
195195
f"or run with ≥2 provider credentials. See docs/methodology.md.",
196196
fg="yellow",
197197
)
@@ -233,14 +233,17 @@ async def execute_tests(
233233
sut_temperature: float = 0.0,
234234
sut_seed: int | None = None,
235235
self_judged: bool = False,
236+
progress_callback=None,
236237
) -> TestRunResult | None:
237238

238239
try:
239-
load_fixture(fixture)
240+
loaded_fixture = load_fixture(fixture)
240241
except FileNotFoundError as exc:
241242
click.echo(click.style(f"Fixture error: {exc}", fg="red"))
242243
return None
243244

245+
effective_callback = progress_callback or _progress_callback
246+
244247
try:
245248
if test_id:
246249
single_result = await run_single(
@@ -270,7 +273,7 @@ async def execute_tests(
270273
system_name=system_name,
271274
system_version=system_version,
272275
provider=provider,
273-
fixture_name=fixture,
276+
fixture_name=loaded_fixture.metadata.name,
274277
overall_score=single_result.score,
275278
strategic_score=single_result.score,
276279
test_results=[single_result],
@@ -288,7 +291,7 @@ async def execute_tests(
288291
model=model,
289292
system_prompt=system_prompt,
290293
timeout=timeout,
291-
progress_callback=_progress_callback,
294+
progress_callback=effective_callback,
292295
pipeline_config=pipeline_config,
293296
judge_config=judge_config,
294297
governor=governor,
@@ -308,7 +311,7 @@ async def execute_tests(
308311
model=model,
309312
system_prompt=system_prompt,
310313
timeout=timeout,
311-
progress_callback=_progress_callback,
314+
progress_callback=effective_callback,
312315
pipeline_config=pipeline_config,
313316
judge_config=judge_config,
314317
governor=governor,

‎ifixai/cli/reports.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from pathlib import Path
23

34
import click
@@ -9,14 +10,22 @@
910
from ifixai.types import TestRunResult
1011

1112

13+
def _slugify(value: str) -> str:
14+
if not value:
15+
return "unknown"
16+
stem = Path(value).stem if ("/" in value or "\\" in value) else value
17+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-").lower()
18+
return cleaned or "unknown"
19+
20+
1221
def save_reports(
1322
result: TestRunResult, output_dir: str, report_format: str
1423
) -> None:
1524
out_path = Path(output_dir)
1625
out_path.mkdir(parents=True, exist_ok=True)
1726

18-
system_slug = result.system_name.lower().replace(" ", "-")
19-
fixture_slug = result.fixture_name.lower().replace(" ", "-")
27+
system_slug = _slugify(result.system_name)
28+
fixture_slug = _slugify(result.fixture_name)
2029
base_name = f"ifixai-{system_slug}-{fixture_slug}"
2130

2231
if report_format in ("json", "both"):

0 commit comments

Comments
 (0)