#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
#   "boto3>=1.35",
# ]
# ///
"""
publish.py: manage a private glanceable podcast feed on Cloudflare R2.

Commands:
  init      create/update the feed settings (safe to re-run)
  check     verify tools, settings, secrets and bucket access
  add       upload an episode made by video_episode.py or article_episode.py
  list      list episodes in the feed
  remove    delete an episode from the feed and the bucket
  feed-url  copy the feed URL to the clipboard (never printed)
  rotate    move the feed to a new secret URL (you'll need to re-subscribe)

Secrets live in a secret store, never in files. Choose one with `init --secrets`:
  keychain   macOS Keychain, service "glanceable-podcast" (default)
  1password  fields on one 1Password item, read with the `op` CLI
             (--op-vault, --op-item, and --op-account if you have several accounts)
The secrets are:
  r2_access_key_id, r2_secret_access_key   you add these once (see the tool page)
  feed_token                               generated by `init`; the feed's secret path
Non-secret settings (including which secret store) live in
~/.config/glanceable-podcast/config.json.

Bucket layout (all paths are random, so one URL doesn't reveal another):
  feeds/<feed_token>.xml    the RSS feed
  feeds/<feed_token>.json   episode list the feed is rebuilt from
  episodes/<id>.mp3|.jpg    audio and artwork
  covers/<id>.jpg           feed artwork
"""

import argparse
import html
import json
import re
import secrets
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from email.utils import format_datetime
from pathlib import Path

SERVICE = "glanceable-podcast"
CONFIG_PATH = Path.home() / ".config" / "glanceable-podcast" / "config.json"
CACHE = Path.home() / "Library" / "Caches" / "glanceable-podcast"
TOOL_PAGE = "https://tools.jamesking.io/glanceable-podcast/"


def fail(msg):
  print(f"error: {msg}", file=sys.stderr)
  sys.exit(1)


# --- Secret stores ---------------------------------------------------------
# Both stores share one interface: get(name) -> value or None, set(name, value),
# describe(), and how_to_add(name). set() is only used for the generated feed
# token: the value is briefly visible to the same user's processes via argv.
# Credentials you enter yourself never pass through this script.

SECRET_NAMES = ("r2_access_key_id", "r2_secret_access_key", "feed_token")
OP_SETUP = ("install the 1Password CLI (brew install 1password-cli) and turn on "
            "Settings → Developer → Integrate with 1Password CLI in the 1Password app")


class Keychain:
  """Generic passwords in the login keychain, under service SERVICE."""

  error = None

  def describe(self):
    return f"macOS Keychain (service '{SERVICE}')"

  def get(self, name):
    proc = subprocess.run(["security", "find-generic-password", "-s", SERVICE, "-a", name, "-w"],
                          capture_output=True, text=True)
    return proc.stdout.strip() if proc.returncode == 0 else None

  def set(self, name, value):
    subprocess.run(["security", "add-generic-password", "-U", "-s", SERVICE, "-a", name, "-w", value],
                   check=True, capture_output=True)

  def how_to_add(self, name):
    return f"run `security add-generic-password -s {SERVICE} -a {name} -w` (it prompts for the value)"


class OnePassword:
  """Fields on a single 1Password item, read and written with the `op` CLI."""

  def __init__(self, vault, item, account=None):
    self.vault, self.item, self.account = vault, item, account
    self.error = None

  def describe(self):
    where = f" ({self.account})" if self.account else ""
    return f"1Password item '{self.item}' in vault '{self.vault}'{where}"

  def _op(self, *args):
    if not shutil.which("op"):
      fail(f"the 1Password CLI isn't available: {OP_SETUP}")
    # With several accounts signed in, op needs to be told which one to use.
    account = ["--account", self.account] if self.account else []
    return subprocess.run(["op", *args, *account], capture_output=True, text=True)

  def get(self, name):
    proc = self._op("read", "--no-newline", f"op://{self.vault}/{self.item}/{name}")
    if proc.returncode != 0:
      # op's errors name the reference, never a value.
      message = " ".join(proc.stderr.split())[:300] or "no message"
      self.error = f"{shutil.which('op')} exited with status {proc.returncode}: {message}"
      return None
    if not proc.stdout:
      self.error = f"{shutil.which('op')} returned an empty value; check the field has one"
      return None
    return proc.stdout

  def set(self, name, value):
    # Adds the field if it's missing. Output is captured, never printed.
    proc = self._op("item", "edit", self.item, "--vault", self.vault, f"{name}[password]={value}")
    if proc.returncode != 0:
      fail(f"couldn't save {name} to {self.describe()}: {proc.stderr.strip()}")

  def how_to_add(self, name):
    # A missing field and a refused or timed-out Touch ID prompt both fail a read;
    # only the first is fixed by adding a field.
    if self.error and re.search(r"authoriz|prompt|timeout|sign ?in|no accounts", self.error, re.I):
      return "1Password didn't grant access; unlock it, approve its prompt (Touch ID), and run this again"
    return (f"in 1Password, add a password field labelled '{name}' to the item "
            f"'{self.item}' in vault '{self.vault}'")


