#!/usr/bin/env python3 """odysseus-contacts — Unix-style CLI for the contacts feature. Talks to the same CardDAV server the web UI does, using credentials from `data/settings.json` (set via the Settings → Connections panel). Output is JSON on stdout, errors on stderr, non-zero exit on failure. odysseus-contacts list | jq -r '.[] | .name + "\\t" + .email' odysseus-contacts search alice odysseus-contacts add --name "Alice Doe" --email alice@example.com Subcommands: list List all contacts search Filter contacts by case-insensitive substring match add Add a new contact (name + email) config Show the current CardDAV config (URL/user; password masked) """ from __future__ import annotations import sys import os, 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 import json import logging import os import sys from pathlib import Path def quiet_logs() -> None: level_name = os.environ.get("LOG_LEVEL", "WARNING").upper() level = getattr(logging, level_name, logging.WARNING) root = logging.getLogger() root.setLevel(level) for handler in root.handlers: handler.setLevel(level) quiet_logs() try: from routes.contacts_routes import ( _get_carddav_config, _fetch_contacts, _create_contact, ) quiet_logs() except ModuleNotFoundError as e: sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n") sys.exit(2) def fail(msg: str, code: int = 1) -> None: sys.stderr.write(f"error: {msg}\n") sys.exit(code) def _contact_rows(contacts): return [c for c in contacts or [] if isinstance(c, dict)] # ─── list ──────────────────────────────────────────────────────────── def cmd_list(args) -> None: cfg = _get_carddav_config() if not cfg["url"]: fail("CardDAV not configured. Set carddav_url/username/password in the web UI.") contacts = _contact_rows(_fetch_contacts(force=args.refresh)) emit(contacts, args) # ─── search ────────────────────────────────────────────────────────── def cmd_search(args) -> None: cfg = _get_carddav_config() if not cfg["url"]: fail("CardDAV not configured.") q = args.query.lower() contacts = _contact_rows(_fetch_contacts()) matches = [ c for c in contacts if q in (c.get("name") or "").lower() or q in (c.get("email") or "").lower() ] emit(matches, args) # ─── add ───────────────────────────────────────────────────────────── def cmd_add(args) -> None: cfg = _get_carddav_config() if not cfg["url"]: fail("CardDAV not configured.") ok = _create_contact(args.name, args.email) if not ok: fail("CardDAV PUT failed (see LOG_LEVEL=DEBUG for detail)") emit({"ok": True, "name": args.name, "email": args.email}, args) # ─── config ────────────────────────────────────────────────────────── def cmd_config(args) -> None: cfg = _get_carddav_config() safe = dict(cfg) if safe.get("password"): safe["password"] = "***" emit(safe, args) # ─── argparse ──────────────────────────────────────────────────────── def _build_parser() -> argparse.ArgumentParser: common = argparse.ArgumentParser(add_help=False) common.add_argument("--pretty", action="store_true", help="Pretty-print JSON") p = argparse.ArgumentParser( prog="odysseus-contacts", description="Shell-friendly wrapper around the Odysseus contacts (CardDAV) feature.", parents=[common], ) sub = p.add_subparsers(dest="cmd", required=True) pl = sub.add_parser("list", help="list all contacts", parents=[common]) pl.add_argument("--refresh", action="store_true", help="bypass cache and re-fetch") pl.set_defaults(func=cmd_list) ps = sub.add_parser("search", help="filter by name/email substring", parents=[common]) ps.add_argument("query") ps.set_defaults(func=cmd_search) pa = sub.add_parser("add", help="add a new contact", parents=[common]) pa.add_argument("--name", required=True) pa.add_argument("--email", required=True) pa.set_defaults(func=cmd_add) pc = sub.add_parser("config", help="show CardDAV config (password masked)", parents=[common]) pc.set_defaults(func=cmd_config) return p if __name__ == "__main__": sys.exit(run(_build_parser()))