#!/usr/bin/env python3 """odysseus-logs — unified view of log files across the app. Right now logs live in two places: - logs/ app-level (compound.log, future runtime) - /tmp/odysseus-tmux/*.log per-tmux-session model download/serve logs This CLI surfaces them so you don't have to remember which directory holds what. odysseus-logs list # every log we know about odysseus-logs tail NAME # tail -f a specific log odysseus-logs tail NAME --lines 200 # last N lines only (no follow) odysseus-logs cat NAME # print full content odysseus-logs clean # delete tmux logs older than 7 days """ from __future__ import annotations import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib")) from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT quiet_logs() import argparse, json, subprocess, time from datetime import datetime from pathlib import Path _APP_LOGS = _REPO_ROOT / "logs" _TMUX_LOGS = Path("/tmp/odysseus-tmux") def _enumerate() -> list[dict]: """Return every known log file as {name, path, bytes, modified}.""" out = [] for base in (_APP_LOGS, _TMUX_LOGS): if not base.is_dir(): continue for p in sorted(base.glob("*.log")): try: st = p.stat() except OSError: continue out.append({ "name": p.name, "path": str(p), "bytes": st.st_size, "modified": datetime.fromtimestamp(st.st_mtime).isoformat(), }) out.sort(key=lambda r: r["modified"], reverse=True) return out def _resolve(name: str) -> Path | None: """Match a log by exact filename, basename-without-extension, or substring. Returns the most-recently-modified match if there are ties.""" if not isinstance(name, str): return None candidates = [] for base in (_APP_LOGS, _TMUX_LOGS): if not base.is_dir(): continue for p in base.glob("*.log"): if p.name == name or p.stem == name or name in p.name: candidates.append(p) if not candidates: return None candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] def cmd_list(args): emit(_enumerate(), args) def cmd_tail(args): p = _resolve(args.name) if p is None: fail(f"no log matching {args.name!r}; run `odysseus-logs list`") if args.follow: # Exec tail -f so signals + buffering are native. os.execvp("tail", ["tail", "-n", str(args.lines), "-f", str(p)]) # Non-follow: print last N lines + exit. proc = subprocess.run(["tail", "-n", str(args.lines), str(p)], capture_output=True, text=True) sys.stdout.write(proc.stdout) sys.exit(proc.returncode) def cmd_cat(args): p = _resolve(args.name) if p is None: fail(f"no log matching {args.name!r}") sys.stdout.write(p.read_text(errors="replace")) def cmd_clean(args): """Delete tmux logs older than --days. Doesn't touch app logs.""" if not _TMUX_LOGS.is_dir(): emit({"deleted": 0, "kept": 0}, args) return cutoff = time.time() - args.days * 86400 deleted, kept = [], 0 for p in _TMUX_LOGS.glob("*.log"): try: if p.stat().st_mtime < cutoff: p.unlink() deleted.append(p.name) else: kept += 1 except OSError: continue emit({"deleted": deleted, "kept": kept, "cutoff_days": args.days}, args) def _build_parser(): p = common_parser("odysseus-logs", "Tail / list logs across the app.") sub = p.add_subparsers(dest="cmd", required=True) pl = sub.add_parser("list", parents=p._common_parents) pl.set_defaults(func=cmd_list) pt = sub.add_parser("tail", parents=p._common_parents) pt.add_argument("name") pt.add_argument("-n", "--lines", type=int, default=80) pt.add_argument("-f", "--follow", action="store_true", help="follow new lines (tail -f); omit for one-shot") pt.set_defaults(func=cmd_tail) pc = sub.add_parser("cat", parents=p._common_parents) pc.add_argument("name") pc.set_defaults(func=cmd_cat) pcl = sub.add_parser("clean", parents=p._common_parents) pcl.add_argument("--days", type=int, default=7, help="delete tmux logs older than N days (default 7)") pcl.set_defaults(func=cmd_clean) return p if __name__ == "__main__": sys.exit(run(_build_parser()))