def secret_store(cfg):
  conf = cfg.get("secrets") or {}
  if conf.get("backend") == "1password":
    return OnePassword(conf["vault"], conf["item"], conf.get("account"))
  return Keychain()


def secret_hint(store, name):
  """What to do about a secret that couldn't be read."""
  hint = store.how_to_add(name)
  if name == "feed_token" and "1Password didn't grant access" not in hint:
    return "run `publish.py init`"
  return hint


def require_secret(store, name):
  value = store.get(name)
  if not value:
    hint = secret_hint(store, name)
    detail = f"\n({store.error})" if store.error else ""
    fail(f"secret '{name}' not found in {store.describe()}: {hint}{detail}\n(see {TOOL_PAGE})")
  return value


# --- Settings --------------------------------------------------------------

def load_config():
  if not CONFIG_PATH.exists():
    fail(f"not set up yet; run `publish.py init` first (see {TOOL_PAGE})")
  return json.loads(CONFIG_PATH.read_text())


def save_config(cfg):
  CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
  CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n")
  CONFIG_PATH.chmod(0o600)


def feed_keys(token):
  return f"feeds/{token}.json", f"feeds/{token}.xml"


# --- R2 --------------------------------------------------------------------

class Store:
  """Thin wrapper over R2's S3-compatible API."""

  def __init__(self, cfg):
    import boto3
    from botocore.config import Config

    self.cfg = cfg
    self.bucket = cfg["bucket"]
    self.secrets = secret_store(cfg)
    self.s3 = boto3.client(
      "s3",
      endpoint_url=f"https://{cfg['account_id']}.r2.cloudflarestorage.com",
      aws_access_key_id=require_secret(self.secrets, "r2_access_key_id"),
      aws_secret_access_key=require_secret(self.secrets, "r2_secret_access_key"),
      region_name="auto",
      # Newer boto3 adds checksum headers by default that R2 may reject.
      config=Config(request_checksum_calculation="when_required",
                    response_checksum_validation="when_required"),
    )

  def put_file(self, key, path, content_type, cache_control):
    self.s3.upload_file(str(path), self.bucket, key,
                        ExtraArgs={"ContentType": content_type, "CacheControl": cache_control})

  def put_bytes(self, key, data, content_type, cache_control):
    self.s3.put_object(Bucket=self.bucket, Key=key, Body=data,
                       ContentType=content_type, CacheControl=cache_control)

  def get_json(self, key):
    from botocore.exceptions import ClientError
    try:
      obj = self.s3.get_object(Bucket=self.bucket, Key=key)
    except ClientError as err:
      if err.response["Error"].get("Code") in ("NoSuchKey", "404"):
        return None
      raise
    return json.loads(obj["Body"].read())

  def delete(self, *keys):
    for key in keys:
      if key:
        self.s3.delete_object(Bucket=self.bucket, Key=key)


def public_url(cfg, key):
  return f"{cfg['public_url']}/{key}"


def public_status(url):
  """HTTP status of a HEAD request to url, or None if it couldn't be made."""
  req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "glanceable-podcast"})
  try:
    with urllib.request.urlopen(req, timeout=15) as resp:
      return resp.status
  except urllib.error.HTTPError as err:
    return err.code
  except Exception:
    return None


# --- Feed rendering --------------------------------------------------------

def x(text):
  return html.escape(str(text), quote=True)


