Merge pull request #7 from AIOSAI/feat/rich-cli-formatting

feat(seedgo): add Rich formatting to CLI output
This commit is contained in:
AIPass
2026-03-02 23:37:50 -08:00
committed by GitHub
6 changed files with 288 additions and 207 deletions
+3 -1
View File
@@ -25,7 +25,9 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = []
dependencies = [
"rich>=13.0",
]
[project.urls]
Homepage = "https://github.com/AIOSAI/AIPass"
+19 -37
View File
@@ -32,6 +32,13 @@ from pathlib import Path
from .config import ConfigError, create_default_config, find_project_root
from .discovery import discover_plugins
from .display import (
print_error,
print_header,
print_init_success,
print_plugin_table,
print_warning,
)
from .reporter import report_results
from .runner import run_checks
@@ -159,17 +166,10 @@ def _cmd_init(args: argparse.Namespace) -> None:
try:
config_path = create_default_config(project_root, profile=profile)
print("Seed Go initialized.")
print(f" Config: {config_path}")
print(f" Plugins: {str(Path(config_path).parent / 'plugins')}")
if profile:
print(f" Profile: {profile}")
print("")
print("Next steps:")
print(" 1. Add plugins to .seedgo/plugins/")
print(" 2. Run: seedgo check")
plugins_path = str(Path(config_path).parent / "plugins")
print_init_success(config_path, plugins_path, profile=profile)
except ConfigError as exc:
print(f"Error: {exc}", file=sys.stderr)
print_error(str(exc), suggestion="Check directory permissions and try again.")
sys.exit(1)
@@ -220,13 +220,14 @@ def _cmd_check(args: argparse.Namespace) -> None:
overall["passed"] = overall["overall_score"] >= args.threshold and error_count == 0
output = report_results(results, overall, format=args.format)
print(output)
if output:
print(output)
except ConfigError as exc:
print(f"Config error: {exc}", file=sys.stderr)
print_error(f"Config error: {exc}")
sys.exit(1)
except Exception as exc:
print(f"Unexpected error: {exc}", file=sys.stderr)
print_error(f"Unexpected error: {exc}")
sys.exit(1)
sys.exit(0 if overall.get("passed", True) else 1)
@@ -235,7 +236,7 @@ def _cmd_check(args: argparse.Namespace) -> None:
def _cmd_list(_args: argparse.Namespace) -> None:
"""Handle `seedgo list`.
Discovers all plugins and prints a formatted table showing:
Discovers all plugins and prints a Rich table showing:
name, description, source, file types.
Exits 0 always (listing is informational).
@@ -247,30 +248,11 @@ def _cmd_list(_args: argparse.Namespace) -> None:
plugins = discover_plugins(project_root)
if not plugins:
print("No plugins found.")
print("")
print("Add plugins to .seedgo/plugins/ or install plugin packages.")
print_warning("No plugins found.")
print_header("SEEDGO — Plugins", "Add plugins to .seedgo/plugins/ or install plugin packages.")
sys.exit(0)
print(f"Found {len(plugins)} plugin(s):\n")
col_name = max(len(p["name"]) for p in plugins) + 2
col_source = max(len(p["source"]) for p in plugins) + 2
header = (
f" {'NAME':<{col_name}}{'SOURCE':<{col_source}}{'FILE TYPES':<20} DESCRIPTION"
)
print(header)
print(" " + "-" * (len(header) - 2))
for plugin in plugins:
module = plugin["module"]
name = plugin["name"]
source = plugin["source"]
description = getattr(module, "PLUGIN_DESCRIPTION", "")
file_types = getattr(module, "FILE_TYPES", ["*"])
types_str = ", ".join(file_types)
print(f" {name:<{col_name}}{source:<{col_source}}{types_str:<20} {description}")
print_header("SEEDGO — Plugins", f"{len(plugins)} plugin(s) discovered")
print_plugin_table(plugins)
sys.exit(0)
+165
View File
@@ -0,0 +1,165 @@
"""Rich-based display utilities for seedgo CLI output.
Provides consistent, styled terminal output using the Rich library.
All seedgo CLI commands use these functions for human-facing output.
Functions:
console — Shared Rich Console instance.
print_header — Branded section header with optional subtitle.
print_plugin — Plugin check result line (name + verdict + score).
print_check_item — Individual check item with severity marker.
print_summary — Overall score and verdict.
print_counts — Check count summary (passed/failed/warnings).
print_plugin_table — Table of discovered plugins (for `seedgo list`).
print_init — Init success message.
"""
from __future__ import annotations
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich import box
from .models import Severity
console = Console()
def print_header(title: str, subtitle: str | None = None) -> None:
"""Print a branded seedgo header panel."""
content = f"[bold cyan]{title}[/bold cyan]"
if subtitle:
content += f"\n[dim]{subtitle}[/dim]"
console.print(Panel(content, border_style="cyan", box=box.ROUNDED, expand=False, padding=(0, 2)))
console.print()
def print_plugin(name: str, file_path: str, passed: bool, score: int) -> None:
"""Print a plugin result header line."""
if passed:
verdict = "[green]PASS[/green]"
icon = "[green]✓[/green]"
else:
verdict = "[red]FAIL[/red]"
icon = "[red]✗[/red]"
# Truncate long paths for readability
path_display = file_path
if len(path_display) > 40:
path_display = "..." + path_display[-37:]
dots = "[dim]" + "·" * max(1, 48 - len(name) - len(path_display)) + "[/dim]"
console.print(f" {icon} [bold]{name}[/bold] [dim]{path_display}[/dim] {dots} {verdict} [dim]({score}/100)[/dim]")
def print_check_item(name: str, passed: bool, message: str, severity: Severity,
line: int | None = None, fix_hint: str | None = None) -> None:
"""Print a single check item with appropriate severity marker."""
if passed:
marker = "[green]✓[/green]"
name_style = "dim"
elif severity == Severity.ERROR:
marker = "[red]✗[/red]"
name_style = "red"
elif severity == Severity.WARNING:
marker = "[yellow]⚠[/yellow]"
name_style = "yellow"
else:
marker = "[cyan]ℹ[/cyan]"
name_style = "cyan"
line_ref = f" [dim]\\[line {line}][/dim]" if line is not None else ""
console.print(f" {marker} [{name_style}]{name}[/{name_style}]: {message}{line_ref}")
if fix_hint and not passed:
console.print(f" [dim]hint: {fix_hint}[/dim]")
def print_summary(score: int, passed: bool, threshold: int) -> None:
"""Print the overall score and verdict."""
if passed:
verdict = "[bold green]PASS[/bold green]"
else:
verdict = "[bold red]FAIL[/bold red]"
console.print(f" [bold]Overall:[/bold] {score}/100 — {verdict} [dim](threshold: {threshold})[/dim]")
def print_counts(plugins_passed: int, plugins_failed: int,
error_count: int = 0, warning_count: int = 0) -> None:
"""Print the check count summary line."""
total = plugins_passed + plugins_failed
parts = [f"{total} check(s) ran", f"{plugins_passed} passed", f"{plugins_failed} failed"]
if error_count:
parts.append(f"[red]{error_count} error(s)[/red]")
if warning_count:
parts.append(f"[yellow]{warning_count} warning(s)[/yellow]")
console.print(" " + ", ".join(parts))
def print_separator() -> None:
"""Print a horizontal separator."""
console.print()
console.print("[dim]" + "─" * 56 + "[/dim]")
console.print()
def print_plugin_table(plugins: list[dict]) -> None:
"""Print a Rich table of discovered plugins."""
table = Table(
show_header=True,
header_style="bold cyan",
border_style="dim",
box=box.ROUNDED,
expand=False,
)
table.add_column("Plugin", style="bold")
table.add_column("Source", style="dim")
table.add_column("File Types")
table.add_column("Description", style="dim")
for plugin in plugins:
module = plugin["module"]
name = plugin["name"]
source = plugin["source"]
description = getattr(module, "PLUGIN_DESCRIPTION", "")
file_types = getattr(module, "FILE_TYPES", ["*"])
types_str = ", ".join(file_types)
table.add_row(name, source, types_str, description)
console.print(table)
def print_init_success(config_path: str, plugins_path: str, profile: str | None = None) -> None:
"""Print init success message."""
console.print()
console.print("[green]✓[/green] [bold]Seed Go initialized[/bold]")
console.print()
console.print(f" Config: [dim]{config_path}[/dim]")
console.print(f" Plugins: [dim]{plugins_path}[/dim]")
if profile:
console.print(f" Profile: [cyan]{profile}[/cyan]")
console.print()
console.print(" [bold]Next steps:[/bold]")
console.print(" 1. Add plugins to .seedgo/plugins/")
console.print(" 2. Run: [cyan]seedgo check[/cyan]")
console.print()
def print_no_results() -> None:
"""Print message when no checks ran."""
console.print("[dim]No checks ran.[/dim]")
def print_error(message: str, suggestion: str | None = None) -> None:
"""Print an error message."""
console.print(f"[red]✗[/red] [red bold]{message}[/red bold]")
if suggestion:
console.print(f" [yellow]→ {suggestion}[/yellow]")
def print_warning(message: str) -> None:
"""Print a warning message."""
console.print(f"[yellow]⚠[/yellow] [yellow]{message}[/yellow]")
+20 -40
View File
@@ -48,40 +48,20 @@ def get_introspective() -> str:
Called when user types `drone @seedgo` with no arguments.
Shows connected plugins and available commands at a glance.
"""
# Discover plugins by calling seedgo list and parsing output
try:
result = subprocess.run(
[sys.executable, "-m", "seedgo", "list"],
capture_output=True,
text=True,
timeout=10,
)
plugin_output = result.stdout if result.stdout else ""
except (subprocess.TimeoutExpired, FileNotFoundError):
plugin_output = ""
# Count plugins from the output
# seedgo list outputs lines like " plugin-name source file_types description"
plugin_lines = []
in_table = False
for line in plugin_output.splitlines():
stripped = line.strip()
if stripped.startswith("---"):
in_table = True
continue
if in_table and stripped:
parts = stripped.split()
if len(parts) >= 2:
name = parts[0]
# Find description — everything after the file types column
# The format is: NAME SOURCE FILE_TYPES DESCRIPTION
desc = ""
if len(parts) >= 4:
# Rejoin from 3rd column onwards as description
# Actually the columns are fixed-width, let's just grab the name
desc = " ".join(parts[3:]) if len(parts) > 3 else ""
plugin_lines.append((name, desc))
Uses direct import of seedgo's discovery module instead of parsing
CLI output, since the adapter lives in the same package.
"""
# Import discovery directly — no subprocess parsing needed
try:
from seedgo.discovery import discover_plugins
except ImportError:
return f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})\n\nDiscovered Plugins: 0\n"
try:
plugins = discover_plugins(None)
except Exception:
plugins = []
lines = []
lines.append(f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})")
@@ -89,16 +69,16 @@ def get_introspective() -> str:
lines.append("Auto-discovered plugin orchestration")
lines.append("")
if plugin_lines:
lines.append(f"Discovered Plugins: {len(plugin_lines)}")
if plugins:
lines.append(f"Discovered Plugins: {len(plugins)}")
lines.append("")
# Find max name length for alignment
max_name = max(len(name) for name, _ in plugin_lines) if plugin_lines else 20
for name, desc in plugin_lines:
max_name = max(len(p["name"]) for p in plugins)
for p in plugins:
desc = getattr(p["module"], "PLUGIN_DESCRIPTION", "")
if desc:
lines.append(f" * {name:<{max_name}} {desc}")
lines.append(f" * {p['name']:<{max_name}} {desc}")
else:
lines.append(f" * {name}")
lines.append(f" * {p['name']}")
else:
lines.append("Discovered Plugins: 0")
+56 -111
View File
@@ -3,10 +3,9 @@ Seed Go Result Reporter
Formats check results for display in three output modes:
"human" — Colored terminal output using raw ANSI escape codes.
"human" — Rich-formatted terminal output using the display module.
Shows plugin names, pass/fail verdicts, scores, and per-check
items with severity markers. Summary line at the bottom.
No external dependencies (no Rich, no colorama).
"json" — Machine-readable JSON dict. Includes all result data and the
overall summary. Safe for piping to other tools.
@@ -20,19 +19,15 @@ All formats are produced by a single entry point: report_results().
import dataclasses
import json
from .models import CheckResult, Severity
# ---------------------------------------------------------------------------
# ANSI color codes (raw escape sequences — zero dependencies)
# ---------------------------------------------------------------------------
_RESET = "\033[0m"
_BOLD = "\033[1m"
_RED = "\033[31m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_CYAN = "\033[36m"
_DIM = "\033[2m"
from .display import (
print_header,
print_plugin,
print_check_item,
print_summary,
print_counts,
print_separator,
print_no_results,
)
def report_results(
@@ -51,18 +46,21 @@ def report_results(
threshold, plugins_passed, plugins_failed, error_count,
warning_count, info_count.
format: Output format. One of:
"human" — Colored terminal output (default).
"human" — Rich terminal output (default). Prints directly,
returns empty string.
"json" — JSON-encoded string, machine-readable.
"github" — GitHub Actions annotation lines.
Returns:
Formatted string ready to print or write to stdout.
For "human" format, prints directly and returns empty string.
Raises:
ValueError: If format is not one of the three supported values.
"""
if format == "human":
return _format_human(results, overall)
_format_human(results, overall)
return ""
elif format == "json":
return _format_json(results, overall)
elif format == "github":
@@ -76,112 +74,59 @@ def report_results(
# ---------------------------------------------------------------------------
def _format_human(results: list[CheckResult], overall: dict) -> str:
"""Produce colored terminal output for human consumption.
def _format_human(results: list[CheckResult], overall: dict) -> None:
"""Print Rich-formatted terminal output for human consumption.
Prints directly to the Rich console via the display module.
Layout:
plugin-name .............. PASS (100/100)
another-plugin ........... FAIL (60/100)
✗ check-name: message [line N]
hint: fix_hint text
✓ passing-check: message
╭─ SEEDGO ─────────────────────────────╮
│ Code Standards Check │
│ 5 plugins · 3 files · threshold: 75 │
╰───────────────────────────────────────╯
✓ plugin-name file.py ········· PASS (100/100)
✓ check-name: message
✗ another-plugin file.py ······ FAIL (60/100)
✗ check-name: message [line N]
hint: fix_hint text
─────────────────────────────────────────
Overall: 80/100 — PASS (threshold: 75)
2 checks ran, 1 passed, 1 failed
"""
lines: list[str] = []
if not results:
lines.append(f"{_DIM}No checks ran.{_RESET}")
lines.append(_summary_line(overall))
return "\n".join(lines)
# Group results by plugin name for a cleaner display
for result in results:
lines.append(_plugin_header_line(result))
for item in result.checks:
lines.extend(_check_item_lines(item))
lines.append("")
lines.append(_summary_line(overall))
lines.append(_counts_line(overall))
return "\n".join(lines)
def _plugin_header_line(result: CheckResult) -> str:
"""Format the plugin name + verdict + score header line."""
name = result.plugin
score = result.score
file_label = f" [{result.file_path}]" if result.file_path else ""
dots = "." * max(1, 50 - len(name) - len(file_label))
if result.passed:
verdict = f"{_GREEN}PASS{_RESET}"
else:
verdict = f"{_RED}FAIL{_RESET}"
score_str = f"({score}/100)"
return f" {_BOLD}{name}{_RESET}{file_label} {_DIM}{dots}{_RESET} {verdict} {_DIM}{score_str}{_RESET}"
def _check_item_lines(item) -> list[str]:
"""Format a single CheckItem into one or two display lines."""
lines: list[str] = []
if item.passed:
marker = f"{_GREEN}✓{_RESET}"
color = _DIM
elif item.severity == Severity.ERROR:
marker = f"{_RED}✗{_RESET}"
color = _RED
elif item.severity == Severity.WARNING:
marker = f"{_YELLOW}⚠{_RESET}"
color = _YELLOW
else:
marker = f"{_CYAN}ℹ{_RESET}"
color = _CYAN
line_ref = f" [line {item.line}]" if item.line is not None else ""
main = f" {marker} {color}{item.name}{_RESET}: {item.message}{line_ref}"
lines.append(main)
if item.fix_hint and not item.passed:
lines.append(f" {_DIM}hint: {item.fix_hint}{_RESET}")
return lines
def _summary_line(overall: dict) -> str:
"""Format the overall score summary line."""
score = overall.get("overall_score", 100)
passed = overall.get("passed", True)
threshold = overall.get("threshold", 75)
if passed:
verdict = f"{_GREEN}{_BOLD}PASS{_RESET}"
else:
verdict = f"{_RED}{_BOLD}FAIL{_RESET}"
return f" {_BOLD}Overall:{_RESET} {score}/100 — {verdict} {_DIM}(threshold: {threshold}){_RESET}"
def _counts_line(overall: dict) -> str:
"""Format the plugin count summary line."""
passed = overall.get("passed", True)
p_passed = overall.get("plugins_passed", 0)
p_failed = overall.get("plugins_failed", 0)
total = p_passed + p_failed
errors = overall.get("error_count", 0)
warnings = overall.get("warning_count", 0)
parts = [f"{total} check(s) ran", f"{p_passed} passed", f"{p_failed} failed"]
if errors:
parts.append(f"{_RED}{errors} error(s){_RESET}")
if warnings:
parts.append(f"{_YELLOW}{warnings} warning(s){_RESET}")
# Header
total_plugins = p_passed + p_failed
subtitle = f"{total_plugins} plugin(s) · threshold: {threshold}"
print_header("SEEDGO — Code Standards Check", subtitle)
return " " + ", ".join(parts)
if not results:
print_no_results()
print_separator()
print_summary(score, passed, threshold)
return
for result in results:
print_plugin(result.plugin, result.file_path, result.passed, result.score)
for item in result.checks:
print_check_item(
item.name, item.passed, item.message, item.severity,
line=item.line, fix_hint=item.fix_hint,
)
print_separator()
print_summary(score, passed, threshold)
print_counts(
p_passed, p_failed,
error_count=overall.get("error_count", 0),
warning_count=overall.get("warning_count", 0),
)
# ---------------------------------------------------------------------------
+25 -18
View File
@@ -548,6 +548,8 @@ def check(file_path, config=None):
class TestReportHuman:
"""Human format prints directly via Rich console. Tests capture stdout."""
def _make_result(self, passed=True, score=100, checks=None):
return CheckResult(
plugin="test-plugin",
@@ -569,34 +571,39 @@ class TestReportHuman:
"info_count": 0,
}
def test_output_contains_plugin_name(self):
def _report_captured(self, results, overall, capsys):
"""Call report_results for human format and return captured output."""
report_results(results, overall, format="human")
return capsys.readouterr().out
def test_output_contains_plugin_name(self, capsys):
result = self._make_result()
output = report_results([result], self._overall())
output = self._report_captured([result], self._overall(), capsys)
assert "test-plugin" in output
def test_passing_result_contains_pass(self):
def test_passing_result_contains_pass(self, capsys):
result = self._make_result(passed=True, score=100)
output = report_results([result], self._overall(score=100, passed=True))
output = self._report_captured([result], self._overall(score=100, passed=True), capsys)
assert "PASS" in output
def test_failing_result_contains_fail(self):
def test_failing_result_contains_fail(self, capsys):
checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)]
result = self._make_result(passed=False, score=0, checks=checks)
output = report_results([result], self._overall(score=0, passed=False))
output = self._report_captured([result], self._overall(score=0, passed=False), capsys)
assert "FAIL" in output
def test_score_shown_in_output(self):
def test_score_shown_in_output(self, capsys):
result = self._make_result(score=75)
output = report_results([result], self._overall(score=75))
output = self._report_captured([result], self._overall(score=75), capsys)
assert "75" in output
def test_check_item_message_shown(self):
def test_check_item_message_shown(self, capsys):
checks = [CheckItem(name="bare-except", passed=False, message="Found bare except", severity=Severity.ERROR)]
result = self._make_result(passed=False, checks=checks)
output = report_results([result], self._overall(passed=False))
output = self._report_captured([result], self._overall(passed=False), capsys)
assert "Found bare except" in output
def test_fix_hint_shown_for_failing_check(self):
def test_fix_hint_shown_for_failing_check(self, capsys):
checks = [
CheckItem(
name="c",
@@ -607,22 +614,22 @@ class TestReportHuman:
)
]
result = self._make_result(passed=False, checks=checks)
output = report_results([result], self._overall(passed=False))
output = self._report_captured([result], self._overall(passed=False), capsys)
assert "Use except Exception:" in output
def test_no_results_shows_no_checks_ran(self):
output = report_results([], self._overall())
def test_no_results_shows_no_checks_ran(self, capsys):
output = self._report_captured([], self._overall(), capsys)
assert "No checks ran" in output
def test_threshold_shown_in_summary(self):
def test_threshold_shown_in_summary(self, capsys):
result = self._make_result()
output = report_results([result], self._overall(threshold=80))
output = self._report_captured([result], self._overall(threshold=80), capsys)
assert "80" in output
def test_line_number_shown_when_present(self):
def test_line_number_shown_when_present(self, capsys):
checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR, line=42)]
result = self._make_result(passed=False, checks=checks)
output = report_results([result], self._overall(passed=False))
output = self._report_captured([result], self._overall(passed=False), capsys)
assert "42" in output
def test_invalid_format_raises_value_error(self):