"""Timmy Control Panel CLI — primary control surface for automations. Usage: timmyctl daily-run # Run the Daily Run orchestration timmyctl log-run # Capture a Daily Run logbook entry timmyctl inbox # Show what's "calling Timmy" timmyctl config # Display key configuration """ import json import os from pathlib import Path from typing import Any import typer import yaml from rich.console import Console from rich.table import Table # Initialize Rich console for nice output console = Console() app = typer.Typer( help="Timmy Control Panel — primary control surface for automations", rich_markup_mode="rich", ) # Default config paths DEFAULT_CONFIG_DIR = Path("timmy_automations/config") AUTOMATIONS_CONFIG = DEFAULT_CONFIG_DIR / "automations.json" DAILY_RUN_CONFIG = DEFAULT_CONFIG_DIR / "daily_run.json" TRIAGE_RULES_CONFIG = DEFAULT_CONFIG_DIR / "triage_rules.yaml" def _load_json_config(path: Path) -> dict[str, Any]: """Load a JSON config file, returning empty dict on error.""" try: with open(path, encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: console.print(f"[red]Error loading {path}: {e}[/red]") return {} def _load_yaml_config(path: Path) -> dict[str, Any]: """Load a YAML config file, returning empty dict on error.""" try: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) or {} except (FileNotFoundError, yaml.YAMLError) as e: console.print(f"[red]Error loading {path}: {e}[/red]") return {} def _get_config_dir() -> Path: """Return the config directory path.""" # Allow override via environment variable env_dir = os.environ.get("TIMMY_CONFIG_DIR") if env_dir: return Path(env_dir) return DEFAULT_CONFIG_DIR @app.command() def daily_run( dry_run: bool = typer.Option( False, "--dry-run", "-n", help="Show what would run without executing" ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"), ): """Run the Daily Run orchestration (agenda + summary). Executes the daily run workflow including: - Loop Guard checks - Cycle Retrospective - Triage scoring (if scheduled) - Loop introspection (if scheduled) """ console.print("[bold green]Timmy Daily Run[/bold green]") console.print() config_path = _get_config_dir() / "daily_run.json" config = _load_json_config(config_path) if not config: console.print("[yellow]No daily run configuration found.[/yellow]") raise typer.Exit(1) schedules = config.get("schedules", {}) triggers = config.get("triggers", {}) if verbose: console.print(f"[dim]Config loaded from: {config_path}[/dim]") console.print() # Show the daily run schedule table = Table(title="Daily Run Schedules") table.add_column("Schedule", style="cyan") table.add_column("Description", style="green") table.add_column("Automations", style="yellow") for schedule_name, schedule_data in schedules.items(): automations = schedule_data.get("automations", []) table.add_row( schedule_name, schedule_data.get("description", ""), ", ".join(automations) if automations else "—", ) console.print(table) console.print() # Show triggers trigger_table = Table(title="Triggers") trigger_table.add_column("Trigger", style="cyan") trigger_table.add_column("Description", style="green") trigger_table.add_column("Automations", style="yellow") for trigger_name, trigger_data in triggers.items(): automations = trigger_data.get("automations", []) trigger_table.add_row( trigger_name, trigger_data.get("description", ""), ", ".join(automations) if automations else "—", ) console.print(trigger_table) console.print() if dry_run: console.print("[yellow]Dry run mode — no actions executed.[/yellow]") else: console.print("[green]Executing daily run automations...[/green]") # TODO: Implement actual automation execution # This would call the appropriate scripts from the automations config console.print("[dim]Automation execution not yet implemented.[/dim]") @app.command() def log_run( message: str = typer.Argument(..., help="Logbook entry message"), category: str = typer.Option( "general", "--category", "-c", help="Entry category (e.g., retro, todo, note)" ), ): """Capture a quick Daily Run logbook entry. Logs a structured entry to the daily run logbook for later review. Entries are timestamped and categorized automatically. """ from datetime import datetime timestamp = datetime.now().isoformat() console.print("[bold green]Daily Run Log Entry[/bold green]") console.print() console.print(f"[dim]Timestamp:[/dim] {timestamp}") console.print(f"[dim]Category:[/dim] {category}") console.print(f"[dim]Message:[/dim] {message}") console.print() # TODO: Persist to actual logbook file # This would append to a logbook file (e.g., .loop/logbook.jsonl) console.print("[green]✓[/green] Entry logged (simulated)") @app.command() def inbox( limit: int = typer.Option(10, "--limit", "-l", help="Maximum items to show"), include_prs: bool = typer.Option(True, "--prs/--no-prs", help="Show open PRs"), include_issues: bool = typer.Option(True, "--issues/--no-issues", help="Show relevant issues"), ): """Show what's "calling Timmy" — PRs, Daily Run items, alerts. Displays a unified inbox of items requiring attention: - Open pull requests awaiting review - Daily run queue items - Alerts and notifications """ console.print("[bold green]Timmy Inbox[/bold green]") console.print() # Load automations to show what's enabled config_path = _get_config_dir() / "automations.json" config = _load_json_config(config_path) automations = config.get("automations", []) enabled_automations = [a for a in automations if a.get("enabled", False)] # Show automation status auto_table = Table(title="Active Automations") auto_table.add_column("ID", style="cyan") auto_table.add_column("Name", style="green") auto_table.add_column("Category", style="yellow") auto_table.add_column("Trigger", style="magenta") for auto in enabled_automations[:limit]: auto_table.add_row( auto.get("id", ""), auto.get("name", ""), "✓" if auto.get("enabled", False) else "✗", auto.get("category", ""), ) console.print(auto_table) console.print() # TODO: Fetch actual PRs from Gitea API if include_prs: pr_table = Table(title="Open Pull Requests (placeholder)") pr_table.add_column("#", style="cyan") pr_table.add_column("Title", style="green") pr_table.add_column("Author", style="yellow") pr_table.add_column("Status", style="magenta") pr_table.add_row("—", "[dim]No PRs fetched (Gitea API not configured)[/dim]", "—", "—") console.print(pr_table) console.print() # TODO: Fetch relevant issues from Gitea API if include_issues: issue_table = Table(title="Issues Calling for Attention (placeholder)") issue_table.add_column("#", style="cyan") issue_table.add_column("Title", style="green") issue_table.add_column("Type", style="yellow") issue_table.add_column("Priority", style="magenta") issue_table.add_row( "—", "[dim]No issues fetched (Gitea API not configured)[/dim]", "—", "—" ) console.print(issue_table) console.print() @app.command() def config( key: str | None = typer.Argument(None, help="Show specific config key (e.g., 'automations')"), show_rules: bool = typer.Option(False, "--rules", "-r", help="Show triage rules overview"), ): """Display key configuration — labels, logbook issue ID, token rules overview. Shows the current Timmy automation configuration including: - Automation manifest - Daily run schedules - Triage scoring rules """ console.print("[bold green]Timmy Configuration[/bold green]") console.print() config_dir = _get_config_dir() if key == "automations" or key is None: auto_config = _load_json_config(config_dir / "automations.json") automations = auto_config.get("automations", []) table = Table(title="Automations Manifest") table.add_column("ID", style="cyan") table.add_column("Name", style="green") table.add_column("Enabled", style="yellow") table.add_column("Category", style="magenta") for auto in automations: enabled = "✓" if auto.get("enabled", False) else "✗" table.add_row( auto.get("id", ""), auto.get("name", ""), enabled, auto.get("category", ""), ) console.print(table) console.print() if key == "daily_run" or (key is None and not show_rules): daily_config = _load_json_config(config_dir / "daily_run.json") if daily_config: console.print("[bold]Daily Run Configuration:[/bold]") console.print(f"[dim]Version:[/dim] {daily_config.get('version', 'unknown')}") console.print(f"[dim]Description:[/dim] {daily_config.get('description', '')}") console.print() if show_rules or key == "triage_rules": rules_config = _load_yaml_config(config_dir / "triage_rules.yaml") if rules_config: thresholds = rules_config.get("thresholds", {}) console.print("[bold]Triage Scoring Rules:[/bold]") console.print(f" Ready threshold: {thresholds.get('ready', 'N/A')}") console.print(f" Excellent threshold: {thresholds.get('excellent', 'N/A')}") console.print() scope = rules_config.get("scope", {}) console.print("[bold]Scope Scoring:[/bold]") console.print(f" Meta penalty: {scope.get('meta_penalty', 'N/A')}") console.print() alignment = rules_config.get("alignment", {}) console.print("[bold]Alignment Scoring:[/bold]") console.print(f" Bug score: {alignment.get('bug_score', 'N/A')}") console.print(f" Refactor score: {alignment.get('refactor_score', 'N/A')}") console.print(f" Feature score: {alignment.get('feature_score', 'N/A')}") console.print() quarantine = rules_config.get("quarantine", {}) console.print("[bold]Quarantine Rules:[/bold]") console.print(f" Failure threshold: {quarantine.get('failure_threshold', 'N/A')}") console.print(f" Lookback cycles: {quarantine.get('lookback_cycles', 'N/A')}") console.print() def main(): """Entry point for the timmyctl CLI.""" app() if __name__ == "__main__": main()