def cdata(text):
  return "<![CDATA[" + text.replace("]]>", "]]]]><![CDATA[>") + "]]>"


def fmt_ts(seconds):
  h, rem = divmod(int(seconds), 3600)
  m, s = divmod(rem, 60)
  return f"{h}:{m:02}:{s:02}" if h else f"{m}:{s:02}"


def linkify(text):
  escaped = html.escape(text)
  return re.sub(r"https?://[^\s<]*[^\s<.,;:!?)\]'\"]",
                lambda m: f'<a href="{m[0]}">{m[0]}</a>', escaped)


def paragraphs(text):
  blocks = [b for b in re.split(r"\n\s*\n", text.strip()) if b.strip()]
  return "".join(f"<p>{linkify(b).replace(chr(10), '<br>')}</p>" for b in blocks)


def show_notes(ep):
  """HTML show notes: summary, chapter timestamps (most podcast apps make
  these tappable), a link back to the video, then its original description."""
  parts = []
  if ep.get("notes"):
    parts.append(paragraphs(ep["notes"]))
  if ep.get("sections"):
    items = "".join(f"<li>{fmt_ts(s['start'])} {html.escape(s['title'])}</li>"
                    for s in ep["sections"])
    parts.append(f"<p><strong>Chapters</strong></p><ul>{items}</ul>")
  byline = " · ".join(v for v in (ep.get("author"), ep.get("site"), ep.get("original_date")) if v)
  source = "article" if ep.get("kind") == "article" else "video"
  if ep.get("link"):
    suffix = f" ({html.escape(byline)})" if byline else ""
    parts.append(f'<p>Original {source}: <a href="{x(ep["link"])}">{html.escape(ep["link"])}</a>{suffix}</p>')
  elif byline:
    parts.append(f"<p>{html.escape(byline)}</p>")
  if ep.get("description"):
    parts.append("<hr>" + paragraphs(ep["description"]))
  return "".join(parts)


def render_item(cfg, ep):
  lines = [
    "    <item>",
    f"      <title>{x(ep['title'])}</title>",
    f'      <guid isPermaLink="false">glanceable-podcast-{x(ep["id"])}</guid>',
    f"      <pubDate>{x(ep['pub_date'])}</pubDate>",
  ]
  if ep.get("link"):
    lines.append(f"      <link>{x(ep['link'])}</link>")
  if ep.get("author"):
    lines.append(f"      <itunes:author>{x(ep['author'])}</itunes:author>")
  lines += [
    f'      <enclosure url="{x(public_url(cfg, ep["mp3_key"]))}" length="{ep["length"]}" type="audio/mpeg"/>',
    f"      <itunes:duration>{int(ep['duration'])}</itunes:duration>",
  ]
  if ep.get("image_key"):
    lines.append(f'      <itunes:image href="{x(public_url(cfg, ep["image_key"]))}"/>')
  lines += [
    f"      <description>{cdata(ep['notes_html'])}</description>",
    f"      <content:encoded>{cdata(ep['notes_html'])}</content:encoded>",
    "    </item>",
  ]
  return "\n".join(lines)


def render_feed(cfg, episodes):
  feed = cfg["feed"]
  image_key = feed.get("cover_key") or next((e["image_key"] for e in episodes if e.get("image_key")), None)
  head = [
    f"    <title>{x(feed['title'])}</title>",
    f"    <description>{x(feed.get('description') or feed['title'])}</description>",
    f"    <link>{x(TOOL_PAGE)}</link>",
    f"    <language>{x(feed.get('language') or 'en')}</language>",
    f"    <lastBuildDate>{format_datetime(datetime.now(timezone.utc))}</lastBuildDate>",
    # Keep it out of Apple's directory and anything that follows its lead.
    "    <itunes:block>Yes</itunes:block>",
    "    <itunes:explicit>false</itunes:explicit>",
  ]
  if feed.get("author"):
    head.append(f"    <itunes:author>{x(feed['author'])}</itunes:author>")
  if image_key:
    url = x(public_url(cfg, image_key))
    head += [
      f'    <itunes:image href="{url}"/>',
      f"    <image><url>{url}</url><title>{x(feed['title'])}</title><link>{x(TOOL_PAGE)}</link></image>",
    ]
  items = [render_item(cfg, ep) for ep in episodes]
  return "\n".join([
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'
    ' xmlns:content="http://purl.org/rss/1.0/modules/content/">',
    "  <channel>",
    *head,
    *items,
    "  </channel>",
    "</rss>",
    "",
  ])


