#!/usr/bin/env python3 """ACS menu bar app with local commit agent. Manages a lightweight commit agent that discovers local repos, asks the remote ACS daemon for LLM-generated commit messages, and commits+pushes. Runs without the full auto_commit_service package — only needs httpx + rumps. Usage: ./commits-tray --url http://apricot.lan:8200 --cycle 300 ./commits-tray --url http://apricot.lan:8200 --commit-local --dry-run ./commits-tray --url http://apricot.lan:8200 --commit-local # for real """ import argparse import json import logging import os import sys from pathlib import Path # Add the tray module directory so we can import directly _script_dir = os.path.dirname(os.path.abspath(__file__)) _tray_dir = os.path.join(_script_dir, "src", "auto_commit_service", "tray") sys.path.insert(0, _tray_dir) def _load_ignore_repos_from_config() -> list[str]: """Read ignore_repos from ~/.config/commits/startup-config.json. Mirrors the daemon's config loader (see auto_commit_service.__main__). Supports either a top-level `ignore_repos` array (shared) or a per-daemon entry whose `directory` matches the current working dir. Missing or unreadable files yield an empty list — never raises. """ config_path = Path.home() / ".config" / "commits" / "startup-config.json" if not config_path.exists(): return [] try: data = json.loads(config_path.read_text()) except (OSError, json.JSONDecodeError) as exc: logging.warning(f"commits-tray: failed to read {config_path}: {exc}") return [] ignore: list[str] = [] if isinstance(data.get("ignore_repos"), list): ignore.extend(str(p) for p in data["ignore_repos"] if p) cwd = str(Path.cwd()) for daemon in data.get("daemons", []) or []: if isinstance(daemon, dict) and daemon.get("directory") == cwd: entries = daemon.get("ignore_repos") if isinstance(entries, list): ignore.extend(str(p) for p in entries if p) break # De-dup while preserving order seen: set[str] = set() deduped: list[str] = [] for p in ignore: if p not in seen: seen.add(p) deduped.append(p) return deduped def main(): parser = argparse.ArgumentParser(description="ACS menu bar app + local commit agent") parser.add_argument( "--url", "-u", default="http://apricot.lan:8200", help="Remote ACS daemon URL for LLM + recording (default: http://apricot.lan:8200)", ) parser.add_argument( "--repos", "-r", nargs="+", default=None, help="Base paths to scan for local git repos (default: ~/Code)", ) parser.add_argument( "--cycle", "-c", type=int, default=300, help="Seconds between commit cycles (default: 300)", ) parser.add_argument( "--commit-local", action="store_true", default=False, help="Enable local commit loop on this host (proxy mode). Default OFF.", ) parser.add_argument( "--dry-run", action="store_true", default=False, help="Scan and generate messages but skip git commit/push. Useful for validation.", ) parser.add_argument( "--max-diff-bytes", type=int, default=131072, help="Per-repo diff size cap in bytes before truncation (default: 131072)", ) parser.add_argument( "--ignore-repo", action="append", default=[], dest="ignore_repos", help=( "Substring matched against absolute repo paths; matching repos " "are excluded from discovery. Repeat for multiple. Merged with " "ignore_repos from ~/.config/commits/startup-config.json." ), ) args = parser.parse_args() ignore_repos = list(_load_ignore_repos_from_config()) + list(args.ignore_repos or []) # Import after argparse so --help exits cleanly before rumps is required from app import run_tray # noqa: E402 repos_paths = [Path(p).expanduser() for p in args.repos] if args.repos else None run_tray( daemon_url=args.url, repos_paths=repos_paths, commit_local=args.commit_local, dry_run=args.dry_run, max_diff_bytes=args.max_diff_bytes, cycle_seconds=args.cycle, ignore_repos=ignore_repos, ) if __name__ == "__main__": main()