diff --git a/.gitignore b/.gitignore index 408bf45..61ec895 100644 --- a/.gitignore +++ b/.gitignore @@ -205,4 +205,6 @@ __marimo__/ *.bak # Schema validation test cache -tests/fixtures/schemas/cache/ \ No newline at end of file +tests/fixtures/schemas/cache/ + +.claude/worktrees/ diff --git a/src/ai_rules/cli/__init__.py b/src/ai_rules/cli/__init__.py index b96b065..7516469 100644 --- a/src/ai_rules/cli/__init__.py +++ b/src/ai_rules/cli/__init__.py @@ -74,11 +74,9 @@ def _display_pending_symlink_changes(targets: list["ConfigTarget"]) -> bool: Returns: True if changes were found and displayed, False otherwise """ - from rich.console import Console - + from ai_rules.cli.display import console, print_add, print_update from ai_rules.symlinks import check_symlink, get_content_diff - console = Console() found_changes = False for agent in targets: @@ -111,9 +109,9 @@ def _display_pending_symlink_changes(targets: list["ConfigTarget"]) -> bool: console.print(f"\n[bold]{agent.name}[/bold]") for action, target, source, content_diff in agent_changes: if action == "create": - console.print(f" [green]+[/green] Create: {target} → {source}") + print_add(f"Create: {target} → {source}", indent=2) else: - console.print(f" [yellow]↻[/yellow] Update: {target} → {source}") + print_update(f"Update: {target} → {source}", indent=2) if content_diff: console.print(content_diff) @@ -126,9 +124,7 @@ def _display_pending_plugin_changes(config: "Config") -> bool: Returns: True if changes were found and displayed, False otherwise """ - from rich.console import Console - - console = Console() + from ai_rules.cli.display import console, print_add, print_skipped result = _get_plugin_status(config) if result is None: @@ -141,23 +137,19 @@ def _display_pending_plugin_changes(config: "Config") -> bool: found_changes = True console.print("\n[bold]Marketplaces[/bold]") for marketplace in plugin_status.marketplaces_missing: - console.print( - f" [green]+[/green] Add: {marketplace['name']} ({marketplace['source']})" - ) + print_add(f"Add: {marketplace['name']} ({marketplace['source']})", indent=2) if plugin_status.pending: found_changes = True console.print("\n[bold]Plugins[/bold]") for plugin in plugin_status.pending: - console.print( - f" [green]+[/green] Install: {plugin['name']}@{plugin['marketplace']}" - ) + print_add(f"Install: {plugin['name']}@{plugin['marketplace']}", indent=2) if plugin_status.extra: if not found_changes: console.print("\n[bold]Plugins[/bold]") for name in sorted(plugin_status.extra): - console.print(f" [dim]○[/dim] {name} (Unmanaged)") + print_skipped(f"{name} (Unmanaged)", indent=2) return found_changes @@ -175,10 +167,7 @@ def check_first_run(targets: list["ConfigTarget"], force: bool) -> bool: Returns: True if should continue, False if should abort """ - from rich.console import Console - from rich.prompt import Confirm - - console = Console() + from ai_rules.cli.display import console, print_warning existing_files = [] @@ -194,15 +183,17 @@ def check_first_run(targets: list["ConfigTarget"], force: bool) -> bool: if force: return True - console.print("\n[yellow]Warning:[/yellow] Found existing configuration files:\n") + print_warning("Found existing configuration files:\n") for agent_name, path in existing_files: console.print(f" [{agent_name}] {path}") - console.print( - "\n[dim]These will be replaced with symlinks (originals will be backed up).[/dim]\n" - ) + from ai_rules.cli.display import print_dim - return Confirm.ask("Continue?", default=False) + console.print() + print_dim("These will be replaced with symlinks (originals will be backed up).") + console.print() + + return click.confirm("Continue?", default=False) def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> None: @@ -213,9 +204,7 @@ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> param: Click parameter value: Whether --version flag was provided """ - from rich.console import Console - - console = Console() + from ai_rules.cli.display import console, print_hint if not value or ctx.resilient_parsing: return @@ -232,8 +221,10 @@ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> if statusline_version: console.print(f"statusline, version {statusline_version}") else: + from ai_rules.cli.display import dim + console.print( - "statusline, version [dim](installed, version unknown)[/dim]" + f"statusline, version {dim('(installed, version unknown)')}" ) except Exception as e: logger.debug(f"Failed to get statusline version: {e}") @@ -246,7 +237,9 @@ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> if bm_version: console.print(f"recall, version {bm_version}") else: - console.print("recall, version [dim](installed, version unknown)[/dim]") + from ai_rules.cli.display import dim as _dim + + console.print(f"recall, version {_dim('(installed, version unknown)')}") except Exception as e: logger.debug(f"Failed to get recall version: {e}") @@ -260,7 +253,7 @@ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> console.print( f"\n[cyan]Update available:[/cyan] {update_info.current_version} → {update_info.latest_version}" ) - console.print("[dim]Run 'ai-agent-rules upgrade' to install[/dim]") + print_hint("Run 'ai-agent-rules upgrade' to install") except Exception as e: logger.debug(f"Failed to check for updates in version callback: {e}") @@ -298,11 +291,9 @@ def cleanup_deprecated_symlinks( Returns: Count of removed symlinks """ - from rich.console import Console - + from ai_rules.cli.display import print_would from ai_rules.symlinks import remove_symlink - console = Console() removed_count = 0 for agent in selected_targets: @@ -318,15 +309,15 @@ def cleanup_deprecated_symlinks( continue if dry_run: - console.print( - f" [yellow]Would remove deprecated:[/yellow] {deprecated_path}" - ) + print_would(f"Would remove deprecated: {deprecated_path}", indent=2) removed_count += 1 else: success, message = remove_symlink(target, force=True) if success: - console.print( - f" [dim]Cleaned up deprecated symlink:[/dim] {deprecated_path}" + from ai_rules.cli.display import print_label + + print_label( + "Cleaned up deprecated symlink", str(deprecated_path), indent=2 ) removed_count += 1 diff --git a/src/ai_rules/cli/commands/diff.py b/src/ai_rules/cli/commands/diff.py index 2b26de7..91e5442 100644 --- a/src/ai_rules/cli/commands/diff.py +++ b/src/ai_rules/cli/commands/diff.py @@ -33,15 +33,12 @@ def _complete_components( ) def diff(agents: str | None, component_filter: str | None) -> None: """Show differences between repo configs and installed symlinks.""" - from rich.console import Console - from ai_rules.cli.components import DIFF_COMPONENTS from ai_rules.cli.context import CliContext - from ai_rules.cli.runner import run_components + from ai_rules.cli.display import console, print_hint + from ai_rules.cli.runner import run_diff_parallel from ai_rules.config import Config - console = Console() - config_dir = cli_facade.get_config_dir() config = Config.load() all_targets = cli_facade.get_targets(config_dir, config) @@ -61,11 +58,9 @@ def diff(agents: str | None, component_filter: str | None) -> None: target_filter=agents, component_filter=parsed_filter, ) - result = run_components(DIFF_COMPONENTS, "diff", cli_ctx) + result = run_diff_parallel(DIFF_COMPONENTS, cli_ctx) if not result.changed: console.print("[green]No differences found - all symlinks are correct![/green]") else: - console.print( - "[yellow]💡 Run 'ai-agent-rules install' to fix these differences[/yellow]" - ) + print_hint("Run 'ai-agent-rules install' to fix these differences") diff --git a/src/ai_rules/cli/commands/install.py b/src/ai_rules/cli/commands/install.py index 36e5014..120b91e 100644 --- a/src/ai_rules/cli/commands/install.py +++ b/src/ai_rules/cli/commands/install.py @@ -74,19 +74,16 @@ def install( config_dir_override: str | None = None, ) -> None: """Install AI agent configs via symlinks.""" - from rich.console import Console - from ai_rules.cli.components import INSTALL_COMPONENTS from ai_rules.cli.context import CliContext + from ai_rules.cli.display import console, print_error from ai_rules.cli.runner import run_install_parallel from ai_rules.config import Config - console = Console() - if config_dir_override: config_dir = Path(config_dir_override) if not config_dir.exists(): - console.print(f"[red]Error:[/red] Config directory not found: {config_dir}") + print_error(f"Config directory not found: {config_dir}") sys.exit(1) else: config_dir = cli_facade.get_config_dir() @@ -108,20 +105,23 @@ def install( if profile_conflicts: _handle_profile_conflicts(profile_conflicts, profile, user_config) except ProfileNotFoundError as e: - console.print(f"[red]Error:[/red] {e}") + print_error(str(e)) sys.exit(1) try: config = Config.load(profile=profile) except ProfileNotFoundError as e: - console.print(f"[red]Error:[/red] {e}") + print_error(str(e)) sys.exit(1) if not dry_run: set_active_profile(profile) if profile and profile != "default": - console.print(f"[dim]Using profile: {profile}[/dim]\n") + from ai_rules.cli.display import print_label + + print_label("Using profile", profile) + console.print() all_targets = cli_facade.get_targets(config_dir, config) selected_targets = cli_facade.select_targets(all_targets, agents) diff --git a/src/ai_rules/cli/commands/list_agents.py b/src/ai_rules/cli/commands/list_agents.py index d5d019a..7076975 100644 --- a/src/ai_rules/cli/commands/list_agents.py +++ b/src/ai_rules/cli/commands/list_agents.py @@ -8,14 +8,12 @@ @click.command("list-agents") def list_agents_cmd() -> None: """List available AI agents.""" - from rich.console import Console from rich.table import Table + from ai_rules.cli.display import console from ai_rules.config import Config from ai_rules.symlinks import check_symlink - console = Console() - config_dir = cli_facade.get_config_dir() config = Config.load() targets = cli_facade.get_targets(config_dir, config) diff --git a/src/ai_rules/cli/commands/setup.py b/src/ai_rules/cli/commands/setup.py index 2d34996..ca1e9f5 100644 --- a/src/ai_rules/cli/commands/setup.py +++ b/src/ai_rules/cli/commands/setup.py @@ -5,6 +5,7 @@ import ai_rules.cli as cli_facade from ai_rules.cli.commands.install import install +from ai_rules.cli.display import dim, print_dim @click.command() @@ -36,9 +37,6 @@ def setup( Example: uvx ai-agent-rules setup """ - from rich.console import Console - from rich.prompt import Confirm - from ai_rules.bootstrap import ( ToolSource, ensure_statusline_installed, @@ -46,13 +44,19 @@ def setup( get_tool_config_dir, install_tool, ) - - console = Console() from ai_rules.bootstrap.updater import ( check_tool_updates, get_tool_by_id, perform_tool_upgrade, ) + from ai_rules.cli.display import ( + console, + print_error, + print_hint, + print_progress, + print_success, + print_warning, + ) console.print("[bold cyan]Step 1/3: Install ai-agent-rules system-wide[/bold cyan]") console.print("This allows you to run 'ai-agent-rules' from any directory.\n") @@ -68,26 +72,21 @@ def setup( ) if statusline_result == "installed": if dry_run and statusline_message: - console.print(f"[dim]{statusline_message}[/dim]") + print_dim(statusline_message) else: - console.print("[green]✓[/green] Installed claude-statusline") + print_success("Installed claude-statusline") elif statusline_result == "upgraded": - console.print( - f"[green]✓[/green] Upgraded claude-statusline ({statusline_message})" - ) + print_success(f"Upgraded claude-statusline ({statusline_message})") elif statusline_result == "upgrade_available": - console.print(f"[dim]{statusline_message}[/dim]") + if statusline_message: + print_dim(statusline_message) elif statusline_result == "source_switched": - console.print( - f"[green]✓[/green] Switched claude-statusline source ({statusline_message})" - ) + print_success(f"Switched claude-statusline source ({statusline_message})") elif statusline_result == "source_switch_needed": if dry_run and statusline_message: - console.print(f"[dim]{statusline_message}[/dim]") + print_dim(statusline_message) elif statusline_result == "failed": - console.print( - "[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)" - ) + print_warning("Failed to install claude-statusline (continuing anyway)") ai_rules_tool = get_tool_by_id("ai-agent-rules") tool_install_success = False @@ -112,14 +111,16 @@ def setup( source_name = "PyPI" if dry_run: console.print( - f"[dim]Would switch ai-agent-rules from {current_source.name if current_source else 'unknown'} to {source_name}[/dim]" + dim( + f"Would switch ai-agent-rules from {current_source.name if current_source else 'unknown'} to {source_name}" + ) ) tool_install_success = True else: - if not yes and not Confirm.ask( + if not yes and not click.confirm( f"Switch ai-agent-rules to {source_name} install?", default=True ): - console.print("[yellow]Skipped source switch[/yellow]") + print_warning("Skipped source switch") tool_install_success = True else: uninstall_success, _ = uninstall_tool(ai_rules_tool.package_name) @@ -137,47 +138,37 @@ def setup( force=True, ) if success: - console.print( - f"[green]✓[/green] Switched to {source_name} install" - ) + print_success(f"Switched to {source_name} install") tool_install_success = True else: - console.print( - f"[red]Error:[/red] Failed to install: {message}" - ) + print_error(f"Failed to install: {message}") else: - console.print( - "[red]Error:[/red] Failed to uninstall current version" - ) + print_error("Failed to uninstall current version") else: try: update_info = check_tool_updates(ai_rules_tool, timeout=10) if update_info and update_info.has_update: if dry_run: - console.print( - f"[dim]Would upgrade ai-agent-rules {update_info.current_version} → {update_info.latest_version}[/dim]" + print_dim( + f"Would upgrade ai-agent-rules {update_info.current_version} → {update_info.latest_version}" ) tool_install_success = True else: - if not yes and not Confirm.ask( + if not yes and not click.confirm( f"Upgrade ai-agent-rules {update_info.current_version} → {update_info.latest_version}?", default=True, ): - console.print( - "[yellow]Skipped ai-agent-rules upgrade[/yellow]" - ) + print_warning("Skipped ai-agent-rules upgrade") tool_install_success = True else: success, msg, _ = perform_tool_upgrade(ai_rules_tool) if success: - console.print( - f"[green]✓[/green] Upgraded ai-agent-rules ({update_info.current_version} → {update_info.latest_version})" + print_success( + f"Upgraded ai-agent-rules ({update_info.current_version} → {update_info.latest_version})" ) tool_install_success = True else: - console.print( - "[red]Error:[/red] Failed to upgrade ai-agent-rules" - ) + print_error("Failed to upgrade ai-agent-rules") else: tool_install_success = True except Exception: @@ -185,9 +176,9 @@ def setup( if not tool_install_success: if not yes and not dry_run: - if not Confirm.ask("Install ai-agent-rules permanently?", default=True): - console.print( - "\n[yellow]Skipped.[/yellow] You can still run via: uvx ai-agent-rules " + if not click.confirm("Install ai-agent-rules permanently?", default=True): + print_warning( + "Skipped. You can still run via: uvx ai-agent-rules " ) return @@ -212,18 +203,18 @@ def setup( ) if dry_run: - console.print(f"[dim]{message}[/dim]") + print_dim(message) tool_install_success = True elif success: - console.print("[green]✓[/green] Tool installed successfully") + print_success("Tool installed successfully") tool_install_success = True else: - console.print(f"\n[red]Error:[/red] {message}") - console.print("\n[yellow]Manual installation:[/yellow]") + print_error(message) + print_progress("Manual installation:") console.print(" uv tool install ai-agent-rules") return except Exception as e: - console.print(f"\n[red]Error:[/red] {e}") + print_error(str(e)) return if not skip_symlinks: @@ -238,17 +229,14 @@ def setup( if tool_config_dir.exists(): config_dir_override = str(tool_config_dir) else: - console.print( - f"[yellow]Warning:[/yellow] Tool config not found at expected location: {tool_config_dir}" - ) - console.print( - "[dim]Falling back to current config directory[/dim]\n" + print_warning( + f"Tool config not found at expected location: {tool_config_dir}" ) + print_dim("Falling back to current config directory") + console.print() except Exception as e: - console.print( - f"[yellow]Warning:[/yellow] Could not determine tool config path: {e}" - ) - console.print("[dim]Falling back to current config directory[/dim]\n") + print_warning(f"Could not determine tool config path: {e}") + print_dim("Falling back to current config directory") ctx.invoke( install, @@ -281,33 +269,31 @@ def setup( if is_legacy_completion_block(config_path): success, msg = update_completion(shell, dry_run=dry_run) if success: - console.print(f"[green]✓[/green] {msg}") + print_success(msg) else: - console.print(f"[yellow]⚠[/yellow] {msg}") + print_warning(msg) else: - console.print( - f"[green]✓[/green] {shell} completion already installed" - ) + print_success(f"{shell} completion already installed") elif ( yes or dry_run - or Confirm.ask(f"Install {shell} tab completion?", default=True) + or click.confirm(f"Install {shell} tab completion?", default=True) ): success, msg = install_completion(shell, dry_run=dry_run) if success: - console.print(f"[green]✓[/green] {msg}") + print_success(msg) else: - console.print(f"[yellow]⚠[/yellow] {msg}") + print_warning(msg) else: supported = ", ".join(get_supported_shells()) - console.print( - f"[dim]Shell completion not available for your shell (only {supported} supported)[/dim]" + print_dim( + f"Shell completion not available for your shell (only {supported} supported)" ) if dry_run: - console.print("\n[dim]Dry run complete - no changes were made.[/dim]") + print_hint("Dry run complete - no changes were made.") else: - console.print("\n[green]✓ Setup complete![/green]") + print_success("Setup complete!") console.print( "You can now run [bold]ai-agent-rules[/bold] (or [bold]ai-rules[/bold]) from anywhere." ) diff --git a/src/ai_rules/cli/commands/status.py b/src/ai_rules/cli/commands/status.py index 1355b12..5749434 100644 --- a/src/ai_rules/cli/commands/status.py +++ b/src/ai_rules/cli/commands/status.py @@ -35,16 +35,13 @@ def _complete_components( ) def status(agents: str | None, component_filter: str | None) -> None: """Check status of AI agent symlinks.""" - from rich.console import Console - from ai_rules.cli.components import STATUS_COMPONENTS from ai_rules.cli.context import CliContext - from ai_rules.cli.runner import run_components + from ai_rules.cli.display import console, print_hint + from ai_rules.cli.runner import run_status_parallel from ai_rules.config import Config from ai_rules.state import get_active_profile - console = Console() - config_dir = cli_facade.get_config_dir() config = Config.load() all_targets = cli_facade.get_targets(config_dir, config) @@ -56,7 +53,10 @@ def status(agents: str | None, component_filter: str | None) -> None: active_profile = get_active_profile() if active_profile: - console.print(f"[dim]Profile: {active_profile}[/dim]\n") + from ai_rules.cli.display import print_label + + print_label("Profile", active_profile) + console.print() cli_ctx = CliContext( console=console, @@ -68,23 +68,17 @@ def status(agents: str | None, component_filter: str | None) -> None: target_filter=agents, component_filter=parsed_filter, ) - result = run_components(STATUS_COMPONENTS, "status", cli_ctx) + result = run_status_parallel(STATUS_COMPONENTS, cli_ctx) if not result.ok: if result.counts.get("cache_stale", 0): - console.print( - "[yellow]💡 Run 'ai-agent-rules install --rebuild-cache' to fix issues[/yellow]" - ) + print_hint("Run 'ai-agent-rules install --rebuild-cache' to fix issues") else: - console.print( - "[yellow]💡 Run 'ai-agent-rules install' to fix issues[/yellow]" - ) + print_hint("Run 'ai-agent-rules install' to fix issues") sys.exit(1) if result.counts.get("optional_missing", 0): console.print("[green]All symlinks are correct![/green]") - console.print( - "[yellow]💡 Run 'ai-agent-rules install' to install optional tools[/yellow]" - ) + print_hint("Run 'ai-agent-rules install' to install optional tools") else: console.print("[green]All symlinks are correct![/green]") diff --git a/src/ai_rules/cli/commands/uninstall.py b/src/ai_rules/cli/commands/uninstall.py index 6f8778c..410c5e1 100644 --- a/src/ai_rules/cli/commands/uninstall.py +++ b/src/ai_rules/cli/commands/uninstall.py @@ -36,16 +36,12 @@ def _complete_components( ) def uninstall(yes: bool, agents: str | None, component_filter: str | None) -> None: """Remove AI agent symlinks.""" - from rich.console import Console - from rich.prompt import Confirm - from ai_rules.cli.components import UNINSTALL_COMPONENTS from ai_rules.cli.context import CliContext + from ai_rules.cli.display import console, print_warning from ai_rules.cli.runner import run_uninstall_parallel from ai_rules.config import Config - console = Console() - config_dir = cli_facade.get_config_dir() config = Config.load() all_targets = cli_facade.get_targets(config_dir, config) @@ -54,13 +50,13 @@ def uninstall(yes: bool, agents: str | None, component_filter: str | None) -> No parsed_filter = cli_facade.select_components(UNINSTALL_COMPONENTS, component_filter) if not yes: - console.print("[yellow]Warning:[/yellow] This will remove symlinks for:\n") + print_warning("This will remove symlinks for:\n") console.print("[bold]Agents:[/bold]") for target in selected_targets: console.print(f" • {target.name}") console.print() - if not Confirm.ask("Continue?", default=False): - console.print("[yellow]Uninstall cancelled[/yellow]") + if not click.confirm("Continue?", default=False): + print_warning("Uninstall cancelled") sys.exit(0) cli_ctx = CliContext( diff --git a/src/ai_rules/cli/commands/upgrade.py b/src/ai_rules/cli/commands/upgrade.py index 38fcf29..ec1e4f1 100644 --- a/src/ai_rules/cli/commands/upgrade.py +++ b/src/ai_rules/cli/commands/upgrade.py @@ -6,6 +6,8 @@ import click +from ai_rules.cli.display import print_dim + if TYPE_CHECKING: from ai_rules.bootstrap.updater import ToolSpec @@ -44,9 +46,6 @@ def upgrade( ai-agent-rules upgrade -y # Auto-confirm installation ai-agent-rules upgrade --only=statusline # Only upgrade statusline tool """ - from rich.console import Console - from rich.prompt import Confirm - from ai_rules.bootstrap import ( ToolSource, check_tool_updates, @@ -56,8 +55,14 @@ def upgrade( ) from ai_rules.bootstrap.installer import install_tool from ai_rules.bootstrap.updater import _TOOL_ID_ALIASES - - console = Console() + from ai_rules.cli.display import ( + console, + print_done, + print_error, + print_hint, + print_success, + print_warning, + ) resolved_only = _TOOL_ID_ALIASES.get(only, only) if only else None all_tools = [ @@ -71,10 +76,10 @@ def upgrade( missing_tools = [t for t in all_tools if not t.is_installed()] for tool in missing_tools: - console.print(f"[yellow]⚠[/yellow] {tool.display_name} is not installed") + print_warning(f"{tool.display_name} is not installed") if missing_tools and not check: - if yes or Confirm.ask("\nReinstall missing tools?", default=True): + if yes or click.confirm("\nReinstall missing tools?", default=True): for tool in missing_tools: source, local_path = get_effective_install_source(tool.tool_id) from_github = source == ToolSource.GITHUB @@ -87,18 +92,16 @@ def upgrade( local_path=local_path, ) if success: - console.print(f"[green]✓[/green] {tool.display_name} reinstalled") + print_success(f"{tool.display_name} reinstalled") tools.append(tool) else: - console.print( - f"[red]Error:[/red] Failed to install {tool.display_name}: {msg}" - ) + print_error(f"Failed to install {tool.display_name}: {msg}") if not tools: if only: - console.print(f"[yellow]⚠[/yellow] Tool '{only}' is not installed") + print_warning(f"Tool '{only}' is not installed") else: - console.print("[yellow]⚠[/yellow] No tools are installed") + print_warning("No tools are installed") sys.exit(1) tool_updates = [] @@ -106,35 +109,27 @@ def upgrade( try: current = tool.get_version() if current: - console.print( - f"[dim]{tool.display_name} current version: {current}[/dim]" - ) + print_dim(f"{tool.display_name} current version: {current}") except Exception as e: - console.print( - f"[red]Error:[/red] Could not get {tool.display_name} version: {e}" - ) + print_error(f"Could not get {tool.display_name} version: {e}") continue with console.status(f"Checking {tool.display_name} for updates..."): try: update_info = check_tool_updates(tool) except Exception as e: - console.print( - f"[red]Error:[/red] Failed to check {tool.display_name} updates: {e}" - ) + print_error(f"Failed to check {tool.display_name} updates: {e}") continue if update_info and (update_info.has_update or force): tool_updates.append((tool, update_info)) elif update_info and not update_info.has_update: - console.print( - f"[green]✓[/green] {tool.display_name} is already up to date!" - ) + print_success(f"{tool.display_name} is already up to date!") console.print() if not tool_updates and not force: - console.print("[green]✓[/green] All tools are up to date!") + print_success("All tools are up to date!") return for tool, update_info in tool_updates: @@ -155,7 +150,7 @@ def upgrade( if check: if tool_updates: - console.print("\nRun [bold]ai-agent-rules upgrade[/bold] to install") + print_hint("Run 'ai-agent-rules upgrade' to install") return if not force and not yes: @@ -163,8 +158,8 @@ def upgrade( prompt = f"\nInstall {tool_updates[0][0].display_name} update?" else: prompt = f"\nInstall {len(tool_updates)} updates?" - if not Confirm.ask(prompt, default=True): - console.print("[yellow]Cancelled.[/yellow]") + if not click.confirm(prompt, default=True): + print_warning("Cancelled") return ai_rules_upgraded = False @@ -173,41 +168,32 @@ def upgrade( try: success, msg, was_upgraded = perform_tool_upgrade(tool) except Exception as e: - console.print( - f"\n[red]Error:[/red] {tool.display_name} upgrade failed: {e}" - ) + print_error(f"{tool.display_name} upgrade failed: {e}") continue if success: new_version = tool.get_version() if new_version == update_info.latest_version: - console.print( - f"[green]✓[/green] {tool.display_name} upgraded to {new_version}" - ) + print_success(f"{tool.display_name} upgraded to {new_version}") if tool.tool_id == "ai-agent-rules": ai_rules_upgraded = True elif new_version == update_info.current_version: - console.print( - f"[yellow]⚠[/yellow] {tool.display_name} upgrade reported success but version unchanged ({new_version})" + print_warning( + f"{tool.display_name} upgrade reported success but version unchanged ({new_version})" ) else: - console.print( - f"[green]✓[/green] {tool.display_name} upgraded to {new_version}" - ) + print_success(f"{tool.display_name} upgraded to {new_version}") if tool.tool_id == "ai-agent-rules": ai_rules_upgraded = True else: - console.print( - f"[red]Error:[/red] {tool.display_name} upgrade failed: {msg}" - ) + print_error(f"{tool.display_name} upgrade failed: {msg}") if ai_rules_upgraded and not skip_install: try: import subprocess - console.print( - "\n[dim]Running 'ai-agent-rules install --rebuild-cache'...[/dim]" - ) + console.print() + print_dim("Running 'ai-agent-rules install --rebuild-cache'...") from ai_rules.state import get_active_profile @@ -228,21 +214,15 @@ def upgrade( ) if result.returncode == 0: - console.print("[dim]✓ Install completed successfully[/dim]") + print_done("Install completed successfully") else: - console.print( - f"[yellow]⚠[/yellow] Install failed with exit code {result.returncode}" - ) - console.print( - "[dim]Run 'ai-agent-rules install --rebuild-cache' manually to retry[/dim]" + print_warning(f"Install failed with exit code {result.returncode}") + print_hint( + "Run 'ai-agent-rules install --rebuild-cache' manually to retry" ) except subprocess.TimeoutExpired: - console.print("[yellow]⚠[/yellow] Install timed out after 30 seconds") - console.print( - "[dim]Run 'ai-agent-rules install --rebuild-cache' manually to retry[/dim]" - ) + print_warning("Install timed out after 30 seconds") + print_hint("Run 'ai-agent-rules install --rebuild-cache' manually to retry") except Exception as e: - console.print(f"[yellow]⚠[/yellow] Could not run install: {e}") - console.print( - "[dim]Run 'ai-agent-rules install --rebuild-cache' manually[/dim]" - ) + print_warning(f"Could not run install: {e}") + print_hint("Run 'ai-agent-rules install --rebuild-cache' manually") diff --git a/src/ai_rules/cli/commands/validate.py b/src/ai_rules/cli/commands/validate.py index 79b8775..79010ba 100644 --- a/src/ai_rules/cli/commands/validate.py +++ b/src/ai_rules/cli/commands/validate.py @@ -35,15 +35,12 @@ def _complete_components( ) def validate(agents: str | None, component_filter: str | None) -> None: """Validate configuration and source files.""" - from rich.console import Console - from ai_rules.cli.components import VALIDATE_COMPONENTS from ai_rules.cli.context import CliContext - from ai_rules.cli.runner import run_components + from ai_rules.cli.display import console, print_error + from ai_rules.cli.runner import run_validate_parallel from ai_rules.config import Config - console = Console() - config_dir = cli_facade.get_config_dir() config = Config.load() all_targets = cli_facade.get_targets(config_dir, config) @@ -63,7 +60,7 @@ def validate(agents: str | None, component_filter: str | None) -> None: target_filter=agents, component_filter=parsed_filter, ) - result = run_components(VALIDATE_COMPONENTS, "validate", cli_ctx) + result = run_validate_parallel(VALIDATE_COMPONENTS, cli_ctx) total_checked = result.counts.get("checked", 0) total_issues = result.counts.get("errors", 0) @@ -72,5 +69,5 @@ def validate(agents: str | None, component_filter: str | None) -> None: if result.ok: console.print("[green]All source files are valid![/green]") else: - console.print(f"[red]Found {total_issues} issue(s)[/red]") + print_error(f"Found {total_issues} issue(s)") sys.exit(1) diff --git a/src/ai_rules/cli/components/completions.py b/src/ai_rules/cli/components/completions.py index e27dc8e..871f125 100644 --- a/src/ai_rules/cli/components/completions.py +++ b/src/ai_rules/cli/components/completions.py @@ -31,6 +31,7 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: if not isinstance(plan, CompletionsPlan): return ComponentResult() + from ai_rules.cli.display import print_done from ai_rules.cli.runner import get_console if not plan.needs_install or plan.shell is None: @@ -41,7 +42,8 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: console = get_console(ctx) success, msg = install_completion(plan.shell, dry_run=ctx.dry_run) if success and not ctx.dry_run and "already installed" not in msg: - console.print(f"\n[dim]✓ {msg}[/dim]") + console.print() + print_done(msg) return ComponentResult(changed=success) @@ -55,13 +57,22 @@ def install(self, ctx: CliContext) -> ComponentResult: if not shell: return ComponentResult() + from ai_rules.cli.display import print_done + success, msg = install_completion(shell, dry_run=ctx.dry_run) if success and not ctx.dry_run and "already installed" not in msg: - ctx.console.print(f"\n[dim]✓ {msg}[/dim]") + ctx.console.print() + print_done(msg) return ComponentResult(changed=success) def status(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import ( + ICON_ABSENT, + ICON_SUCCESS, + print_dim, + ) + from ai_rules.cli.runner import get_console from ai_rules.completions import ( detect_shell, find_config_file, @@ -69,25 +80,25 @@ def status(self, ctx: CliContext) -> ComponentResult: is_completion_installed, ) - ctx.console.print("[bold cyan]Shell Completions[/bold cyan]\n") - + console = get_console(ctx) shell = detect_shell() if shell: config_path = find_config_file(shell) if config_path and is_completion_installed(config_path): - ctx.console.print( - f" [green]✓[/green] {shell} completion installed ({config_path})" + console.print( + f" {ICON_SUCCESS} {shell} completion installed ({config_path})" ) else: - ctx.console.print( - f" [yellow]○[/yellow] {shell} completion not installed " + console.print( + f" {ICON_ABSENT} {shell} completion not installed " "(run: ai-agent-rules completions install)" ) else: supported = ", ".join(get_supported_shells()) - ctx.console.print( - f" [dim]Shell completion not available for your shell (only {supported} supported)[/dim]" + print_dim( + f"Shell completion not available for your shell (only {supported} supported)", + indent=2, ) - ctx.console.print() + console.print() return ComponentResult() diff --git a/src/ai_rules/cli/components/config.py b/src/ai_rules/cli/components/config.py index 077e5ba..342a0fc 100644 --- a/src/ai_rules/cli/components/config.py +++ b/src/ai_rules/cli/components/config.py @@ -3,6 +3,10 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import Console from ai_rules.cli.context import ( CliContext, @@ -25,12 +29,19 @@ def _display_symlink_status( target: Path, source: Path, message: str, + console: Console | None = None, ) -> bool: - from rich.console import Console + from ai_rules.cli.display import ( + dim, + get_console, + print_error, + print_success, + print_warning, + ) from ai_rules.symlinks import get_content_diff - console = Console() + active_console: Console = console or get_console() target_str = str(target) if source.is_dir(): @@ -38,35 +49,33 @@ def _display_symlink_status( target_display = target_str if status_code == "correct": - console.print(f" [green]✓[/green] {target_display}") + print_success(target_display, indent=2) return True if status_code == "missing": - console.print(f" [red]✗[/red] {target_display} [dim](not installed)[/dim]") + print_error(f"{target_display} {dim('(not installed)')}", indent=2) return False if status_code == "broken": - console.print(f" [red]✗[/red] {target_display} [dim](broken symlink)[/dim]") + print_error(f"{target_display} {dim('(broken symlink)')}", indent=2) return False if status_code == "wrong_target": - console.print(f" [yellow]⚠[/yellow] {target_display} [dim]({message})[/dim]") + print_warning(f"{target_display} {dim(f'({message})')}", indent=2) try: actual = target.expanduser().resolve() diff_output = get_content_diff(actual, source) if diff_output: - console.print(diff_output) + active_console.print(diff_output) except (OSError, RuntimeError): pass return False if status_code == "not_symlink": - console.print( - f" [yellow]⚠[/yellow] {target_display} [dim](not a symlink)[/dim]" - ) + print_warning(f"{target_display} {dim('(not a symlink)')}", indent=2) try: diff_output = get_content_diff(target.expanduser(), source) if diff_output: - console.print(diff_output) + active_console.print(diff_output) except (OSError, RuntimeError): pass @@ -104,11 +113,16 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: return ComponentResult() from ai_rules.cli import cleanup_deprecated_symlinks - from ai_rules.cli.runner import get_console + from ai_rules.cli.display import ( + dim, + print_absent, + print_error, + print_success, + print_unchanged, + print_update, + ) from ai_rules.symlinks import SymlinkResult, create_symlink - console = get_console(ctx) - created = updated = unchanged = skipped = excluded = errors = 0 excluded = plan.excluded_count @@ -117,19 +131,19 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: result, message = create_symlink(target, source, True, ctx.dry_run) if result == SymlinkResult.CREATED: - console.print(f" [green]✓[/green] {target} → {source}") + print_success(f"{target} → {source}", indent=2) created += 1 elif result == SymlinkResult.ALREADY_CORRECT: - console.print(f" [dim]•[/dim] {target} [dim](already correct)[/dim]") + print_unchanged(f"{target} {dim('(already correct)')}", indent=2) unchanged += 1 elif result == SymlinkResult.UPDATED: - console.print(f" [yellow]↻[/yellow] {target} → {source}") + print_update(f"{target} → {source}", indent=2) updated += 1 elif result == SymlinkResult.SKIPPED: - console.print(f" [yellow]○[/yellow] {target} [dim](skipped)[/dim]") + print_absent(f"{target} {dim('(skipped)')}", indent=2) skipped += 1 elif result == SymlinkResult.ERROR: - console.print(f" [red]✗[/red] {target}: {message}") + print_error(f"{target}: {message}", indent=2) errors += 1 cleanup_deprecated_symlinks( @@ -151,6 +165,15 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: def install(self, ctx: CliContext) -> ComponentResult: from ai_rules.cli import cleanup_deprecated_symlinks + from ai_rules.cli.display import ( + dim, + print_absent, + print_dim, + print_error, + print_success, + print_unchanged, + print_update, + ) from ai_rules.symlinks import SymlinkResult, create_symlink created = updated = unchanged = skipped = excluded = errors = 0 @@ -169,9 +192,7 @@ def install(self, ctx: CliContext) -> ComponentResult: ] if user_excluded_count > 0: - ctx.console.print( - f" [dim]({user_excluded_count} symlink(s) excluded)[/dim]" - ) + print_dim(f"({user_excluded_count} symlink(s) excluded)", indent=2) excluded += user_excluded_count for target, source in config_symlinks: @@ -180,23 +201,19 @@ def install(self, ctx: CliContext) -> ComponentResult: ) if result == SymlinkResult.CREATED: - ctx.console.print(f" [green]✓[/green] {target} → {source}") + print_success(f"{target} → {source}", indent=2) created += 1 elif result == SymlinkResult.ALREADY_CORRECT: - ctx.console.print( - f" [dim]•[/dim] {target} [dim](already correct)[/dim]" - ) + print_unchanged(f"{target} {dim('(already correct)')}", indent=2) unchanged += 1 elif result == SymlinkResult.UPDATED: - ctx.console.print(f" [yellow]↻[/yellow] {target} → {source}") + print_update(f"{target} → {source}", indent=2) updated += 1 elif result == SymlinkResult.SKIPPED: - ctx.console.print( - f" [yellow]○[/yellow] {target} [dim](skipped)[/dim]" - ) + print_absent(f"{target} {dim('(skipped)')}", indent=2) skipped += 1 elif result == SymlinkResult.ERROR: - ctx.console.print(f" [red]✗[/red] {target}: {message}") + print_error(f"{target}: {message}", indent=2) errors += 1 cleanup_deprecated_symlinks( @@ -218,13 +235,15 @@ def install(self, ctx: CliContext) -> ComponentResult: ) def status(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import dim, print_skipped + from ai_rules.cli.runner import get_console from ai_rules.symlinks import check_symlink - ctx.console.print("[bold cyan]Config Files[/bold cyan]\n") + console = get_console(ctx) all_correct = True for target in ctx.selected_targets: - ctx.console.print(f"[bold]{target.name}:[/bold]") + console.print(f"[bold]{target.name}[/bold]") filtered_symlinks = target.get_filtered_symlinks() excluded_symlinks = [ @@ -238,21 +257,24 @@ def status(self, ctx: CliContext) -> ComponentResult: continue status_code, message = check_symlink(tgt, source) - is_correct = _display_symlink_status(status_code, tgt, source, message) + is_correct = _display_symlink_status( + status_code, tgt, source, message, console + ) all_correct = all_correct and is_correct for tgt, _source in excluded_symlinks: - ctx.console.print( - f" [dim]○[/dim] {tgt} [dim](excluded by config)[/dim]" - ) + print_skipped(f"{tgt} {dim('(excluded by config)')}", indent=2) - ctx.console.print() + console.print() return ComponentResult(ok=all_correct, changed=not all_correct) def diff(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import print_dim, print_error, print_warning + from ai_rules.cli.runner import get_console from ai_rules.symlinks import check_symlink, get_content_diff + console = get_console(ctx) found_differences = False for target in ctx.selected_targets: @@ -311,7 +333,7 @@ def diff(self, ctx: CliContext) -> ComponentResult: target_has_diff = True if target_has_diff: - ctx.console.print(f"[bold]{target.name}:[/bold]") + console.print(f"[bold]{target.name}[/bold]") for ( path, expected_source, @@ -320,37 +342,37 @@ def diff(self, ctx: CliContext) -> ComponentResult: content_diff, ) in target_diffs: if diff_type == "missing": - ctx.console.print(f" [red]✗[/red] {path}") - ctx.console.print(f" [dim]{desc}[/dim]") - ctx.console.print( - f" [dim]Expected: → {expected_source}[/dim]" - ) + print_error(str(path), indent=2) + print_dim(desc, indent=4) + print_dim(f"Expected: → {expected_source}", indent=4) elif diff_type == "broken": - ctx.console.print(f" [red]✗[/red] {path}") - ctx.console.print(f" [dim]{desc}[/dim]") + print_error(str(path), indent=2) + print_dim(desc, indent=4) elif diff_type == "wrong": - ctx.console.print(f" [yellow]⚠[/yellow] {path}") - ctx.console.print(f" [dim]{desc}[/dim]") - ctx.console.print( - f" [dim]Expected: → {expected_source}[/dim]" - ) + print_warning(str(path), indent=2) + print_dim(desc, indent=4) + print_dim(f"Expected: → {expected_source}", indent=4) if content_diff: - ctx.console.print(content_diff) + console.print(content_diff) elif diff_type == "file": - ctx.console.print(f" [yellow]⚠[/yellow] {path}") - ctx.console.print(f" [dim]{desc}[/dim]") - ctx.console.print( - f" [dim]Expected: → {expected_source}[/dim]" - ) + print_warning(str(path), indent=2) + print_dim(desc, indent=4) + print_dim(f"Expected: → {expected_source}", indent=4) if content_diff: - ctx.console.print(content_diff) + console.print(content_diff) - ctx.console.print() + console.print() found_differences = True return ComponentResult(ok=not found_differences, changed=found_differences) def uninstall(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import ( + dim, + print_absent, + print_success, + print_unchanged, + ) from ai_rules.cli.runner import get_console from ai_rules.symlinks import remove_symlink @@ -358,7 +380,6 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: total_removed = 0 total_skipped = 0 - console.print("\n[bold cyan]Config Files[/bold cyan]") for target in ctx.selected_targets: console.print(f"\n[bold]{target.name}[/bold]") @@ -368,12 +389,12 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: success, message = remove_symlink(tgt, ctx.yes) if success: - console.print(f" [green]✓[/green] {tgt} removed") + print_success(f"{tgt} removed", indent=2) total_removed += 1 elif "Does not exist" in message: - console.print(f" [dim]•[/dim] {tgt} [dim](not installed)[/dim]") + print_unchanged(f"{tgt} {dim('(not installed)')}", indent=2) else: - console.print(f" [yellow]○[/yellow] {tgt} [dim]({message})[/dim]") + print_absent(f"{tgt} {dim(f'({message})')}", indent=2) total_skipped += 1 return ComponentResult( diff --git a/src/ai_rules/cli/components/extensions.py b/src/ai_rules/cli/components/extensions.py index 8e415bd..5fe29fe 100644 --- a/src/ai_rules/cli/components/extensions.py +++ b/src/ai_rules/cli/components/extensions.py @@ -23,6 +23,14 @@ def install(self, ctx: CliContext) -> ComponentResult: return ComponentResult() from ai_rules.claude_extensions import ClaudeExtensionManager + from ai_rules.cli.display import ( + dim, + print_absent, + print_error, + print_success, + print_unchanged, + print_update, + ) from ai_rules.symlinks import SymlinkResult, create_symlink ext_manager = ClaudeExtensionManager(ctx.config_dir) @@ -50,27 +58,21 @@ def install(self, ctx: CliContext) -> ComponentResult: ) if result == SymlinkResult.CREATED: - ctx.console.print( - f" [green]✓[/green] {target_path} → {source_path}" - ) + print_success(f"{target_path} → {source_path}", indent=2) created += 1 elif result == SymlinkResult.ALREADY_CORRECT: - ctx.console.print( - f" [dim]•[/dim] {target_path} [dim](already correct)[/dim]" + print_unchanged( + f"{target_path} {dim('(already correct)')}", indent=2 ) unchanged += 1 elif result == SymlinkResult.UPDATED: - ctx.console.print( - f" [yellow]↻[/yellow] {target_path} → {source_path}" - ) + print_update(f"{target_path} → {source_path}", indent=2) updated += 1 elif result == SymlinkResult.SKIPPED: - ctx.console.print( - f" [yellow]○[/yellow] {target_path} [dim](skipped)[/dim]" - ) + print_absent(f"{target_path} {dim('(skipped)')}", indent=2) skipped += 1 elif result == SymlinkResult.ERROR: - ctx.console.print(f" [red]✗[/red] {target_path}: {message}") + print_error(f"{target_path}: {message}", indent=2) errors += 1 return ComponentResult( @@ -118,14 +120,20 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: if not isinstance(plan, ClaudeExtensionsPlan): return ComponentResult() + from ai_rules.cli.display import ( + dim, + print_absent, + print_error, + print_success, + print_unchanged, + print_update, + ) from ai_rules.cli.runner import get_console from ai_rules.symlinks import SymlinkResult, create_symlink console = get_console(ctx) created = updated = unchanged = skipped = errors = 0 - console.print("\n[bold cyan]Claude Extensions[/bold cyan]") - current_section = None for ext_type, target_path, source_path in plan.symlink_ops: if ext_type != current_section: @@ -140,23 +148,19 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ) if result == SymlinkResult.CREATED: - console.print(f" [green]✓[/green] {target_path} → {source_path}") + print_success(f"{target_path} → {source_path}", indent=2) created += 1 elif result == SymlinkResult.ALREADY_CORRECT: - console.print( - f" [dim]•[/dim] {target_path} [dim](already correct)[/dim]" - ) + print_unchanged(f"{target_path} {dim('(already correct)')}", indent=2) unchanged += 1 elif result == SymlinkResult.UPDATED: - console.print(f" [yellow]↻[/yellow] {target_path} → {source_path}") + print_update(f"{target_path} → {source_path}", indent=2) updated += 1 elif result == SymlinkResult.SKIPPED: - console.print( - f" [yellow]○[/yellow] {target_path} [dim](skipped)[/dim]" - ) + print_absent(f"{target_path} {dim('(skipped)')}", indent=2) skipped += 1 elif result == SymlinkResult.ERROR: - console.print(f" [red]✗[/red] {target_path}: {message}") + print_error(f"{target_path}: {message}", indent=2) errors += 1 return ComponentResult( @@ -177,6 +181,12 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: return ComponentResult() from ai_rules.claude_extensions import ClaudeExtensionManager + from ai_rules.cli.display import ( + dim, + print_absent, + print_success, + print_unchanged, + ) from ai_rules.cli.runner import get_console from ai_rules.symlinks import remove_symlink @@ -184,7 +194,6 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: ext_manager = ClaudeExtensionManager(ctx.config_dir) removed = skipped = 0 - console.print("\n[bold cyan]Claude Extensions[/bold cyan]") for ext_type in ClaudeExtensionManager.USER_DIRS: managed = ext_manager._get_managed_extensions(ext_type) if not managed: @@ -201,16 +210,12 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: success, message = remove_symlink(target_path, force=ctx.yes) if success: - console.print(f" [green]✓[/green] {target_path} removed") + print_success(f"{target_path} removed", indent=2) removed += 1 elif "Does not exist" in message: - console.print( - f" [dim]•[/dim] {target_path} [dim](not installed)[/dim]" - ) + print_unchanged(f"{target_path} {dim('(not installed)')}", indent=2) else: - console.print( - f" [yellow]○[/yellow] {target_path} [dim]({message})[/dim]" - ) + print_absent(f"{target_path} {dim(f'({message})')}", indent=2) skipped += 1 return ComponentResult( @@ -241,11 +246,13 @@ def status(self, ctx: CliContext) -> ComponentResult: pass from ai_rules.claude_extensions import ClaudeExtensionManager + from ai_rules.cli.display import dim, print_dim + from ai_rules.cli.runner import get_console ext_manager = ClaudeExtensionManager(ctx.config_dir) all_orphaned = ext_manager.get_all_orphaned() all_correct = True - rendered_header = False + console = get_console(ctx) for ext_type, type_name in [ ("agents", "Agents"), @@ -271,53 +278,52 @@ def status(self, ctx: CliContext) -> ComponentResult: ): continue - if not rendered_header: - ctx.console.print("[bold cyan]Claude Extensions[/bold cyan]\n") - rendered_header = True - ctx.console.print(f"[bold]{type_name}:[/bold]") + console.print(f"[bold]{type_name}[/bold]") for name in sorted(type_status.managed_installed.keys()): - ctx.console.print( - f" {name:<20} [green]Installed[/green] [dim](managed)[/dim]" + console.print( + f" {name:<20} [green]Installed[/green] {dim('(managed)')}" ) for name, item in sorted(type_status.managed_wrong_target.items()): if item.is_broken: - ctx.console.print( - f" {name:<20} [red]Broken symlink[/red] [dim](managed)[/dim]" + console.print( + f" {name:<20} [red]Broken symlink[/red] {dim('(managed)')}" ) else: - ctx.console.print( - f" {name:<20} [yellow]Wrong target[/yellow] [dim](managed)[/dim]" + console.print( + f" {name:<20} [yellow]Wrong target[/yellow] {dim('(managed)')}" ) if item.actual_source and item.expected_source: + print_dim(f"Points to {item.actual_source}", indent=4) + print_dim(f"Expected: → {item.expected_source}", indent=4) from ai_rules.symlinks import get_content_diff diff_output = get_content_diff( item.actual_source, item.expected_source ) if diff_output: - ctx.console.print(diff_output) + console.print(diff_output) all_correct = False for name in sorted(type_status.managed_pending.keys()): - ctx.console.print( - f" {name:<20} [yellow]Not installed[/yellow] [dim](managed)[/dim]" + console.print( + f" {name:<20} [yellow]Not installed[/yellow] {dim('(managed)')}" ) all_correct = False for name in sorted(type_status.unmanaged.keys()): if ext_type in all_orphaned and name in all_orphaned[ext_type]: - ctx.console.print(f" {name:<20} [yellow]Orphaned[/yellow]") + console.print(f" {name:<20} [yellow]Orphaned[/yellow]") else: - ctx.console.print(f" {name:<20} [dim]Unmanaged[/dim]") + console.print(f" {name:<20} {dim('Unmanaged')}") for name in sorted(orphaned_hooks.keys()): - ctx.console.print( - f" {name:<20} [yellow]No configuration[/yellow] [dim](orphaned)[/dim]" + console.print( + f" {name:<20} [yellow]No configuration[/yellow] {dim('(orphaned)')}" ) all_correct = False - ctx.console.print() + console.print() return ComponentResult(ok=all_correct, changed=not all_correct) diff --git a/src/ai_rules/cli/components/mcp.py b/src/ai_rules/cli/components/mcp.py index 700d6fd..61302aa 100644 --- a/src/ai_rules/cli/components/mcp.py +++ b/src/ai_rules/cli/components/mcp.py @@ -4,7 +4,7 @@ from typing import Any -from rich.prompt import Confirm +import click from ai_rules.agents.base import Agent from ai_rules.cli.context import ( @@ -54,24 +54,34 @@ def install(self, ctx: CliContext) -> ComponentResult: diff = mgr.format_diff(conflict_name, expected, installed) ctx.console.print(f"\n{diff}\n") - if not ctx.dry_run and not Confirm.ask( + if not ctx.dry_run and not click.confirm( "Overwrite local changes?", default=False ): - ctx.console.print("[yellow]Skipped MCP installation[/yellow]") + from ai_rules.cli.display import print_warning + + print_warning("Skipped MCP installation") skipped += 1 else: result, message, _ = target.install_mcps( force=True, dry_run=ctx.dry_run ) - ctx.console.print(f"[green]✓[/green] {target.name}: {message}") + from ai_rules.cli.display import print_success + + print_success(f"{target.name}: {message}", indent=2) updated += 1 elif result == OperationResult.UPDATED: - ctx.console.print(f"[green]✓[/green] {target.name}: {message}") + from ai_rules.cli.display import print_success + + print_success(f"{target.name}: {message}", indent=2) updated += 1 elif result == OperationResult.ALREADY_INSTALLED: - ctx.console.print(f"[dim]○ {target.name}: {message}[/dim]") + from ai_rules.cli.display import print_skipped + + print_skipped(f"{target.name}: {message}", indent=2) elif result != OperationResult.NOT_FOUND: - ctx.console.print(f"[yellow]⚠[/yellow] {target.name}: {message}") + from ai_rules.cli.display import print_warning + + print_warning(f"{target.name}: {message}", indent=2) errors += 1 return ComponentResult( @@ -123,20 +133,24 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: if not isinstance(plan, MCPPlan): return ComponentResult() - from ai_rules.cli.runner import get_console from ai_rules.mcp import OperationResult - console = get_console(ctx) updated = 0 skipped = 0 errors = 0 + from ai_rules.cli.display import ( + print_skipped, + print_success, + print_warning, + ) + # Targets with conflicts that can't be resolved without a prompt — skip them. # The serial install() path handles prompt-based conflict resolution. for target_name in plan.conflict_targets: - console.print( - f"[yellow]⚠[/yellow] {target_name}: MCP conflicts detected, " - f"skipping (use -y to force-apply)" + print_warning( + f"{target_name}: MCP conflicts detected, skipping (use -y to force-apply)", + indent=2, ) skipped += 1 @@ -157,12 +171,12 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ) if result == OperationResult.UPDATED: - console.print(f"[green]✓[/green] {target.name}: {message}") + print_success(f"{target.name}: {message}", indent=2) updated += 1 elif result == OperationResult.ALREADY_INSTALLED: - console.print(f"[dim]○ {target.name}: {message}[/dim]") + print_skipped(f"{target.name}: {message}", indent=2) elif result != OperationResult.NOT_FOUND: - console.print(f"[yellow]⚠[/yellow] {target.name}: {message}") + print_warning(f"{target.name}: {message}", indent=2) errors += 1 return ComponentResult( @@ -175,8 +189,11 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ) def status(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import dim, print_dim + from ai_rules.cli.runner import get_console + + console = get_console(ctx) all_correct = True - rendered_header = False for target in ctx.selected_targets: if not isinstance(target, Agent): @@ -193,12 +210,8 @@ def status(self, ctx: CliContext) -> ComponentResult: ): continue - if not rendered_header: - ctx.console.print("[bold cyan]MCPs[/bold cyan]\n") - rendered_header = True - mgr = target.get_mcp_manager() - ctx.console.print(f"[bold]{target.name}:[/bold]") + console.print(f"[bold]{target.name}[/bold]") for name in sorted(mcp_status.managed_mcps.keys()): is_installed = mcp_status.installed.get(name, False) has_override = mcp_status.has_overrides.get(name, False) @@ -208,8 +221,8 @@ def status(self, ctx: CliContext) -> ComponentResult: else "[yellow]Outdated[/yellow]" ) override_text = ", override" if has_override else "" - ctx.console.print( - f" {name:<20} {status_text} [dim](managed{override_text})[/dim]" + console.print( + f" {name:<20} {status_text} {dim(f'(managed{override_text})')}" ) if not is_installed and mgr is not None: expected = mgr.load_managed_mcps(ctx.config_dir, ctx.config).get( @@ -222,28 +235,28 @@ def status(self, ctx: CliContext) -> ComponentResult: if line.startswith("MCP"): continue if line.strip(): - ctx.console.print(f" [dim]{line}[/dim]") + print_dim(line, indent=4) all_correct = False for name in sorted(mcp_status.pending_mcps.keys()): has_override = mcp_status.has_overrides.get(name, False) override_text = ", override" if has_override else "" - ctx.console.print( - f" {name:<20} [yellow]Not installed[/yellow] [dim](managed{override_text})[/dim]" + console.print( + f" {name:<20} [yellow]Not installed[/yellow] {dim(f'(managed{override_text})')}" ) if mgr is not None: expected = mcp_status.pending_mcps.get(name, {}) pending_output = mgr.format_pending(name, expected) if pending_output: - ctx.console.print(pending_output) + console.print(pending_output) all_correct = False for name in sorted(mcp_status.stale_mcps.keys()): - ctx.console.print( - f" {name:<20} [red]Should be removed[/red] [dim](no longer in config)[/dim]" + console.print( + f" {name:<20} [red]Should be removed[/red] {dim('(no longer in config)')}" ) all_correct = False for name in sorted(mcp_status.unmanaged_mcps.keys()): - ctx.console.print(f" {name:<20} [dim]Unmanaged[/dim]") - ctx.console.print() + console.print(f" {name:<20} {dim('Unmanaged')}") + console.print() return ComponentResult(ok=all_correct, changed=not all_correct) @@ -251,10 +264,8 @@ def diff(self, ctx: CliContext) -> ComponentResult: return self.status(ctx) def uninstall(self, ctx: CliContext) -> ComponentResult: - from ai_rules.cli.runner import get_console from ai_rules.mcp import OperationResult - console = get_console(ctx) removed = 0 for target in ctx.selected_targets: if not isinstance(target, Agent): @@ -266,9 +277,13 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: result, message = target.uninstall_mcps() if result == OperationResult.REMOVED: - console.print(f" [green]✓[/green] {message}") + from ai_rules.cli.display import print_success + + print_success(message, indent=2) removed += 1 elif result == OperationResult.NOT_FOUND: - console.print(f" [dim]•[/dim] {message}") + from ai_rules.cli.display import print_unchanged + + print_unchanged(message, indent=2) return ComponentResult(changed=removed > 0, counts={"removed": removed}) diff --git a/src/ai_rules/cli/components/optional_tools.py b/src/ai_rules/cli/components/optional_tools.py index 19607e4..eb979dc 100644 --- a/src/ai_rules/cli/components/optional_tools.py +++ b/src/ai_rules/cli/components/optional_tools.py @@ -28,6 +28,7 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ensure_statusline_installed, get_effective_install_source, ) + from ai_rules.cli.display import print_dim, print_success, print_warning from ai_rules.cli.runner import get_console console = get_console(ctx) @@ -37,21 +38,23 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ) if recall_result == "installed": if ctx.dry_run and recall_message: - console.print(f"[dim]{recall_message}[/dim]\n") + print_dim(recall_message) + console.print() else: - console.print("[green]✓[/green] Installed recall\n") + print_success("Installed recall") + console.print() elif recall_result in ("upgraded", "source_switched"): - console.print( - f"[green]✓[/green] Updated recall ({recall_message})\n" - if recall_message - else "[green]✓[/green] Updated recall\n" - ) + if recall_message: + print_success(f"Updated recall ({recall_message})") + else: + print_success("Updated recall") + console.print() elif recall_result == "upgrade_available" and ctx.dry_run and recall_message: - console.print(f"[dim]{recall_message}[/dim]\n") + print_dim(recall_message) + console.print() elif recall_result == "failed": - console.print( - "[yellow]⚠[/yellow] Failed to install recall (continuing anyway)\n" - ) + print_warning("Failed to install recall (continuing anyway)") + console.print() sl_source, sl_local_path = get_effective_install_source( "statusline", config=ctx.config @@ -63,13 +66,14 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ) if statusline_result == "installed": if ctx.dry_run and statusline_message: - console.print(f"[dim]{statusline_message}[/dim]\n") + print_dim(statusline_message) + console.print() else: - console.print("[green]✓[/green] Installed claude-statusline\n") + print_success("Installed claude-statusline") + console.print() elif statusline_result == "failed": - console.print( - "[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)\n" - ) + print_warning("Failed to install claude-statusline (continuing anyway)") + console.print() return ComponentResult() @@ -80,27 +84,30 @@ def install(self, ctx: CliContext) -> ComponentResult: ensure_statusline_installed, get_effective_install_source, ) + from ai_rules.cli.display import print_dim, print_success, print_warning recall_result, recall_message = ensure_recall_installed( dry_run=ctx.dry_run, config=ctx.config ) if recall_result == "installed": if ctx.dry_run and recall_message: - ctx.console.print(f"[dim]{recall_message}[/dim]\n") + print_dim(recall_message) + ctx.console.print() else: - ctx.console.print("[green]✓[/green] Installed recall\n") + print_success("Installed recall") + ctx.console.print() elif recall_result in ("upgraded", "source_switched"): - ctx.console.print( - f"[green]✓[/green] Updated recall ({recall_message})\n" - if recall_message - else "[green]✓[/green] Updated recall\n" - ) + if recall_message: + print_success(f"Updated recall ({recall_message})") + else: + print_success("Updated recall") + ctx.console.print() elif recall_result == "upgrade_available" and ctx.dry_run and recall_message: - ctx.console.print(f"[dim]{recall_message}[/dim]\n") + print_dim(recall_message) + ctx.console.print() elif recall_result == "failed": - ctx.console.print( - "[yellow]⚠[/yellow] Failed to install recall (continuing anyway)\n" - ) + print_warning("Failed to install recall (continuing anyway)") + ctx.console.print() sl_source, sl_local_path = get_effective_install_source( "statusline", config=ctx.config @@ -112,35 +119,37 @@ def install(self, ctx: CliContext) -> ComponentResult: ) if statusline_result == "installed": if ctx.dry_run and statusline_message: - ctx.console.print(f"[dim]{statusline_message}[/dim]\n") + print_dim(statusline_message) + ctx.console.print() else: - ctx.console.print("[green]✓[/green] Installed claude-statusline\n") + print_success("Installed claude-statusline") + ctx.console.print() elif statusline_result == "failed": - ctx.console.print( - "[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)\n" - ) + print_warning("Failed to install claude-statusline (continuing anyway)") + ctx.console.print() return ComponentResult() def status(self, ctx: CliContext) -> ComponentResult: from ai_rules.bootstrap import is_command_available from ai_rules.bootstrap.installer import _is_recall_configured + from ai_rules.cli.display import print_absent, print_success + from ai_rules.cli.runner import get_console - ctx.console.print("[bold cyan]Optional Tools[/bold cyan]\n") - + console = get_console(ctx) missing = 0 if is_command_available("claude-statusline"): - ctx.console.print(" [green]✓[/green] claude-statusline installed") + print_success("claude-statusline installed", indent=2) else: - ctx.console.print(" [yellow]○[/yellow] claude-statusline not installed") + print_absent("claude-statusline not installed", indent=2) missing += 1 if _is_recall_configured(ctx.config): if is_command_available("recall"): - ctx.console.print(" [green]✓[/green] recall installed") + print_success("recall installed", indent=2) else: - ctx.console.print(" [yellow]○[/yellow] recall not installed") + print_absent("recall not installed", indent=2) missing += 1 - ctx.console.print() + console.print() return ComponentResult(counts={"optional_missing": missing}) diff --git a/src/ai_rules/cli/components/plugins.py b/src/ai_rules/cli/components/plugins.py index 63633e2..4f6dd44 100644 --- a/src/ai_rules/cli/components/plugins.py +++ b/src/ai_rules/cli/components/plugins.py @@ -32,17 +32,19 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: if not plan.has_changes: return ComponentResult() - from ai_rules.cli.runner import get_console + from ai_rules.cli.display import ( + print_dim, + print_skipped, + print_success, + print_warning, + ) from ai_rules.plugins import OperationResult, PluginManager - console = get_console(ctx) plugin_manager = PluginManager() if not plugin_manager.is_cli_available(): if not ctx.dry_run: - console.print( - "[dim]○[/dim] Skipped plugin sync (claude CLI not available)" - ) + print_skipped("Skipped plugin sync (claude CLI not available)") return ComponentResult() desired_plugins = ctx.config.get_plugin_configs() @@ -53,16 +55,16 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: ) if plugin_result == OperationResult.SUCCESS: - console.print(f"[green]✓[/green] {message}") + print_success(message) elif plugin_result == OperationResult.ALREADY_INSTALLED: - console.print(f"[dim]○[/dim] {message}") + print_skipped(message) elif plugin_result == OperationResult.DRY_RUN: - console.print(f"[dim]{message}[/dim]") + print_dim(message) elif plugin_result == OperationResult.ERROR: - console.print(f"[yellow]⚠[/yellow] {message}") + print_warning(message) for warning in warnings: - console.print(f"[yellow]⚠[/yellow] {warning}") + print_warning(warning) return ComponentResult( changed=plugin_result in (OperationResult.SUCCESS, OperationResult.DRY_RUN), @@ -75,14 +77,18 @@ def install(self, ctx: CliContext) -> ComponentResult: ): return ComponentResult() + from ai_rules.cli.display import ( + print_dim, + print_skipped, + print_success, + print_warning, + ) from ai_rules.plugins import OperationResult, PluginManager plugin_manager = PluginManager() if not plugin_manager.is_cli_available(): if not ctx.dry_run: - ctx.console.print( - "[dim]○[/dim] Skipped plugin sync (claude CLI not available)" - ) + print_skipped("Skipped plugin sync (claude CLI not available)") return ComponentResult() desired_plugins = ctx.config.get_plugin_configs() @@ -93,16 +99,16 @@ def install(self, ctx: CliContext) -> ComponentResult: ) if plugin_result == OperationResult.SUCCESS: - ctx.console.print(f"[green]✓[/green] {message}") + print_success(message) elif plugin_result == OperationResult.ALREADY_INSTALLED: - ctx.console.print(f"[dim]○[/dim] {message}") + print_skipped(message) elif plugin_result == OperationResult.DRY_RUN: - ctx.console.print(f"[dim]{message}[/dim]") + print_dim(message) elif plugin_result == OperationResult.ERROR: - ctx.console.print(f"[yellow]⚠[/yellow] {message}") + print_warning(message) for warning in warnings: - ctx.console.print(f"[yellow]⚠[/yellow] {warning}") + print_warning(warning) return ComponentResult( changed=plugin_result in (OperationResult.SUCCESS, OperationResult.DRY_RUN), @@ -114,6 +120,7 @@ def status(self, ctx: CliContext) -> ComponentResult: return ComponentResult() from ai_rules.cli import _get_plugin_status + from ai_rules.cli.runner import get_console plugin_result = _get_plugin_status(ctx.config) if plugin_result is None: @@ -129,29 +136,32 @@ def status(self, ctx: CliContext) -> ComponentResult: or plugin_status.extra or plugin_status.marketplaces_missing ): - ctx.console.print("[bold cyan]Claude Plugins[/bold cyan]\n") + from ai_rules.cli.display import dim + + console = get_console(ctx) for marketplace in plugin_status.marketplaces_missing: - ctx.console.print( - f" {marketplace['name']:<20} [yellow]Marketplace missing[/yellow] [dim]({marketplace['source']})[/dim]" + mp_source = marketplace["source"] + console.print( + f" {marketplace['name']:<20} [yellow]Marketplace missing[/yellow] {dim(f'({mp_source})')}" ) all_correct = False for plugin_config in desired_plugins: plugin_key = plugin_config.key if plugin_key in plugin_status.installed: - ctx.console.print( - f" {plugin_config.name:<20} [green]Installed[/green] [dim](managed)[/dim]" + console.print( + f" {plugin_config.name:<20} [green]Installed[/green] {dim('(managed)')}" ) else: - ctx.console.print( - f" {plugin_config.name:<20} [yellow]Not installed[/yellow] [dim](managed)[/dim]" + console.print( + f" {plugin_config.name:<20} [yellow]Not installed[/yellow] {dim('(managed)')}" ) all_correct = False for key in sorted(plugin_status.extra): - ctx.console.print(f" {key:<20} [dim]Unmanaged[/dim]") - ctx.console.print() + console.print(f" {key:<20} {dim('Unmanaged')}") + console.print() return ComponentResult(ok=all_correct, changed=not all_correct) diff --git a/src/ai_rules/cli/components/settings.py b/src/ai_rules/cli/components/settings.py index 427b586..0a8fdfc 100644 --- a/src/ai_rules/cli/components/settings.py +++ b/src/ai_rules/cli/components/settings.py @@ -14,7 +14,7 @@ class SettingsComponent(Component): - label = "Settings" + label = "Settings Cache" component_id = "settings" filterable = False @@ -56,10 +56,6 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: if not isinstance(plan, SettingsPlan): return ComponentResult() - from ai_rules.cli.runner import get_console - - console = get_console(ctx) - if ctx.dry_run: return ComponentResult() @@ -69,18 +65,22 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult: if target.build_merged_settings(force_rebuild=ctx.rebuild_cache): built += 1 except ValueError as exc: - console.print(f"[red]Error building {target.name} config:[/red] {exc}") + from ai_rules.cli.display import print_error + + print_error(f"Error building {target.name} config: {exc}") return ComponentResult(ok=False, abort=True, counts={"errors": 1}) for expanded in plan.excluded_symlinks_to_clean: expanded.unlink() + from ai_rules.cli.display import print_done + orphaned = ctx.config.cleanup_orphaned_cache( {target.target_id for target in ctx.all_targets if target.needs_cache} ) if orphaned: - console.print( - f"[dim]✓ Cleaned up orphaned cache for: {', '.join(orphaned)}[/dim]" + print_done( + f"Cleaned up orphaned cache for: {', '.join(orphaned)}", indent=2 ) return ComponentResult( @@ -101,9 +101,9 @@ def install(self, ctx: CliContext) -> ComponentResult: if target.build_merged_settings(force_rebuild=ctx.rebuild_cache): built += 1 except ValueError as exc: - ctx.console.print( - f"[red]Error building {target.name} config:[/red] {exc}" - ) + from ai_rules.cli.display import print_error + + print_error(f"Error building {target.name} config: {exc}") return ComponentResult(ok=False, abort=True, counts={"errors": 1}) for target in ctx.all_targets: @@ -123,13 +123,15 @@ def install(self, ctx: CliContext) -> ComponentResult: continue expanded.unlink() + from ai_rules.cli.display import print_done + targets_needing_cache = { target.target_id for target in ctx.all_targets if target.needs_cache } orphaned = ctx.config.cleanup_orphaned_cache(targets_needing_cache) if orphaned: - ctx.console.print( - f"[dim]✓ Cleaned up orphaned cache for: {', '.join(orphaned)}[/dim]" + print_done( + f"Cleaned up orphaned cache for: {', '.join(orphaned)}", indent=2 ) return ComponentResult( @@ -138,6 +140,8 @@ def install(self, ctx: CliContext) -> ComponentResult: ) def status(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.runner import get_console + stale_targets = [] for target in ctx.selected_targets: if target.needs_cache and target.is_cache_stale(): @@ -146,14 +150,16 @@ def status(self, ctx: CliContext) -> ComponentResult: if not stale_targets: return ComponentResult() - ctx.console.print("[bold cyan]Settings Cache[/bold cyan]\n") + from ai_rules.cli.display import print_warning + + console = get_console(ctx) for target in stale_targets: - ctx.console.print(f"[bold]{target.name}:[/bold]") - ctx.console.print(" [yellow]⚠[/yellow] Cached settings are stale") + console.print(f"[bold]{target.name}[/bold]") + print_warning("Cached settings are stale", indent=2) diff_output = target.get_cache_diff() if diff_output: - ctx.console.print(diff_output) - ctx.console.print() + console.print(diff_output) + console.print() return ComponentResult( ok=False, diff --git a/src/ai_rules/cli/components/skills.py b/src/ai_rules/cli/components/skills.py index f488749..88e66fc 100644 --- a/src/ai_rules/cli/components/skills.py +++ b/src/ai_rules/cli/components/skills.py @@ -284,8 +284,11 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: ) def status(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import dim, print_dim + from ai_rules.cli.runner import get_console + + console = get_console(ctx) all_correct = True - rendered_header = False for target in ctx.selected_targets: skill_status = ( @@ -320,48 +323,47 @@ def status(self, ctx: CliContext) -> ComponentResult: ): continue - if not rendered_header: - ctx.console.print("[bold cyan]Skills[/bold cyan]\n") - rendered_header = True - ctx.console.print(f"[bold]{target.name}:[/bold]") + console.print(f"[bold]{target.name}[/bold]") for name in sorted(skill_status.managed_installed.keys()): - ctx.console.print( - f" {name:<20} [green]Installed[/green] [dim](managed)[/dim]" + console.print( + f" {name:<20} [green]Installed[/green] {dim('(managed)')}" ) for name, item in sorted(skill_status.managed_wrong_target.items()): if item.is_broken: - ctx.console.print( - f" {name:<20} [red]Broken symlink[/red] [dim](managed)[/dim]" + console.print( + f" {name:<20} [red]Broken symlink[/red] {dim('(managed)')}" ) else: - ctx.console.print( - f" {name:<20} [yellow]Wrong target[/yellow] [dim](managed)[/dim]" + console.print( + f" {name:<20} [yellow]Wrong target[/yellow] {dim('(managed)')}" ) if item.actual_source and item.expected_source: + print_dim(f"Points to {item.actual_source}", indent=4) + print_dim(f"Expected: → {item.expected_source}", indent=4) from ai_rules.symlinks import get_content_diff diff_output = get_content_diff( item.actual_source, item.expected_source ) if diff_output: - ctx.console.print(diff_output) + console.print(diff_output) all_correct = False for name in sorted(skill_status.managed_pending.keys()): - ctx.console.print( - f" {name:<20} [yellow]Not installed[/yellow] [dim](managed)[/dim]" + console.print( + f" {name:<20} [yellow]Not installed[/yellow] {dim('(managed)')}" ) all_correct = False for name in sorted(skill_status.unmanaged.keys()): if name in orphaned_skills: - ctx.console.print(f" {name:<20} [yellow]Orphaned[/yellow]") + console.print(f" {name:<20} [yellow]Orphaned[/yellow]") else: - ctx.console.print(f" {name:<20} [dim]Unmanaged[/dim]") + console.print(f" {name:<20} {dim('Unmanaged')}") - ctx.console.print() + console.print() return ComponentResult(ok=all_correct, changed=not all_correct) diff --git a/src/ai_rules/cli/components/source_files.py b/src/ai_rules/cli/components/source_files.py index 8b2430b..bcbcda7 100644 --- a/src/ai_rules/cli/components/source_files.py +++ b/src/ai_rules/cli/components/source_files.py @@ -10,12 +10,16 @@ class SourceFilesComponent(Component): component_id = "source-files" def validate(self, ctx: CliContext) -> ComponentResult: + from ai_rules.cli.display import print_dim, print_error, print_success + from ai_rules.cli.runner import get_console + + console = get_console(ctx) all_valid = True total_checked = 0 total_issues = 0 for target in ctx.selected_targets: - ctx.console.print(f"[bold]{target.name}:[/bold]") + console.print(f"[bold]{target.name}[/bold]") target_issues = [] for _tgt, source in target.symlinks: @@ -28,7 +32,7 @@ def validate(self, ctx: CliContext) -> ComponentResult: target_issues.append((source, "Source is not a file or directory")) all_valid = False else: - ctx.console.print(f" [green]✓[/green] {source.name}") + print_success(source.name, indent=2) excluded_symlinks = [ (tgt, source) @@ -36,16 +40,17 @@ def validate(self, ctx: CliContext) -> ComponentResult: if (tgt, source) not in target.get_filtered_symlinks() ] if excluded_symlinks: - ctx.console.print( - f" [dim]({len(excluded_symlinks)} symlink(s) excluded by config)[/dim]" + print_dim( + f"({len(excluded_symlinks)} symlink(s) excluded by config)", + indent=2, ) for path, issue in target_issues: - ctx.console.print(f" [red]✗[/red] {path}") - ctx.console.print(f" [dim]{issue}[/dim]") + print_error(str(path), indent=2) + print_dim(issue, indent=4) total_issues += 1 - ctx.console.print() + console.print() return ComponentResult( ok=all_valid, diff --git a/src/ai_rules/cli/display.py b/src/ai_rules/cli/display.py new file mode 100644 index 0000000..73240a0 --- /dev/null +++ b/src/ai_rules/cli/display.py @@ -0,0 +1,135 @@ +"""Shared console proxy and display helpers. + +All CLI output routes through the ``console`` singleton exported here. +During parallel execution, worker threads set ``_console_override`` to a +per-thread ``Console(file=StringIO())`` so that ``console.print()`` writes +to the thread-local buffer transparently — no component code changes needed. +""" + +from __future__ import annotations + +import contextvars + +from typing import Any + +from rich.console import Console + +_console_override: contextvars.ContextVar[Console | None] = contextvars.ContextVar( + "_console_override", default=None +) + +_real_console = Console() + + +class _ConsoleProxy: + """Proxy that routes console calls through a ContextVar override.""" + + def __init__(self, real: Console) -> None: + self._real = real + + def _active(self) -> Console: + return _console_override.get() or self._real + + def print(self, *args: Any, **kwargs: Any) -> None: + self._active().print(*args, **kwargs) + + def print_exception(self, *args: Any, **kwargs: Any) -> None: + self._active().print_exception(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(self._active(), name) + + +console: Any = _ConsoleProxy(_real_console) + + +def get_console() -> Console: + """Return the active console for the current execution context.""" + return _console_override.get() or _real_console + + +# ─── Icon constants ────────────────────────────────────────────────────────── + +ICON_SUCCESS = "[green]✓[/green]" +ICON_DONE = "[dim]✓[/dim]" +ICON_UNCHANGED = "[dim]•[/dim]" +ICON_SKIPPED = "[dim]○[/dim]" +ICON_ABSENT = "[yellow]○[/yellow]" +ICON_UPDATE = "[yellow]↻[/yellow]" +ICON_ERROR = "[red]✗[/red]" +ICON_WARNING = "[yellow]⚠[/yellow]" +ICON_INFO = "[blue]ℹ[/blue]" +ICON_HINT = "[yellow]💡[/yellow]" +ICON_WOULD = "[dim]→[/dim]" +ICON_ADD = "[green]+[/green]" +ICON_NONE = "[dim]-[/dim]" + + +# ─── Markup builder ────────────────────────────────────────────────────────── + + +def dim(text: str) -> str: + return f"[dim]{text}[/dim]" + + +# ─── Print helpers ─────────────────────────────────────────────────────────── + + +def print_error(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_ERROR} {message}") + + +def print_warning(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_WARNING} {message}") + + +def print_info(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_INFO} {message}") + + +def print_hint(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_HINT} {message}") + + +def print_success(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_SUCCESS} {message}") + + +def print_done(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_DONE} {message}") + + +def print_unchanged(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_UNCHANGED} {message}") + + +def print_skipped(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_SKIPPED} {message}") + + +def print_absent(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_ABSENT} {message}") + + +def print_update(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_UPDATE} {message}") + + +def print_would(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_WOULD} {message}") + + +def print_add(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{ICON_ADD} {message}") + + +def print_progress(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}[yellow]{message}[/yellow]") + + +def print_dim(message: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{dim(message)}") + + +def print_label(key: str, value: str, *, indent: int = 0) -> None: + get_console().print(f"{' ' * indent}{dim(key + ':')} {value}") diff --git a/src/ai_rules/cli/groups/completions.py b/src/ai_rules/cli/groups/completions.py index 725f8a6..cbb2a40 100644 --- a/src/ai_rules/cli/groups/completions.py +++ b/src/ai_rules/cli/groups/completions.py @@ -4,6 +4,15 @@ import click +from ai_rules.cli.display import ( + ICON_ABSENT, + ICON_NONE, + ICON_SUCCESS, + dim, + print_hint, + print_label, +) + @click.group() def completions() -> None: @@ -19,40 +28,40 @@ def completions() -> None: @completions.command(name="bash") def completions_bash() -> None: """Output bash completion script for manual installation.""" - from rich.console import Console - + from ai_rules.cli.display import console from ai_rules.completions import generate_completion_script - console = Console() - try: script = generate_completion_script("bash") console.print(script) - console.print( - "\n[dim]To install: Add the above to your ~/.bashrc or run:[/dim]" + console.print() + print_hint( + "To install: Add the above to your ~/.bashrc or run: ai-agent-rules completions install" ) - console.print("[dim] ai-agent-rules completions install[/dim]") except Exception as e: - console.print(f"[red]Error generating completion script:[/red] {e}") + from ai_rules.cli.display import print_error + + print_error(f"Error generating completion script: {e}") sys.exit(1) @completions.command(name="zsh") def completions_zsh() -> None: """Output zsh completion script for manual installation.""" - from rich.console import Console - + from ai_rules.cli.display import console from ai_rules.completions import generate_completion_script - console = Console() - try: script = generate_completion_script("zsh") console.print(script) - console.print("\n[dim]To install: Add the above to your ~/.zshrc or run:[/dim]") - console.print("[dim] ai-agent-rules completions install[/dim]") + console.print() + print_hint( + "To install: Add the above to your ~/.zshrc or run: ai-agent-rules completions install" + ) except Exception as e: - console.print(f"[red]Error generating completion script:[/red] {e}") + from ai_rules.cli.display import print_error + + print_error(f"Error generating completion script: {e}") sys.exit(1) @@ -64,27 +73,22 @@ def completions_zsh() -> None: ) def completions_install(shell: str | None) -> None: """Install shell completion to config file.""" - from rich.console import Console - + from ai_rules.cli.display import print_error, print_success from ai_rules.completions import detect_shell, install_completion - console = Console() - if shell is None: shell = detect_shell() if shell is None: - console.print( - "[red]Error:[/red] Could not detect shell. Please specify with --shell" - ) + print_error("Could not detect shell. Please specify with --shell") sys.exit(1) - console.print(f"[dim]Detected shell:[/dim] {shell}") + print_label("Detected shell", shell) success, message = install_completion(shell, dry_run=False) if success: - console.print(f"[green]✓[/green] {message}") + print_success(message) else: - console.print(f"[red]Error:[/red] {message}") + print_error(message) sys.exit(1) @@ -96,35 +100,30 @@ def completions_install(shell: str | None) -> None: ) def completions_uninstall(shell: str | None) -> None: """Remove shell completion from config file.""" - from rich.console import Console - + from ai_rules.cli.display import print_error, print_success from ai_rules.completions import ( detect_shell, find_config_file, uninstall_completion, ) - console = Console() - if shell is None: shell = detect_shell() if shell is None: - console.print( - "[red]Error:[/red] Could not detect shell. Please specify with --shell" - ) + print_error("Could not detect shell. Please specify with --shell") sys.exit(1) config_path = find_config_file(shell) if config_path is None: - console.print(f"[red]Error:[/red] No {shell} config file found") + print_error(f"No {shell} config file found") sys.exit(1) success, message = uninstall_completion(config_path) if success: - console.print(f"[green]✓[/green] {message}") + print_success(message) else: - console.print(f"[red]Error:[/red] {message}") + print_error(message) sys.exit(1) @@ -136,51 +135,44 @@ def completions_uninstall(shell: str | None) -> None: ) def completions_update(shell: str | None) -> None: """Re-generate completion block (fixes PATH shadowing issues).""" - from rich.console import Console - + from ai_rules.cli.display import print_error, print_success from ai_rules.completions import detect_shell, update_completion - console = Console() - if shell is None: shell = detect_shell() if shell is None: - console.print( - "[red]Error:[/red] Could not detect shell. Use --shell to specify." - ) + print_error("Could not detect shell. Use --shell to specify.") sys.exit(1) - console.print(f"[dim]Detected shell:[/dim] {shell}") + print_label("Detected shell", shell) success, message = update_completion(shell, dry_run=False) if success: - console.print(f"[green]✓[/green] {message}") + print_success(message) else: - console.print(f"[red]Error:[/red] {message}") + print_error(message) sys.exit(1) @completions.command(name="status") def completions_status() -> None: """Show shell completion installation status.""" - from rich.console import Console from rich.table import Table + from ai_rules.cli.display import console, print_warning from ai_rules.completions import ( detect_shell, find_config_file, is_completion_installed, ) - console = Console() - detected_shell = detect_shell() console.print("[bold cyan]Shell Completions Status[/bold cyan]\n") if detected_shell: console.print(f"Detected shell: [cyan]{detected_shell}[/cyan]\n") else: - console.print("[yellow]No supported shell detected[/yellow]\n") + print_warning("No supported shell detected") table = Table(show_header=True) table.add_column("Shell") @@ -191,17 +183,18 @@ def completions_status() -> None: config_path = find_config_file(shell) if config_path is None: - status = "[dim]-[/dim]" - config_str = "[dim]No config file found[/dim]" + status = ICON_NONE + config_str = dim("No config file found") elif is_completion_installed(config_path): - status = "[green]✓[/green]" + status = ICON_SUCCESS config_str = str(config_path) else: - status = "[yellow]○[/yellow]" - config_str = f"{config_path} [dim](not installed)[/dim]" + status = ICON_ABSENT + config_str = f"{config_path} {dim('(not installed)')}" shell_name = f"[bold]{shell}[/bold]" if shell == detected_shell else shell table.add_row(shell_name, status, config_str) console.print(table) - console.print("\n[dim]To install: ai-agent-rules completions install[/dim]") + console.print() + print_hint("To install: ai-agent-rules completions install") diff --git a/src/ai_rules/cli/groups/config.py b/src/ai_rules/cli/groups/config.py index eb52f0d..6ba7f53 100644 --- a/src/ai_rules/cli/groups/config.py +++ b/src/ai_rules/cli/groups/config.py @@ -8,6 +8,8 @@ import ai_rules.cli as cli_facade +from ai_rules.cli.display import dim + @click.group() def config() -> None: @@ -22,12 +24,16 @@ def config() -> None: @click.option("--agent", help="Show config for specific agent only") def config_show(merged: bool, agent: str | None) -> None: """Show current configuration.""" - from rich.console import Console - + from ai_rules.cli.display import ( + console, + print_add, + print_error, + print_unchanged, + print_update, + print_warning, + ) from ai_rules.config import Config - console = Console() - config_dir = cli_facade.get_config_dir() cfg = Config.load() user_config_path = cli_facade.get_user_config_path() @@ -47,16 +53,17 @@ def config_show(merged: bool, agent: str | None) -> None: has_cache = cache_path and cache_path.exists() if not has_overrides and not has_cache: - console.print( - f"[dim]{agent_name}: No overrides (using base settings)[/dim]\n" - ) + from ai_rules.cli.display import print_dim + + print_dim(f"{agent_name}: No overrides (using base settings)") + console.print() continue console.print(f"[bold]{agent_name}:[/bold]") agent_format = AGENT_FORMATS.get(agent_name) if not agent_format: - console.print(f" [red]✗[/red] Unknown agent: {agent_name}") + print_error(f"Unknown agent: {agent_name}", indent=2) console.print() continue @@ -68,8 +75,8 @@ def config_show(merged: bool, agent: str | None) -> None: try: base_settings = load_config_file(base_path, agent_format) except CONFIG_PARSE_ERRORS as e: - console.print( - f" [red]✗[/red] Failed to load base settings from {base_path}: {e}" + print_error( + f"Failed to load base settings from {base_path}: {e}", indent=2 ) console.print() continue @@ -82,26 +89,22 @@ def config_show(merged: bool, agent: str | None) -> None: if key in base_settings: old_val = base_settings[key] new_val = merged_settings[key] - console.print( - f" [yellow]↻[/yellow] {key}: {old_val} → {new_val}" - ) + print_update(f"{key}: {old_val} → {new_val}", indent=2) overridden_keys.append(key) else: - console.print( - f" [green]+[/green] {key}: {merged_settings[key]}" - ) + print_add(f"{key}: {merged_settings[key]}", indent=2) overridden_keys.append(key) for key, value in merged_settings.items(): if key not in overridden_keys: - console.print(f" [dim]•[/dim] {key}: {value}") + print_unchanged(f"{key}: {value}", indent=2) else: - console.print( - f" [yellow]⚠[/yellow] No base settings found at {base_path}" - ) + print_warning(f"No base settings found at {base_path}", indent=2) if has_overrides: - console.print( - f" [dim]Overrides: {cfg.settings_overrides[agent_name]}[/dim]" + from ai_rules.cli.display import print_dim + + print_dim( + f"Overrides: {cfg.settings_overrides[agent_name]}", indent=2 ) console.print() @@ -114,19 +117,20 @@ def config_show(merged: bool, agent: str | None) -> None: console.print(f"[bold]User Config:[/bold] {user_config_path}") console.print(content) else: - console.print(f"[dim]No user config at {user_config_path}[/dim]\n") + from ai_rules.cli.display import print_dim + + print_dim(f"No user config at {user_config_path}") + console.print() @config.command("edit") def config_edit() -> None: """Edit user configuration file in $EDITOR.""" - from rich.console import Console - - console = Console() - import os import subprocess + from ai_rules.cli.display import print_success + user_config_path = cli_facade.get_user_config_path() editor = os.environ.get("EDITOR", "vi") @@ -137,9 +141,11 @@ def config_edit() -> None: try: subprocess.run([editor, str(user_config_path)], check=True) - console.print(f"[green]✓[/green] Config edited: {user_config_path}") + print_success(f"Config edited: {user_config_path}") except subprocess.CalledProcessError: - console.print("[red]Error opening editor[/red]") + from ai_rules.cli.display import print_error + + print_error("Error opening editor") sys.exit(1) @@ -183,9 +189,7 @@ def _collect_exclusion_patterns() -> list[str]: Returns: List of exclusion patterns """ - from rich.console import Console - - console = Console() + from ai_rules.cli.display import console, print_success console.print("\n[bold]Step 1: Exclusion Patterns[/bold]") console.print("Do you want to exclude any files from being managed?\n") @@ -201,23 +205,22 @@ def _collect_exclusion_patterns() -> list[str]: ) if should_exclude: selected_exclusions.append(pattern) - console.print(f" [green]✓[/green] Will exclude: {pattern}") + print_success(f"Will exclude: {pattern}", indent=4) - console.print( - "\n[dim]Enter custom exclusion patterns (glob patterns supported)[/dim]" - ) - console.print("[dim]One per line, empty line to finish:[/dim]") + from ai_rules.cli.display import print_dim + + console.print() + print_dim("Enter custom exclusion patterns (glob patterns supported)") + print_dim("One per line, empty line to finish:") while True: pattern = console.input("> ").strip() if not pattern: break selected_exclusions.append(pattern) - console.print(f" [green]✓[/green] Added: {pattern}") + print_success(f"Added: {pattern}", indent=2) if selected_exclusions: - console.print( - f"\n[green]✓[/green] Configured {len(selected_exclusions)} exclusion pattern(s)" - ) + print_success(f"Configured {len(selected_exclusions)} exclusion pattern(s)") return selected_exclusions @@ -228,12 +231,10 @@ def _collect_settings_overrides() -> dict[str, dict[str, Any]]: Returns: Dictionary of agent settings overrides """ - from rich.console import Console - - console = Console() - import json + from ai_rules.cli.display import console, print_success, print_warning + console.print("\n[bold]Step 2: Settings Overrides[/bold]") response = console.input( "Do you want to override any settings for this machine? [y/N]: " @@ -261,12 +262,15 @@ def _collect_settings_overrides() -> dict[str, dict[str, Any]]: agent = agent_map.get(agent_choice) if not agent: - console.print("[yellow]Invalid choice[/yellow]") + print_warning("Invalid choice") continue console.print(f"\n[bold]{agent.title()} settings overrides:[/bold]") - console.print("[dim]Enter key=value pairs (empty to finish):[/dim]") - console.print("[dim]Example: model=claude-sonnet-4-5-20250929[/dim]\n") + from ai_rules.cli.display import print_dim + + print_dim("Enter key=value pairs (empty to finish):") + print_dim("Example: model=claude-sonnet-4-5-20250929") + console.print() agent_overrides = {} while True: @@ -275,7 +279,7 @@ def _collect_settings_overrides() -> dict[str, dict[str, Any]]: break if "=" not in override: - console.print("[yellow]Invalid format. Use key=value[/yellow]") + print_warning("Invalid format. Use key=value") continue key, value = override.split("=", 1) @@ -288,15 +292,15 @@ def _collect_settings_overrides() -> dict[str, dict[str, Any]]: parsed_value = value agent_overrides[key] = parsed_value - console.print(f" [green]✓[/green] Added: {key} = {parsed_value}") + print_success(f"Added: {key} = {parsed_value}", indent=2) if agent_overrides: settings_overrides[agent] = agent_overrides if settings_overrides: total_overrides = sum(len(v) for v in settings_overrides.values()) - console.print( - f"\n[green]✓[/green] Configured {total_overrides} override(s) for {len(settings_overrides)} agent(s)" + print_success( + f"Configured {total_overrides} override(s) for {len(settings_overrides)} agent(s)" ) return settings_overrides @@ -308,12 +312,10 @@ def _display_configuration_summary(config_data: dict[str, Any]) -> None: Args: config_data: Configuration dictionary to display """ - from rich.console import Console - - console = Console() + from ai_rules.cli.display import console console.print("\n[bold cyan]Configuration Summary:[/bold cyan]") - console.print("=" * 50) + console.rule() if "exclude_symlinks" in config_data: console.print( @@ -329,31 +331,29 @@ def _display_configuration_summary(config_data: dict[str, Any]) -> None: for key, value in overrides.items(): console.print(f" • {key}: {value}") - console.print("\n" + "=" * 50) + console.rule() @config.command("init") def config_init() -> None: """Interactive configuration wizard.""" - from rich.console import Console - from rich.prompt import Confirm - + from ai_rules.cli.display import console, print_success, print_warning from ai_rules.config import Config - console = Console() - user_config_path = cli_facade.get_user_config_path() console.print( "[bold cyan]Welcome to ai-agent-rules configuration wizard![/bold cyan]\n" ) console.print("This will help you set up your .ai-agent-rules-config.yaml file.") - console.print(f"Config will be created at: [dim]{user_config_path}[/dim]\n") + console.print(f"Config will be created at: {dim(str(user_config_path))}\n") if user_config_path.exists(): - console.print("[yellow]⚠[/yellow] Config file already exists!") - if not Confirm.ask("Overwrite existing config?", default=False): - console.print("[dim]Cancelled[/dim]") + print_warning("Config file already exists!") + if not click.confirm("Overwrite existing config?", default=False): + from ai_rules.cli.display import print_dim + + print_dim("Cancelled") return config_data: dict[str, Any] = {"version": 1} @@ -368,10 +368,10 @@ def config_init() -> None: _display_configuration_summary(config_data) - if Confirm.ask("\nSave configuration?", default=True): + if click.confirm("\nSave configuration?", default=True): Config.save_user_config(config_data) - console.print(f"\n[green]✓[/green] Configuration saved to {user_config_path}") + print_success(f"Configuration saved to {user_config_path}") console.print("\n[bold]Next steps:[/bold]") console.print( " • Run [cyan]ai-agent-rules install[/cyan] to apply these settings" @@ -383,4 +383,6 @@ def config_init() -> None: " • Run [cyan]ai-agent-rules config show --merged[/cyan] to see merged settings" ) else: - console.print("[dim]Configuration not saved[/dim]") + from ai_rules.cli.display import print_dim + + print_dim("Configuration not saved") diff --git a/src/ai_rules/cli/groups/exclude.py b/src/ai_rules/cli/groups/exclude.py index b737d5c..a60279e 100644 --- a/src/ai_rules/cli/groups/exclude.py +++ b/src/ai_rules/cli/groups/exclude.py @@ -6,6 +6,8 @@ import ai_rules.cli as cli_facade +from ai_rules.cli.display import dim, print_dim + @click.group() def exclude() -> None: @@ -20,74 +22,65 @@ def exclude_add(pattern: str) -> None: PATTERN can be an exact path or glob pattern (e.g., ~/.claude/*.json) """ - from rich.console import Console - + from ai_rules.cli.display import print_success, print_warning from ai_rules.config import Config - console = Console() - data = Config.load_user_config() if "exclude_symlinks" not in data: data["exclude_symlinks"] = [] if pattern in data["exclude_symlinks"]: - console.print(f"[yellow]Pattern already excluded:[/yellow] {pattern}") + print_warning(f"Pattern already excluded: {pattern}") return data["exclude_symlinks"].append(pattern) Config.save_user_config(data) user_config_path = cli_facade.get_user_config_path() - console.print(f"[green]✓[/green] Added exclusion pattern: {pattern}") - console.print(f"[dim]Config updated: {user_config_path}[/dim]") + print_success(f"Added exclusion pattern: {pattern}") + print_dim(f"Config updated: {user_config_path}") @exclude.command("remove") @click.argument("pattern") def exclude_remove(pattern: str) -> None: """Remove an exclusion pattern from user config.""" - from rich.console import Console - + from ai_rules.cli.display import print_error, print_success, print_warning from ai_rules.config import Config - console = Console() - user_config_path = cli_facade.get_user_config_path() if not user_config_path.exists(): - console.print("[red]No user config found[/red]") + print_error("No user config found") sys.exit(1) data = Config.load_user_config() if "exclude_symlinks" not in data or pattern not in data["exclude_symlinks"]: - console.print(f"[yellow]Pattern not found:[/yellow] {pattern}") + print_warning(f"Pattern not found: {pattern}") sys.exit(1) data["exclude_symlinks"].remove(pattern) Config.save_user_config(data) - console.print(f"[green]✓[/green] Removed exclusion pattern: {pattern}") - console.print(f"[dim]Config updated: {user_config_path}[/dim]") + print_success(f"Removed exclusion pattern: {pattern}") + print_dim(f"Config updated: {user_config_path}") @exclude.command("list") def exclude_list() -> None: """List all exclusion patterns.""" - from rich.console import Console - + from ai_rules.cli.display import console from ai_rules.config import Config - console = Console() - config = Config.load() if not config.exclude_symlinks: - console.print("[dim]No exclusion patterns configured[/dim]") + print_dim("No exclusion patterns configured") return console.print("[bold]Exclusion Patterns:[/bold]\n") for pattern in sorted(config.exclude_symlinks): - console.print(f" • {pattern} [dim](user)[/dim]") + console.print(f" • {pattern} {dim('(user)')}") diff --git a/src/ai_rules/cli/groups/override.py b/src/ai_rules/cli/groups/override.py index ccaff14..c6e980d 100644 --- a/src/ai_rules/cli/groups/override.py +++ b/src/ai_rules/cli/groups/override.py @@ -8,6 +8,8 @@ import ai_rules.cli as cli_facade +from ai_rules.cli.display import print_dim + @click.group() def override() -> None: @@ -69,7 +71,9 @@ def _override_set_with_array_index( config_dir = cli_facade.get_config_dir() agent_format = AGENT_FORMATS.get(agent) if not agent_format: - console.print(f"[red]Error:[/red] Unknown agent format for '{agent}'") + from ai_rules.cli.display import print_error + + print_error(f"Unknown agent format for '{agent}'") sys.exit(1) config_file = FORMAT_CONFIG_FILES.get(agent_format, "settings.json") @@ -142,25 +146,26 @@ def override_set(key: str, value: str) -> None: - Validates full path against base settings structure - Provides helpful suggestions when paths are invalid """ - from rich.console import Console - + from ai_rules.cli.display import ( + console, + print_error, + print_hint, + print_success, + print_warning, + ) from ai_rules.config import ( Config, parse_setting_path, validate_override_path, ) - console = Console() - user_config_path = cli_facade.get_user_config_path() config_dir = cli_facade.get_config_dir() parts = key.split(".", 1) if len(parts) != 2: - console.print("[red]Error:[/red] Key must be in format 'agent.setting'") - console.print( - "[dim]Example: claude.model or claude.hooks.SubagentStop[0].command[/dim]" - ) + print_error("Key must be in format 'agent.setting'") + print_dim("Example: claude.model or claude.hooks.SubagentStop[0].command") sys.exit(1) agent, setting = parts @@ -170,15 +175,13 @@ def override_set(key: str, value: str) -> None: ) if not is_valid: - console.print(f"[red]Error:[/red] {error_msg}") + print_error(error_msg) if suggestions: - console.print( - f"[dim]Available options: {', '.join(suggestions[:10])}[/dim]" - ) + print_dim(f"Available options: {', '.join(suggestions[:10])}") sys.exit(1) if warning_msg: - console.print(f"[yellow]Warning:[/yellow] {warning_msg}") + print_warning(warning_msg) import json @@ -198,7 +201,7 @@ def override_set(key: str, value: str) -> None: try: path_components = parse_setting_path(setting) except ValueError as e: - console.print(f"[red]Error:[/red] {e}") + print_error(str(e)) sys.exit(1) has_array_index = any(isinstance(c, int) for c in path_components) @@ -212,11 +215,9 @@ def override_set(key: str, value: str) -> None: Config.save_user_config(data) - console.print(f"[green]✓[/green] Set override: {agent}.{setting} = {parsed_value}") - console.print(f"[dim]Config updated: {user_config_path}[/dim]") - console.print( - "\n[yellow]💡 Run 'ai-agent-rules install --rebuild-cache' to apply changes[/yellow]" - ) + print_success(f"Set override: {agent}.{setting} = {parsed_value}") + print_dim(f"Config updated: {user_config_path}") + print_hint("Run 'ai-agent-rules install --rebuild-cache' to apply changes") @override.command("unset") @@ -227,21 +228,23 @@ def override_unset(key: str) -> None: KEY should be in format 'agent.setting' (e.g., 'claude.model') Supports nested keys like 'agent.nested.key' """ - from rich.console import Console - + from ai_rules.cli.display import ( + print_error, + print_hint, + print_success, + print_warning, + ) from ai_rules.config import Config - console = Console() - user_config_path = cli_facade.get_user_config_path() if not user_config_path.exists(): - console.print("[red]No user config found[/red]") + print_error("No user config found") sys.exit(1) parts = key.split(".", 1) if len(parts) != 2: - console.print("[red]Error:[/red] Key must be in format 'agent.setting'") + print_error("Key must be in format 'agent.setting'") sys.exit(1) agent, setting = parts @@ -249,7 +252,7 @@ def override_unset(key: str) -> None: data = Config.load_user_config() if "settings_overrides" not in data or agent not in data["settings_overrides"]: - console.print(f"[yellow]Override not found:[/yellow] {key}") + print_warning(f"Override not found: {key}") sys.exit(1) setting_parts = setting.split(".") @@ -257,13 +260,13 @@ def override_unset(key: str) -> None: for part in setting_parts[:-1]: if not isinstance(current, dict) or part not in current: - console.print(f"[yellow]Override not found:[/yellow] {key}") + print_warning(f"Override not found: {key}") sys.exit(1) current = current[part] final_key = setting_parts[-1] if not isinstance(current, dict) or final_key not in current: - console.print(f"[yellow]Override not found:[/yellow] {key}") + print_warning(f"Override not found: {key}") sys.exit(1) del current[final_key] @@ -286,27 +289,22 @@ def override_unset(key: str) -> None: Config.save_user_config(data) - console.print(f"[green]✓[/green] Removed override: {key}") - console.print(f"[dim]Config updated: {user_config_path}[/dim]") - console.print( - "\n[yellow]💡 Run 'ai-agent-rules install --rebuild-cache' to apply changes[/yellow]" - ) + print_success(f"Removed override: {key}") + print_dim(f"Config updated: {user_config_path}") + print_hint("Run 'ai-agent-rules install --rebuild-cache' to apply changes") @override.command("list") def override_list() -> None: """List all settings overrides.""" - from rich.console import Console - + from ai_rules.cli.display import console from ai_rules.config import Config - console = Console() - user_data = Config.load_user_config() user_overrides = user_data.get("settings_overrides", {}) if not user_overrides: - console.print("[dim]No settings overrides configured[/dim]") + print_dim("No settings overrides configured") return console.print("[bold]Settings Overrides:[/bold]\n") diff --git a/src/ai_rules/cli/groups/profile.py b/src/ai_rules/cli/groups/profile.py index ea4c6e8..b5a8cdd 100644 --- a/src/ai_rules/cli/groups/profile.py +++ b/src/ai_rules/cli/groups/profile.py @@ -8,6 +8,7 @@ import ai_rules.cli as cli_facade +from ai_rules.cli.display import dim, print_dim, print_label from ai_rules.profiles import Profile @@ -20,13 +21,11 @@ def profile() -> None: @profile.command("list") def profile_list() -> None: """List available profiles.""" - from rich.console import Console from rich.table import Table + from ai_rules.cli.display import console from ai_rules.profiles import ProfileLoader - console = Console() - loader = ProfileLoader() profiles = loader.list_profiles() @@ -42,7 +41,7 @@ def profile_list() -> None: extends = info.get("extends") or "-" table.add_row(name, desc, extends) except Exception: - table.add_row(name, "[dim]Error loading[/dim]", "-") + table.add_row(name, dim("Error loading"), "-") console.print(table) @@ -54,25 +53,22 @@ def profile_list() -> None: ) def profile_show(name: str, resolved: bool) -> None: """Show profile details.""" - from rich.console import Console - + from ai_rules.cli.display import console, print_error from ai_rules.profiles import ( CircularInheritanceError, ProfileLoader, ProfileNotFoundError, ) - console = Console() - loader = ProfileLoader() try: if resolved: profile = loader.load_profile(name) console.print(f"[bold]Profile: {profile.name}[/bold] (resolved)") - console.print(f"[dim]Description:[/dim] {profile.description}") + print_label("Description", profile.description) if profile.extends: - console.print(f"[dim]Extends:[/dim] {profile.extends}") + print_label("Extends", profile.extends) if profile.settings_overrides: console.print("\n[bold]Settings Overrides:[/bold]") @@ -122,27 +118,24 @@ def profile_show(name: str, resolved: bool) -> None: console.print(yaml.dump(info, default_flow_style=False, sort_keys=False)) except ProfileNotFoundError as e: - console.print(f"[red]Error:[/red] {e}") + print_error(str(e)) sys.exit(1) except CircularInheritanceError as e: - console.print(f"[red]Error:[/red] {e}") + print_error(str(e)) sys.exit(1) @profile.command("current") def profile_current() -> None: """Show currently active profile.""" - from rich.console import Console - + from ai_rules.cli.display import console from ai_rules.state import get_active_profile - console = Console() - active = get_active_profile() if active: console.print(f"Active profile: [cyan]{active}[/cyan]") else: - console.print("[dim]No profile set (using default)[/dim]") + print_dim("No profile set (using default)") def _detect_profile_override_conflicts( @@ -181,18 +174,13 @@ def _handle_profile_conflicts( profile_name: Name of profile being installed user_config: User config dict to potentially modify """ - from rich.console import Console - + from ai_rules.cli.display import console, print_success, print_warning from ai_rules.config import Config - console = Console() - if not conflicts: return - console.print( - f"\n[yellow]⚠[/yellow] User overrides conflict with profile '{profile_name}':" - ) + print_warning(f"User overrides conflict with profile '{profile_name}':") for agent, key, value in conflicts: console.print(f" • {agent}.{key}: {value}") @@ -205,7 +193,7 @@ def _handle_profile_conflicts( del user_settings[agent] Config.save_user_config(user_config) - console.print("[green]✓[/green] Cleared conflicting overrides\n") + print_success("Cleared conflicting overrides") else: console.print() @@ -215,19 +203,16 @@ def _handle_profile_conflicts( @click.pass_context def profile_switch(ctx: click.Context, name: str) -> None: """Switch to a different profile.""" - from rich.console import Console - from ai_rules.cli.commands.install import install + from ai_rules.cli.display import console, print_error from ai_rules.config import Config from ai_rules.profiles import ProfileLoader, ProfileNotFoundError - console = Console() - loader = ProfileLoader() try: profile_obj = loader.load_profile(name) except ProfileNotFoundError as e: - console.print(f"[red]Error:[/red] {e}") + print_error(str(e)) sys.exit(1) user_config = Config.load_user_config() diff --git a/src/ai_rules/cli/groups/skill.py b/src/ai_rules/cli/groups/skill.py index 9bd8536..e7cb6b9 100644 --- a/src/ai_rules/cli/groups/skill.py +++ b/src/ai_rules/cli/groups/skill.py @@ -39,19 +39,18 @@ def complete_skills( ) def skill_list(download_url: bool) -> None: """List all bundled skills.""" - from rich.console import Console from rich.table import Table + from ai_rules.cli.display import console from ai_rules.skills import SkillManager - console = Console() - if download_url: url = SkillManager.get_download_url() if url is None: - console.print( - "[red]Error:[/red] Could not determine GitHub URL. " - "Package metadata may be unavailable." + from ai_rules.cli.display import print_error + + print_error( + "Could not determine GitHub URL. Package metadata may be unavailable." ) sys.exit(1) click.echo(url) @@ -91,12 +90,11 @@ def skill_show(name: str, url: bool, download_url: bool, raw: bool) -> None: ai-agent-rules skill show research --download-url ai-agent-rules skill show code-reviewer --raw """ - from rich.console import Console from rich.markdown import Markdown + from ai_rules.cli.display import console, print_error from ai_rules.skills import SkillManager - console = Console() config_dir = cli_facade.get_config_dir() manager = SkillManager(config_dir=config_dir, agent_id="") @@ -109,9 +107,7 @@ def skill_show(name: str, url: bool, download_url: bool, raw: bool) -> None: managed = manager._get_managed_skills() if name not in managed: available = ", ".join(sorted(managed.keys())) - console.print( - f"[red]Error:[/red] Unknown skill '{name}'. Available: {available}" - ) + print_error(f"Unknown skill '{name}'. Available: {available}") sys.exit(1) if download_url: @@ -120,9 +116,8 @@ def skill_show(name: str, url: bool, download_url: bool, raw: bool) -> None: result = SkillManager.get_skill_url(name) if result is None: - console.print( - "[red]Error:[/red] Could not determine GitHub URL. " - "Package metadata may be unavailable." + print_error( + "Could not determine GitHub URL. Package metadata may be unavailable." ) sys.exit(1) click.echo(result) @@ -132,9 +127,7 @@ def skill_show(name: str, url: bool, download_url: bool, raw: bool) -> None: if content is None: managed = manager._get_managed_skills() available = ", ".join(sorted(managed.keys())) - console.print( - f"[red]Error:[/red] Unknown skill '{name}'. Available: {available}" - ) + print_error(f"Unknown skill '{name}'. Available: {available}") sys.exit(1) if raw: diff --git a/src/ai_rules/cli/groups/tool.py b/src/ai_rules/cli/groups/tool.py index c783744..55ba772 100644 --- a/src/ai_rules/cli/groups/tool.py +++ b/src/ai_rules/cli/groups/tool.py @@ -7,6 +7,7 @@ import click from ai_rules.bootstrap import ToolSource +from ai_rules.cli.display import dim, print_hint def _resolve_configured_source(configured: str | None) -> ToolSource | None: @@ -23,7 +24,7 @@ def _resolve_configured_source(configured: str | None) -> ToolSource | None: def _format_source_display(source: ToolSource | None, configured: str | None) -> str: """Format combined source+config info for table display.""" if not source: - return "[dim]unknown[/dim]" + return dim("unknown") source_str = source.name.lower() configured_source = _resolve_configured_source(configured) @@ -33,8 +34,8 @@ def _format_source_display(source: ToolSource | None, configured: str | None) -> if configured is not None: if configured and configured.startswith("local:"): local_cfg_path = configured[len("local:") :] - return f"{source_str} [dim]({local_cfg_path})[/dim]" - return f"{source_str} [dim](config)[/dim]" + return f"{source_str} {dim(f'({local_cfg_path})')}" + return f"{source_str} {dim('(config)')}" return source_str @@ -55,7 +56,6 @@ def tool() -> None: @tool.command("list") def tool_list() -> None: """List managed tools with version and update info.""" - from rich.console import Console from rich.table import Table from ai_rules.bootstrap import ( @@ -63,10 +63,9 @@ def tool_list() -> None: get_tool_source, get_updatable_tools, ) + from ai_rules.cli.display import console from ai_rules.config import Config - console = Console() - try: config = Config.load() except Exception: @@ -82,7 +81,7 @@ def tool_list() -> None: for spec in get_updatable_tools(): if not spec.is_installed(): - table.add_row(spec.display_name, "-", "-", "[dim](not installed)[/dim]") + table.add_row(spec.display_name, "-", "-", dim("(not installed)")) continue source = get_tool_source(spec.package_name) @@ -90,7 +89,7 @@ def tool_list() -> None: source_display = _format_source_display(source, configured) version = spec.get_version() - version_display = version if version else "[dim]unknown[/dim]" + version_display = version if version else dim("unknown") update_display = "-" try: @@ -99,7 +98,7 @@ def tool_list() -> None: update_display = f"[cyan]{update_info.latest_version} available[/cyan]" has_updates = True except Exception: - update_display = "[dim](check failed)[/dim]" + update_display = dim("(check failed)") table.add_row( spec.display_name, source_display, version_display, update_display @@ -108,7 +107,9 @@ def tool_list() -> None: console.print(table) if has_updates: - console.print("\n[dim]Run 'ai-agent-rules upgrade' to install updates.[/dim]") + from ai_rules.cli.display import print_hint + + print_hint("Run 'ai-agent-rules upgrade' to install updates.") @tool.command("show") @@ -122,41 +123,34 @@ def tool_show(tool_id: str) -> None: ai-agent-rules tool show statusline ai-agent-rules tool show ai-agent-rules """ - from rich.console import Console - from ai_rules.bootstrap import ( check_tool_updates, get_tool_source, ) from ai_rules.bootstrap.updater import _TOOL_ID_ALIASES, get_tool_by_id + from ai_rules.cli.display import console, print_error from ai_rules.config import Config - console = Console() - canonical_id = _TOOL_ID_ALIASES.get(tool_id, tool_id) spec = get_tool_by_id(canonical_id) if spec is None: from ai_rules.bootstrap import get_updatable_tools valid_ids = ", ".join(sorted(t.tool_id for t in get_updatable_tools())) - console.print( - f"[red]Error:[/red] Unknown tool '{tool_id}'. Valid tools: {valid_ids}" - ) + print_error(f"Unknown tool '{tool_id}'. Valid tools: {valid_ids}") sys.exit(1) console.print(f"[bold]{spec.display_name}[/bold]\n") if not spec.is_installed(): - console.print(" Status: [dim]not installed[/dim]") + console.print(f" Status: {dim('not installed')}") return version = spec.get_version() - console.print(f" Version: {version or '[dim]unknown[/dim]'}") + console.print(f" Version: {version or dim('unknown')}") source = get_tool_source(spec.package_name) - console.print( - f" Source: {source.name.lower() if source else '[dim]unknown[/dim]'}" - ) + console.print(f" Source: {source.name.lower() if source else dim('unknown')}") try: config = Config.load() @@ -167,7 +161,7 @@ def tool_show(tool_id: str) -> None: config_display = _format_config_display(source, configured) console.print(f" Config: {config_display}") else: - console.print(" Config: [dim](not set — default: pypi)[/dim]") + console.print(f" Config: {dim('(not set — default: pypi)')}") if spec.github_repo: console.print(f" Repo: https://github.com/{spec.github_repo}") @@ -181,7 +175,7 @@ def tool_show(tool_id: str) -> None: elif update_info: console.print(" Update: [green]up to date[/green]") except Exception: - console.print(" Update: [dim](check failed)[/dim]") + console.print(f" Update: {dim('(check failed)')}") @tool.group() @@ -197,14 +191,12 @@ def source_list() -> None: Examples: ai-agent-rules tool source list """ - from rich.console import Console from rich.table import Table from ai_rules.bootstrap.updater import get_updatable_tools + from ai_rules.cli.display import console from ai_rules.config import Config - console = Console() - tools = get_updatable_tools() table = Table(title="Tool Install Source Preferences", show_header=True) table.add_column("Tool", style="cyan") @@ -219,12 +211,12 @@ def source_list() -> None: effective_pref = None table.add_row( spec.tool_id, - user_pref or "[dim](not set)[/dim]", - effective_pref or "[dim](default: pypi)[/dim]", + user_pref or dim("(not set)"), + effective_pref or dim("(default: pypi)"), ) console.print(table) - console.print( - "\n[dim]Run 'ai-agent-rules setup' after changing to switch the installed source.[/dim]" + print_hint( + "Run 'ai-agent-rules setup' after changing to switch the installed source." ) @@ -235,10 +227,10 @@ def _resolve_tool_id(tool_id: str) -> str: canonical_id = _TOOL_ID_ALIASES.get(tool_id, tool_id) valid_ids = {t.tool_id for t in get_updatable_tools()} if canonical_id not in valid_ids: - from rich.console import Console + from ai_rules.cli.display import print_error - Console().print( - f"[red]Error:[/red] Unknown tool '{tool_id}'. Valid tools: {', '.join(sorted(valid_ids))}" + print_error( + f"Unknown tool '{tool_id}'. Valid tools: {', '.join(sorted(valid_ids))}" ) sys.exit(1) return canonical_id @@ -254,11 +246,9 @@ def source_get(tool_id: str) -> None: Examples: ai-agent-rules tool source get statusline """ - from rich.console import Console - + from ai_rules.cli.display import console from ai_rules.config import Config - console = Console() canonical_id = _resolve_tool_id(tool_id) user_pref = Config.get_tool_install_source_from_user_config(canonical_id) @@ -272,14 +262,14 @@ def source_get(tool_id: str) -> None: f"[cyan]{canonical_id}[/cyan] user config: [bold]{user_pref}[/bold]" ) else: - console.print(f"[cyan]{canonical_id}[/cyan] user config: [dim](not set)[/dim]") + console.print(f"[cyan]{canonical_id}[/cyan] user config: {dim('(not set)')}") if effective_pref: console.print( f"[cyan]{canonical_id}[/cyan] effective (profile/config): [bold]{effective_pref}[/bold]" ) else: console.print( - f"[cyan]{canonical_id}[/cyan] effective: [dim](default: pypi)[/dim]" + f"[cyan]{canonical_id}[/cyan] effective: {dim('(default: pypi)')}" ) @@ -298,41 +288,37 @@ def source_set(tool_id: str, source_value: str) -> None: ai-agent-rules tool source set ai-agent-rules "local:~/Development/Personal/ai-rules" ai-agent-rules tool source set statusline reset """ - from rich.console import Console - + from ai_rules.cli.display import print_error, print_success from ai_rules.config import Config - console = Console() canonical_id = _resolve_tool_id(tool_id) if source_value == "reset": Config.set_tool_install_source(canonical_id, None) - console.print( - f"[green]✓[/green] Cleared install source preference for [cyan]{canonical_id}[/cyan]" + print_success( + f"Cleared install source preference for [cyan]{canonical_id}[/cyan]" ) elif source_value in ("pypi", "github"): Config.set_tool_install_source(canonical_id, source_value) - console.print( - f"[green]✓[/green] Set [cyan]{canonical_id}[/cyan] install source to [bold]{source_value}[/bold]" + print_success( + f"Set [cyan]{canonical_id}[/cyan] install source to [bold]{source_value}[/bold]" ) - console.print( - "[dim]Run 'ai-agent-rules setup' to switch the installed source if needed.[/dim]" + print_hint( + "Run 'ai-agent-rules setup' to switch the installed source if needed." ) elif source_value.startswith("local:"): local_path = source_value[len("local:") :] resolved = Path(local_path).expanduser().resolve() if not resolved.exists(): - console.print(f"[red]Error:[/red] Path does not exist: {resolved}") + print_error(f"Path does not exist: {resolved}") sys.exit(1) Config.set_tool_install_source(canonical_id, f"local:{resolved}") - console.print( - f"[green]✓[/green] Set [cyan]{canonical_id}[/cyan] install source to [bold]local: {resolved}[/bold]" - ) - console.print( - "[dim]Run 'ai-agent-rules install' to install from local path.[/dim]" + print_success( + f"Set [cyan]{canonical_id}[/cyan] install source to [bold]local: {resolved}[/bold]" ) + print_hint("Run 'ai-agent-rules install' to install from local path.") else: - console.print( - f"[red]Error:[/red] Invalid source value '{source_value}'. Use: pypi, github, local:, or reset" + print_error( + f"Invalid source value '{source_value}'. Use: pypi, github, local:, or reset" ) sys.exit(1) diff --git a/src/ai_rules/cli/helpers.py b/src/ai_rules/cli/helpers.py index 4c40a74..e62bad6 100644 --- a/src/ai_rules/cli/helpers.py +++ b/src/ai_rules/cli/helpers.py @@ -81,9 +81,7 @@ def select_targets( all_targets: list[ConfigTarget], filter_string: str | None ) -> list[ConfigTarget]: """Select targets based on a comma-separated target filter.""" - from rich.console import Console - - console = Console() + from ai_rules.cli.display import print_error if not filter_string: return all_targets @@ -96,10 +94,10 @@ def select_targets( if not selected: invalid_ids = requested_ids - {target.target_id for target in all_targets} available_ids = [target.target_id for target in all_targets] - console.print( - f"[red]Error:[/red] Invalid agent ID(s): {', '.join(sorted(invalid_ids))}\n" - f"[dim]Available agents: {', '.join(available_ids)}[/dim]" - ) + print_error(f"Invalid agent ID(s): {', '.join(sorted(invalid_ids))}") + from ai_rules.cli.display import print_dim + + print_dim(f"Available agents: {', '.join(available_ids)}") sys.exit(1) return selected @@ -120,10 +118,12 @@ def select_components( invalid_ids = [cid for cid in requested_ids if cid not in known_ids] if invalid_ids: - click.echo( - f"Error: Invalid component ID(s): {', '.join(sorted(invalid_ids))}\n" - f"Available components: {', '.join(sorted(known_ids))}" - ) + from ai_rules.cli.display import print_error + + print_error(f"Invalid component ID(s): {', '.join(sorted(invalid_ids))}") + from ai_rules.cli.display import print_dim + + print_dim(f"Available components: {', '.join(sorted(known_ids))}") sys.exit(1) return tuple(requested_ids) @@ -156,9 +156,8 @@ def format_summary( unchanged: int = 0, ) -> None: """Format and print operation summary.""" - from rich.console import Console + from ai_rules.cli.display import console, print_error - console = Console() console.print() has_actions = created or updated or skipped or errors @@ -199,4 +198,4 @@ def format_summary( console.print(f" ({excluded} excluded by config)") if errors > 0: - console.print(f" [red]{errors} error(s)[/red]") + print_error(f"{errors} error(s)", indent=2) diff --git a/src/ai_rules/cli/runner.py b/src/ai_rules/cli/runner.py index 4dc5ea7..ad4fa82 100644 --- a/src/ai_rules/cli/runner.py +++ b/src/ai_rules/cli/runner.py @@ -2,8 +2,6 @@ from __future__ import annotations -import contextvars - from collections.abc import Iterable from concurrent.futures import Future, ThreadPoolExecutor, as_completed from dataclasses import dataclass, field @@ -19,13 +17,12 @@ ComponentResult, LifecycleOperation, ) +from ai_rules.cli.display import _console_override, _real_console -_console_override: contextvars.ContextVar[RichConsole | None] = contextvars.ContextVar( - "_console_override", default=None +_BUFFERED_METHODS: frozenset[str] = frozenset( + {"apply", "uninstall", "status", "diff", "validate"} ) -_BUFFERED_METHODS: frozenset[str] = frozenset({"apply", "uninstall"}) - @dataclass class _RunAccumulator: @@ -120,19 +117,23 @@ def run_install( return acc.to_result() if not ctx.yes and not ctx.dry_run: - from rich.prompt import Confirm + import click from ai_rules.cli import ( _display_pending_changes, check_first_run, ) + from ai_rules.cli.display import print_warning if not check_first_run(list(ctx.selected_targets), ctx.yes): acc.aborted = True return acc.to_result() if _display_pending_changes(ctx): - if not Confirm.ask("Apply these changes?"): + try: + click.confirm("Apply these changes?", abort=True) + except click.exceptions.Abort: + print_warning("Cancelled") acc.aborted = True return acc.to_result() @@ -157,7 +158,6 @@ def run_install_parallel( ) -> ComponentRunResult: acc = _RunAccumulator() - # Phase 1: Infrastructure (sequential) — must complete before semantic for component in infrastructure: plan = component.plan(ctx) if plan.has_changes: @@ -169,33 +169,35 @@ def run_install_parallel( acc.aborted = True return acc.to_result() - # Phase 2: Prompt gate (main thread) if not ctx.yes and not ctx.dry_run: - from rich.prompt import Confirm + import click from ai_rules.cli import ( _display_pending_changes, check_first_run, ) + from ai_rules.cli.display import print_warning if not check_first_run(list(ctx.selected_targets), ctx.yes): acc.aborted = True return acc.to_result() if _display_pending_changes(ctx): - if not Confirm.ask("Apply these changes?"): + try: + click.confirm("Apply these changes?", abort=True) + except click.exceptions.Abort: + print_warning("Cancelled") acc.aborted = True return acc.to_result() - # Phase 3: Parallel plan all semantic components semantic_list = list(semantic) plans = run_components_parallel(semantic_list, "plan", ctx) - # Phase 4: Parallel apply — skip components whose plan failed - apply_list = [c for c in semantic_list if type(plans.get(c)) is not ComponentPlan] + apply_list = [ + c for c in semantic_list if getattr(plans.get(c), "has_changes", False) + ] results = run_components_parallel(apply_list, "apply", ctx, plans=plans) - # Fold results into accumulator for component in semantic_list: if _should_skip(component, ctx): continue @@ -205,25 +207,52 @@ def run_install_parallel( return acc.to_result() -def run_uninstall_parallel( +def run_parallel( components: Iterable[Component], + method: LifecycleOperation, ctx: CliContext, ) -> ComponentRunResult: - """Run uninstall across all components in parallel, returning a ComponentRunResult.""" acc = _RunAccumulator() comp_list = list(components) - results = run_components_parallel(comp_list, "uninstall", ctx) + results = run_components_parallel(comp_list, method, ctx) for comp in comp_list: if _should_skip(comp, ctx): continue - result = results.get(comp, ComponentResult()) - acc.fold(comp, result) + acc.fold(comp, results.get(comp, ComponentResult())) return acc.to_result() +def run_uninstall_parallel( + components: Iterable[Component], + ctx: CliContext, +) -> ComponentRunResult: + return run_parallel(components, "uninstall", ctx) + + +def run_status_parallel( + components: Iterable[Component], + ctx: CliContext, +) -> ComponentRunResult: + return run_parallel(components, "status", ctx) + + +def run_diff_parallel( + components: Iterable[Component], + ctx: CliContext, +) -> ComponentRunResult: + return run_parallel(components, "diff", ctx) + + +def run_validate_parallel( + components: Iterable[Component], + ctx: CliContext, +) -> ComponentRunResult: + return run_parallel(components, "validate", ctx) + + def get_console(ctx: CliContext) -> RichConsole: - """Return the active console — thread override if set, else ctx.console.""" - return _console_override.get() or ctx.console + """Return the active console for the current execution context.""" + return _console_override.get() or _real_console def run_components_parallel( @@ -235,10 +264,12 @@ def run_components_parallel( ) -> dict[Component, Any]: """Run a component method across all components in parallel. - For 'apply' and 'uninstall': each component's console output is captured - into a per-thread buffer and replayed atomically in completion order. + For methods in ``_BUFFERED_METHODS``, each component's console output is + captured into a per-thread buffer and replayed atomically in original + component order after the Progress bar closes. - For 'plan' and other methods: no buffering — a Status spinner shows progress. + For ``plan`` and other methods: no buffering — a Progress bar shows + per-component spinner rows. Errors are collected rather than aborting on first failure. After all futures settle, per-component errors are printed and partial results are returned. @@ -261,19 +292,29 @@ def run_components_parallel( buffers[comp] = buf buffered_consoles[comp] = RichConsole( file=buf, - force_terminal=ctx.console.is_terminal, - color_system=ctx.console.color_system, # type: ignore[arg-type] + force_terminal=_real_console.is_terminal, + color_system=_real_console.color_system, # type: ignore[arg-type] highlight=False, ) - def _make_task(comp: Component, override: RichConsole | None = None) -> Any: + def _make_task( + comp: Component, + override: RichConsole | None = None, + plan: ComponentPlan | None = None, + ) -> Any: def _run() -> Any: - if override is not None: - _console_override.set(override) - if method == "apply": - plan = (plans or {})[comp] - return comp.apply(ctx, plan) - return getattr(comp, method)(ctx) + token = None + try: + if override is not None: + token = _console_override.set(override) + else: + token = _console_override.set(None) + if plan is not None: + return comp.apply(ctx, plan) + return getattr(comp, method)(ctx) + finally: + if token is not None: + _console_override.reset(token) return _run @@ -285,10 +326,11 @@ def _run() -> Any: _make_task, buffered_consoles, buffers, + plans, futures, results, errors, - ctx.console, + get_console(ctx), ) else: _run_unbuffered( @@ -299,12 +341,17 @@ def _run() -> Any: futures, results, errors, - ctx.console, + get_console(ctx), ) if errors: + for _comp, exc in errors.items(): + if isinstance(exc, (KeyboardInterrupt, SystemExit)): + raise exc + from ai_rules.cli.display import print_error + for comp, exc in errors.items(): - ctx.console.print(f"[red]✗[/red] {comp.label}: {type(exc).__name__}: {exc}") + print_error(f"{comp.label}: {type(exc).__name__}: {exc}") results[comp] = ComponentResult(ok=False, counts={"errors": 1}) if len(errors) == len(comp_list): raise next(iter(errors.values())) @@ -312,31 +359,36 @@ def _run() -> Any: return results +def _make_progress(real_console: RichConsole) -> Any: + from rich.progress import Progress, SpinnerColumn, TextColumn + + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=real_console, + transient=True, + ) + + def _run_buffered( pool: ThreadPoolExecutor, components: list[Component], make_task: Any, buffered_consoles: dict[Component, RichConsole], buffers: dict[Component, StringIO], + plans: dict[Component, ComponentPlan] | None, futures: dict[Future[Any], Component], results: dict[Component, Any], errors: dict[Component, BaseException], real_console: RichConsole, ) -> None: - from rich.progress import Progress, SpinnerColumn, TextColumn - - completed_buffers: list[tuple[Component, str]] = [] - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=real_console, - transient=True, - ) as progress: + """Execute components with per-thread output buffering and a Progress bar.""" + with _make_progress(real_console) as progress: task_ids: dict[Component, Any] = {} for comp in components: task_ids[comp] = progress.add_task(f"[cyan]{comp.label}[/cyan]", total=None) - future = pool.submit(make_task(comp, buffered_consoles[comp])) + plan = (plans or {}).get(comp) + future = pool.submit(make_task(comp, buffered_consoles[comp], plan)) futures[future] = comp for future in as_completed(futures): @@ -357,14 +409,16 @@ def _run_buffered( completed=True, ) - buf_content = buffers[comp].getvalue() - if buf_content.strip(): - completed_buffers.append((comp, buf_content)) - - for comp, buf_content in completed_buffers: - real_console.print(f"\n[bold cyan]{comp.label}[/bold cyan]") - real_console.file.write(buf_content) - real_console.file.flush() + # Replay buffers in original component order (not completion order) + first = True + for comp in components: + buf_content = buffers[comp].getvalue() + if buf_content.strip(): + if not first: + real_console.print() + real_console.print(f"[bold cyan]{comp.label}[/bold cyan]") + first = False + real_console.print(buf_content.rstrip("\n"), markup=False, highlight=False) def _run_unbuffered( @@ -377,24 +431,39 @@ def _run_unbuffered( errors: dict[Component, BaseException], real_console: RichConsole, ) -> None: - with real_console.status(f"Running {method}...") as status: + """Execute components under a Progress bar (no output buffering).""" + from ai_rules.cli.display import print_warning + + pending_warnings: list[str] = [] + + with _make_progress(real_console) as progress: + task_ids: dict[Component, Any] = {} for comp in components: + task_ids[comp] = progress.add_task(f"[cyan]{comp.label}[/cyan]", total=None) future = pool.submit(make_task(comp)) futures[future] = comp - completed = 0 for future in as_completed(futures): comp = futures[future] - completed += 1 exc = future.exception() if exc is not None: if method == "plan": results[comp] = ComponentPlan(has_changes=False) - real_console.print( - f"[yellow]⚠[/yellow] {comp.label} plan failed: {exc}" - ) + pending_warnings.append(f"{comp.label} plan failed: {exc}") else: errors[comp] = exc + progress.update( + task_ids[comp], + description=f"[red]{comp.label} (failed)[/red]", + completed=True, + ) else: results[comp] = future.result() - status.update(f"Running {method}... ({completed}/{len(components)} done)") + progress.update( + task_ids[comp], + description=f"[green]{comp.label}[/green]", + completed=True, + ) + + for msg in pending_warnings: + print_warning(msg) diff --git a/src/ai_rules/mcp.py b/src/ai_rules/mcp.py index d6875fb..e71689a 100644 --- a/src/ai_rules/mcp.py +++ b/src/ai_rules/mcp.py @@ -164,11 +164,13 @@ def format_diff( def format_pending(self, name: str, expected: dict[str, Any]) -> str: """Format expected MCP config for pending installation.""" + from ai_rules.cli.display import dim + marker = self._marker_field display_config = {k: v for k, v in expected.items() if k != marker} config_json = json.dumps(display_config, indent=2, sort_keys=True) - lines = ["[dim] Will be installed with:[/dim]"] + lines = [f" {dim('Will be installed with:')}"] for line in config_json.splitlines(): lines.append(f"[green] {line}[/green]") return "\n".join(lines) diff --git a/src/ai_rules/symlinks.py b/src/ai_rules/symlinks.py index 51290e3..7ab17c4 100644 --- a/src/ai_rules/symlinks.py +++ b/src/ai_rules/symlinks.py @@ -6,9 +6,7 @@ from enum import Enum from pathlib import Path -from rich.console import Console - -console = Console() +from ai_rules.cli.display import console, dim def create_backup_path(target: Path) -> Path: @@ -85,7 +83,7 @@ def create_symlink( elif force: backup = create_backup_path(target) target.rename(backup) - console.print(f" [dim]Backed up to {backup}[/dim]") + console.print(f" {dim(f'Backed up to {backup}')}") else: response = console.input( f"[yellow]?[/yellow] File {target} exists and is not a symlink\n Replace with symlink? (y/N): " @@ -94,7 +92,7 @@ def create_symlink( return (SymlinkResult.SKIPPED, "Skipped by user") backup = create_backup_path(target) target.rename(backup) - console.print(f" [dim]Backed up to {backup}[/dim]") + console.print(f" {dim(f'Backed up to {backup}')}") if dry_run: return (SymlinkResult.CREATED, f"Would create: {target} → {source}") @@ -109,14 +107,13 @@ def create_symlink( return ( SymlinkResult.ERROR, f"Permission denied: {e}\n" - " [dim]Tip: Check file permissions and ownership. " - "You may need to remove existing files manually.[/dim]", + f" {dim('Tip: Check file permissions and ownership. You may need to remove existing files manually.')}", ) except FileExistsError as e: return ( SymlinkResult.ERROR, f"File already exists: {e}\n" - " [dim]Tip: Use -y to replace existing files.[/dim]", + f" {dim('Tip: Use -y to replace existing files.')}", ) except (OSError, ValueError) as e: try: @@ -126,13 +123,13 @@ def create_symlink( return ( SymlinkResult.ERROR, f"Permission denied: {e}\n" - " [dim]Tip: Check file permissions and ownership.[/dim]", + f" {dim('Tip: Check file permissions and ownership.')}", ) except Exception as e2: return ( SymlinkResult.ERROR, f"Failed to create symlink: {e2}\n" - " [dim]Tip: Check that the target directory exists and is writable.[/dim]", + f" {dim('Tip: Check that the target directory exists and is writable.')}", ) @@ -197,13 +194,13 @@ def remove_symlink(target_path: Path, force: bool = False) -> tuple[bool, str]: return ( False, f"Permission denied: {e}\n" - " [dim]Tip: Check file permissions. You may need elevated privileges.[/dim]", + f" {dim('Tip: Check file permissions. You may need elevated privileges.')}", ) except OSError as e: return ( False, f"Error removing symlink: {e}\n" - " [dim]Tip: Check that the file exists and is accessible.[/dim]", + f" {dim('Tip: Check that the file exists and is accessible.')}", ) diff --git a/src/ai_rules/targets/base.py b/src/ai_rules/targets/base.py index a31deba..de38c4a 100644 --- a/src/ai_rules/targets/base.py +++ b/src/ai_rules/targets/base.py @@ -272,11 +272,7 @@ def is_cache_stale(self) -> bool: cache_mtime = cache_path.stat().st_mtime - base_settings_path = self._base_settings_path - if base_settings_path.exists(): - if base_settings_path.stat().st_mtime > cache_mtime: - return True - + # User config and profile changes are always real edits — trust mtime from ai_rules.config import get_user_config_path user_config_path = get_user_config_path() @@ -292,6 +288,8 @@ def is_cache_stale(self) -> bool: if profile_path.exists() and profile_path.stat().st_mtime > cache_mtime: return True + # Base settings mtime changes on upgrades/installs without content change; + # fall through to content check to avoid false positives return self.get_cache_diff() is not None def get_cache_diff(self) -> str | None: diff --git a/tests/unit/test_cli_components.py b/tests/unit/test_cli_components.py index 0f3b310..701a9cb 100644 --- a/tests/unit/test_cli_components.py +++ b/tests/unit/test_cli_components.py @@ -12,7 +12,7 @@ @pytest.mark.unit def test_install_components_run_in_expected_order(): assert [component.label for component in INSTALL_COMPONENTS] == [ - "Settings", + "Settings Cache", "Optional Tools", "Config Files", "Skills", @@ -27,7 +27,7 @@ def test_install_components_run_in_expected_order(): def test_status_components_cover_managed_lifecycle_surfaces(): assert [component.label for component in STATUS_COMPONENTS] == [ "Config Files", - "Settings", + "Settings Cache", "MCPs", "Claude Plugins", "Claude Extensions", @@ -41,7 +41,7 @@ def test_status_components_cover_managed_lifecycle_surfaces(): def test_diff_components_include_drift_sources(): assert [component.label for component in DIFF_COMPONENTS] == [ "Config Files", - "Settings", + "Settings Cache", "MCPs", "Claude Plugins", "Claude Extensions", diff --git a/tests/unit/test_cli_runner.py b/tests/unit/test_cli_runner.py index 45cca12..8babb49 100644 --- a/tests/unit/test_cli_runner.py +++ b/tests/unit/test_cli_runner.py @@ -1,7 +1,11 @@ +import threading +import time + from io import StringIO from pathlib import Path from unittest.mock import patch +import click import pytest from rich.console import Console @@ -12,6 +16,7 @@ run_components_parallel, run_install, run_install_parallel, + run_parallel, run_uninstall_parallel, ) from ai_rules.config import Config @@ -149,7 +154,7 @@ def test_run_install_skips_confirmation_when_yes(tmp_path: Path) -> None: # Confirm is only imported inside the branch guarded by `not ctx.yes and not # ctx.dry_run`. With yes=True the block is never entered, so both phases run # without any interactive prompt. - with patch("rich.prompt.Confirm.ask") as mock_ask: + with patch("click.confirm") as mock_ask: result = run_install([infra], [semantic], make_context(tmp_path, yes=True)) mock_ask.assert_not_called() @@ -163,7 +168,7 @@ def test_run_install_skips_confirmation_when_dry_run(tmp_path: Path) -> None: infra = InfraComponent("infra", ComponentResult()) semantic = FakeComponent("semantic", ComponentResult()) - with patch("rich.prompt.Confirm.ask") as mock_ask: + with patch("click.confirm") as mock_ask: result = run_install([infra], [semantic], make_context(tmp_path, dry_run=True)) mock_ask.assert_not_called() @@ -316,7 +321,7 @@ def test_run_install_parallel_skips_confirmation_when_yes(tmp_path: Path) -> Non infra = PlanApplyInfraComponent("infra") semantic = PlanApplyComponent("semantic") - with patch("rich.prompt.Confirm.ask") as mock_ask: + with patch("click.confirm") as mock_ask: result = run_install_parallel( [infra], [semantic], make_context(tmp_path, yes=True) ) @@ -330,7 +335,7 @@ def test_run_install_parallel_skips_confirmation_when_dry_run(tmp_path: Path) -> infra = PlanApplyInfraComponent("infra") semantic = PlanApplyComponent("semantic") - with patch("rich.prompt.Confirm.ask") as mock_ask: + with patch("click.confirm") as mock_ask: result = run_install_parallel( [infra], [semantic], make_context(tmp_path, dry_run=True) ) @@ -439,3 +444,139 @@ def test_run_components_parallel_respects_component_filter(tmp_path: Path) -> No assert included in results assert excluded not in results + + +@pytest.mark.unit +def test_run_install_user_cancels_confirmation(tmp_path: Path) -> None: + infra = InfraComponent("infra", ComponentResult()) + semantic = FakeComponent("semantic", ComponentResult()) + + with ( + patch("ai_rules.cli.check_first_run", return_value=True), + patch("ai_rules.cli._display_pending_changes", return_value=True), + patch("click.confirm", side_effect=click.exceptions.Abort()), + ): + result = run_install([infra], [semantic], make_context(tmp_path)) + + assert result.aborted is True + assert semantic.calls == 0 + + +@pytest.mark.unit +def test_run_install_parallel_user_cancels_confirmation(tmp_path: Path) -> None: + infra = PlanApplyInfraComponent("infra") + semantic = PlanApplyComponent("semantic") + + with ( + patch("ai_rules.cli.check_first_run", return_value=True), + patch("ai_rules.cli._display_pending_changes", return_value=True), + patch("click.confirm", side_effect=click.exceptions.Abort()), + ): + result = run_install_parallel([infra], [semantic], make_context(tmp_path)) + + assert result.aborted is True + assert semantic.plan_calls == 0 + assert semantic.apply_calls == 0 + + +class _StatusComponent(Component): + component_id = "status-test" + filterable = True + + def __init__( + self, + label: str, + *, + result: ComponentResult | None = None, + output: str = "", + delay: float = 0.0, + barrier: threading.Barrier | None = None, + ): + self.label = label + self._result = result or ComponentResult() + self._output = output + self._delay = delay + self._barrier = barrier + self.calls = 0 + + def status(self, ctx: CliContext) -> ComponentResult: + self.calls += 1 + if self._barrier is not None: + self._barrier.wait() + if self._delay: + time.sleep(self._delay) + if self._output: + from ai_rules.cli.display import get_console + + get_console().print(self._output, markup=False, highlight=False) + return self._result + + def diff(self, ctx: CliContext) -> ComponentResult: + return self._result + + def validate(self, ctx: CliContext) -> ComponentResult: + return self._result + + +@pytest.mark.unit +def test_run_parallel_status_aggregates_ok(tmp_path: Path) -> None: + first = _StatusComponent("first", result=ComponentResult(ok=True)) + second = _StatusComponent("second", result=ComponentResult(ok=False)) + + result = run_parallel([first, second], "status", make_context(tmp_path)) + + assert result.ok is False + + +@pytest.mark.unit +def test_run_parallel_diff_aggregates_changed(tmp_path: Path) -> None: + comp = _StatusComponent("comp", result=ComponentResult(changed=True)) + + result = run_parallel([comp], "diff", make_context(tmp_path)) + + assert result.changed is True + + +@pytest.mark.unit +def test_run_parallel_validate_aggregates_failure(tmp_path: Path) -> None: + comp = _StatusComponent("comp", result=ComponentResult(ok=False)) + + result = run_parallel([comp], "validate", make_context(tmp_path)) + + assert result.ok is False + + +@pytest.mark.unit +def test_run_parallel_buffer_replay_preserves_definition_order(tmp_path: Path) -> None: + barrier = threading.Barrier(2) + + first = _StatusComponent( + "Alpha", + output="alpha-output", + delay=0.05, + barrier=barrier, + result=ComponentResult(), + ) + second = _StatusComponent( + "Beta", + output="beta-output", + barrier=barrier, + result=ComponentResult(), + ) + + buf = StringIO() + replay_console = Console(file=buf, highlight=False, markup=False) + + comp_list = [first, second] + ctx = make_context(tmp_path) + + with ( + patch("ai_rules.cli.runner._real_console", replay_console), + patch("ai_rules.cli.display._real_console", replay_console), + ): + run_components_parallel(comp_list, "status", ctx) + + output = buf.getvalue() + + assert output.index("Alpha") < output.index("Beta") + assert output.index("alpha-output") < output.index("Beta") diff --git a/uv.lock b/uv.lock index 09a97a8..c8a19eb 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "ai-agent-rules" -version = "0.49.1" +version = "0.50.1" source = { editable = "." } dependencies = [ { name = "click" },