def write_feed(store, cfg, token, state):
  json_key, xml_key = feed_keys(token)
  store.put_bytes(json_key, json.dumps(state, indent=2).encode(), "application/json", "no-cache")
  store.put_bytes(xml_key, render_feed(cfg, state["episodes"]).encode(),
                  "application/rss+xml; charset=utf-8", "public, max-age=300")


def load_state(store, token):
  return store.get_json(feed_keys(token)[0]) or {"episodes": []}


# --- Commands --------------------------------------------------------------

def cmd_init(args):
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
  feed = cfg.setdefault("feed", {})
  if args.account_id:
    cfg["account_id"] = args.account_id.strip()
  if args.bucket:
    cfg["bucket"] = args.bucket.strip()
  if args.public_url:
    cfg["public_url"] = args.public_url.strip().rstrip("/")
  for field in ("title", "description", "author", "language"):
    value = getattr(args, field)
    if value:
      feed[field] = value
  feed.setdefault("title", "Glanceable podcast")

  store_conf = cfg.setdefault("secrets", {"backend": "keychain"})
  if args.secrets:
    store_conf["backend"] = args.secrets
  if store_conf["backend"] == "1password":
    if args.op_vault:
      store_conf["vault"] = args.op_vault
    if args.op_item:
      store_conf["item"] = args.op_item
    if args.op_account:
      store_conf["account"] = args.op_account
    store_conf.setdefault("item", "Glanceable podcast")
    if not store_conf.get("vault"):
      fail("--op-vault is required with --secrets 1password")
  else:
    cfg["secrets"] = {"backend": "keychain"}

  missing = [f"--{k.replace('_', '-')}" for k in ("account_id", "bucket", "public_url") if not cfg.get(k)]
  if missing:
    fail("missing " + ", ".join(missing))
  if not cfg["public_url"].startswith("https://"):
    fail("--public-url should be the bucket's public https:// address (r2.dev or a custom domain)")
  save_config(cfg)

  store = Store(cfg)
  if args.cover:
    cover = Path(args.cover).expanduser()
    if not cover.is_file():
      fail(f"cover image not found: {cover}")
    old_key = feed.get("cover_key")
    feed["cover_key"] = f"covers/{secrets.token_urlsafe(12)}{cover.suffix.lower() or '.jpg'}"
    content_type = "image/png" if cover.suffix.lower() == ".png" else "image/jpeg"
    store.put_file(feed["cover_key"], cover, content_type, "public, max-age=31536000, immutable")
    store.delete(old_key)
  save_config(cfg)

  token = store.secrets.get("feed_token")
  if not token:
    token = secrets.token_urlsafe(24)
    store.secrets.set("feed_token", token)
  state = load_state(store, token)
  write_feed(store, cfg, token, state)

  status = public_status(public_url(cfg, feed_keys(token)[1]))
  print(f"Feed ready ({len(state['episodes'])} episodes). Settings saved to {CONFIG_PATH}.")
  if status != 200:
    print(f"warning: the feed isn't publicly readable yet (HTTP {status or 'error'}). "
          "Check public access is enabled on the bucket and --public-url is right.")
  print("Next: run `publish.py feed-url` to copy the feed URL, then add it to your podcast app.")


def cmd_check(args):
  ok = True

  def report(good, label, hint=""):
    nonlocal ok
    ok = ok and good
    print(f"  {'✓' if good else '✗'} {label}" + (f": {hint}" if hint and not good else ""))

  print("Tools")
  for tool, hint in (("ffmpeg", "brew install ffmpeg"), ("uv", "brew install uv"),
                     ("deno", "brew install deno (yt-dlp needs it for YouTube)")):
    report(bool(shutil.which(tool)), tool, hint)

  print("Settings")
  if not CONFIG_PATH.exists():
    report(False, str(CONFIG_PATH), "run `publish.py init`")
    sys.exit(1)
  cfg = load_config()
  report(True, f"{CONFIG_PATH} (bucket '{cfg.get('bucket')}', feed '{cfg.get('feed', {}).get('title')}')")

  vault = secret_store(cfg)
  print(f"Secrets ({vault.describe()})")
  if isinstance(vault, OnePassword) and not shutil.which("op"):
    report(False, "op (1Password CLI)", OP_SETUP)
    sys.exit(1)
  found = {}
  for name in SECRET_NAMES:
    found[name] = vault.get(name)
    hint = secret_hint(vault, name)
    if vault.error:
      hint += f" ({vault.error})"
      vault.error = None
    report(found[name] is not None, name, hint)

  token = found["feed_token"]
  if not all(found.values()):
    sys.exit(1)
  print("Bucket")
  try:
    state = load_state(Store(cfg), token)
    report(True, f"R2 read access ({len(state['episodes'])} episodes)")
  except Exception as err:
    report(False, "R2 read access", describe_error(err))
  status = public_status(public_url(cfg, feed_keys(token)[1]))
  report(status == 200, "feed publicly readable",
         f"HTTP {status or 'error'}; enable public access on the bucket or fix public_url")
  sys.exit(0 if ok else 1)


def cmd_add(args):
  episode_path = Path(args.episode).expanduser()
  ep = json.loads(episode_path.read_text())
  mp3 = Path(ep["mp3"])
  if not mp3.is_file():
    fail(f"MP3 not found: {mp3}")

  cfg = load_config()
  store = Store(cfg)
  token = require_secret(store.secrets, "feed_token")
  state = load_state(store, token)

  if ep.get("source_id") and not args.force:
    dupe = next((e for e in state["episodes"] if e.get("source_id") == ep["source_id"]), None)
    if dupe:
      fail(f"already in the feed as '{dupe['title']}' (id {dupe['id']}); use --force to add it again")

  episode_id = secrets.token_urlsafe(12)
  record = {
    "id": episode_id,
    "kind": ep.get("kind", "video"),
    "source_id": ep.get("source_id"),
    "title": ep["title"],
    "author": ep.get("author"),
    "link": ep.get("link"),
    "original_date": ep.get("original_date"),
    "duration": int(ep["duration_seconds"]),
    "pub_date": format_datetime(datetime.now(timezone.utc)),
    "notes_html": show_notes(ep),
    "mp3_key": f"episodes/{episode_id}.mp3",
    "length": mp3.stat().st_size,
    "image_key": None,
  }
  print("Uploading audio…", file=sys.stderr)
  store.put_file(record["mp3_key"], mp3, "audio/mpeg", "public, max-age=31536000, immutable")
  cover = Path(ep["cover"]) if ep.get("cover") else None
  if cover and cover.is_file():
    record["image_key"] = f"episodes/{episode_id}.jpg"
    store.put_file(record["image_key"], cover, "image/jpeg", "public, max-age=31536000, immutable")

  state["episodes"].insert(0, record)
  write_feed(store, cfg, token, state)
  print(f"Added '{record['title']}' ({record['length'] / 1_000_000:.1f} MB, {fmt_ts(record['duration'])}). "
        f"The feed now has {len(state['episodes'])} episodes; podcast apps pick it up on their next refresh.")

  # Only ever delete the episode scripts' own work folders.
  workdir = mp3.parent.resolve()
  if args.cleanup and CACHE.resolve() in workdir.parents:
    shutil.rmtree(workdir)
    print(f"Removed work folder {workdir}")


def cmd_list(args):
  cfg = load_config()
  store = Store(cfg)
  state = load_state(store, require_secret(store.secrets, "feed_token"))
  if not state["episodes"]:
    print("The feed is empty.")
    return
  for ep in state["episodes"]:
    added = datetime.strptime(ep["pub_date"], "%a, %d %b %Y %H:%M:%S %z").strftime("%Y-%m-%d")
    print(f"{ep['id']}  {added}  {ep.get('kind', 'video'):7}  {fmt_ts(ep['duration']):>8}  {ep['title']}")


def cmd_remove(args):
  cfg = load_config()
  store = Store(cfg)
  token = require_secret(store.secrets, "feed_token")
  state = load_state(store, token)
  ep = next((e for e in state["episodes"] if args.id in (e["id"], e.get("source_id"))), None)
  if not ep:
    fail(f"no episode with id '{args.id}'; see `publish.py list`")
  state["episodes"].remove(ep)
  write_feed(store, cfg, token, state)
  store.delete(ep["mp3_key"], ep.get("image_key"))
  print(f"Removed '{ep['title']}'.")


def cmd_feed_url(args):
  cfg = load_config()
  url = public_url(cfg, feed_keys(require_secret(secret_store(cfg), "feed_token"))[1])
  subprocess.run(["pbcopy"], input=url, text=True, check=True)
  print("Copied the feed URL to the clipboard (not shown here). Paste it into your podcast app's "
        "'add by URL' option.")


def cmd_rotate(args):
  cfg = load_config()
  store = Store(cfg)
  old = require_secret(store.secrets, "feed_token")
  state = load_state(store, old)
  new = secrets.token_urlsafe(24)
  # Write the new feed before forgetting the old one, so nothing is lost if
  # a step fails part-way.
  write_feed(store, cfg, new, state)
  store.secrets.set("feed_token", new)
  store.delete(*feed_keys(old))
  print("Moved the feed to a new secret URL; the old one no longer works. "
        "Run `publish.py feed-url` and re-subscribe in your podcast app.")


def describe_error(err):
  """Turn R2/network errors into a hint, without echoing credentials."""
  from botocore.exceptions import BotoCoreError, ClientError
  if isinstance(err, ClientError):
    code = err.response["Error"].get("Code", "error")
    hints = {
      "AccessDenied": "the API token needs Object Read & Write on this bucket",
      "NoSuchBucket": "check the bucket name in the settings",
      "InvalidAccessKeyId": "the stored r2_access_key_id is wrong",
      "SignatureDoesNotMatch": "the stored r2_secret_access_key is wrong",
    }
    return f"{code}: {hints.get(code, 'R2 request failed')}"
  if isinstance(err, BotoCoreError):
    return f"couldn't reach R2 ({type(err).__name__}); check the account ID and your connection"
  return str(err)


def main():
  p = argparse.ArgumentParser(description="Manage a private glanceable podcast feed on Cloudflare R2.")
  sub = p.add_subparsers(dest="command", required=True)

  init = sub.add_parser("init", help="create or update the feed settings")
  init.add_argument("--account-id", help="Cloudflare account ID")
  init.add_argument("--bucket", help="R2 bucket name")
  init.add_argument("--public-url", help="bucket's public URL, e.g. https://pub-xxxx.r2.dev")
  init.add_argument("--title", help="feed title (default 'Glanceable podcast')")
  init.add_argument("--description", help="feed description")
  init.add_argument("--author", help="feed author")
  init.add_argument("--language", help="feed language (default 'en')")
  init.add_argument("--cover", help="square JPEG/PNG artwork for the feed")
  init.add_argument("--secrets", choices=("keychain", "1password"),
                    help="where the secrets live (default keychain)")
  init.add_argument("--op-vault", help="1Password vault holding the item")
  init.add_argument("--op-item", help="1Password item title (default 'Glanceable podcast')")
  init.add_argument("--op-account", help="1Password account, e.g. example.1password.com "
                    "(needed if the CLI is signed into more than one)")
  init.set_defaults(func=cmd_init)

  sub.add_parser("check", help="verify the setup").set_defaults(func=cmd_check)

  add = sub.add_parser("add", help="publish an episode made by video_episode.py or article_episode.py")
  add.add_argument("episode", help="path to episode.json")
  add.add_argument("--force", action="store_true", help="add even if this video is already in the feed")
  add.add_argument("--cleanup", action="store_true", help="delete the work folder after uploading")
  add.set_defaults(func=cmd_add)

  sub.add_parser("list", help="list episodes").set_defaults(func=cmd_list)

  remove = sub.add_parser("remove", help="remove an episode")
  remove.add_argument("id", help="episode id (from `list`) or YouTube video id")
  remove.set_defaults(func=cmd_remove)

  sub.add_parser("feed-url", help="copy the feed URL to the clipboard").set_defaults(func=cmd_feed_url)
  sub.add_parser("rotate", help="move the feed to a new secret URL").set_defaults(func=cmd_rotate)

  args = p.parse_args()
  try:
    args.func(args)
  except SystemExit:
    raise
  except Exception as err:
    fail(describe_error(err))


if __name__ == "__main__":
  main()
