Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from bundle
Connect to Miro boards, inspect items, download images, and create boards or diagrams programmatically.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/miro_api.py
1#!/usr/bin/env python32"""Miro Board API helper for the marketplace skill bundle."""34import argparse5import json6import re7import sys8import urllib.request9import urllib.parse10import urllib.error11from datetime import datetime, timedelta, timezone12from http.server import HTTPServer, BaseHTTPRequestHandler13from pathlib import Path1415TOKEN_PATH = Path.home() / ".miro-token"16API_BASE = "https://api.miro.com/v2"17TOKEN_REFRESH_MARGIN_SECONDS = 300181920def load_token_state() -> dict | None:21if not TOKEN_PATH.exists():22return None2324raw = TOKEN_PATH.read_text().strip()25if not raw:26return None2728try:29data = json.loads(raw)30except json.JSONDecodeError:31return {"access_token": raw}3233if isinstance(data, dict):34return data3536return {"access_token": raw}373839def save_token_state(token_state: dict):40payload = dict(token_state)41payload.setdefault("saved_at", datetime.now(timezone.utc).isoformat())42TOKEN_PATH.write_text(json.dumps(payload, indent=2) + "\n")43TOKEN_PATH.chmod(0o600)44print(f"Token saved to {TOKEN_PATH}")454647def api_get(path: str, token: str) -> dict:48url = f"{API_BASE}{path}"49req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})50try:51with urllib.request.urlopen(req) as resp:52return json.loads(resp.read())53except urllib.error.HTTPError as e:54body = e.read().decode(errors="replace")55raise RuntimeError(f"Miro API request failed ({e.code}): {body}") from e56except urllib.error.URLError as e:57raise RuntimeError(f"Could not reach Miro API: {e.reason}") from e585960def exchange_oauth_token(payload: dict, action: str) -> dict:61data = urllib.parse.urlencode(payload).encode()62req = urllib.request.Request(63"https://api.miro.com/v1/oauth/token",64data=data,65headers={"Content-Type": "application/x-www-form-urlencoded"},66)6768try:69with urllib.request.urlopen(req) as resp:70return json.loads(resp.read())71except urllib.error.HTTPError as e:72body = e.read().decode(errors="replace")73raise RuntimeError(f"{action} failed ({e.code}): {body}") from e74except urllib.error.URLError as e:75raise RuntimeError(f"{action} failed: {e.reason}") from e767778def token_needs_refresh(token_state: dict) -> bool:79expires_in = token_state.get("expires_in")80saved_at = token_state.get("saved_at")81if not expires_in or not saved_at:82return False8384try:85saved_at_dt = datetime.fromisoformat(saved_at)86expires_in_seconds = int(expires_in)87except (TypeError, ValueError):88return False8990if saved_at_dt.tzinfo is None:91saved_at_dt = saved_at_dt.replace(tzinfo=timezone.utc)9293refresh_at = saved_at_dt + timedelta(94seconds=max(0, expires_in_seconds - TOKEN_REFRESH_MARGIN_SECONDS)95)96return datetime.now(timezone.utc) >= refresh_at979899def refresh_access_token(token_state: dict) -> dict:100refresh_token = token_state.get("refresh_token")101client_id = token_state.get("client_id")102client_secret = token_state.get("client_secret")103if not refresh_token or not client_id or not client_secret:104raise RuntimeError("Stored token expired and cannot be refreshed. Run 'oauth' again.")105106refreshed = exchange_oauth_token(107{108"grant_type": "refresh_token",109"client_id": client_id,110"client_secret": client_secret,111"refresh_token": refresh_token,112},113"Token refresh",114)115refreshed["client_id"] = client_id116refreshed["client_secret"] = client_secret117refreshed.setdefault("refresh_token", refresh_token)118refreshed["saved_at"] = datetime.now(timezone.utc).isoformat()119save_token_state(refreshed)120return refreshed121122123def get_token_state() -> dict | None:124token_state = load_token_state()125if not token_state or not token_state.get("access_token"):126return None127128if token_needs_refresh(token_state):129print("Access token expired or expiring soon. Refreshing...")130token_state = refresh_access_token(token_state)131132return token_state133134135def download_url(url: str, headers: dict | None = None) -> tuple[bytes, str, str]:136req = urllib.request.Request(url, headers=headers or {})137try:138with urllib.request.urlopen(req) as resp:139return resp.read(), resp.geturl(), resp.headers.get_content_type()140except urllib.error.HTTPError as e:141body = e.read().decode(errors="replace")142raise RuntimeError(f"Download failed ({e.code}): {body}") from e143except urllib.error.URLError as e:144raise RuntimeError(f"Download failed: {e.reason}") from e145146147def detect_extension(content_type: str, url: str) -> str:148content_type = (content_type or "").split(";", 1)[0].strip().lower()149if content_type == "image/jpeg":150return ".jpg"151if content_type == "image/png":152return ".png"153if content_type == "image/gif":154return ".gif"155if content_type == "image/webp":156return ".webp"157if content_type == "image/svg+xml":158return ".svg"159160suffix = Path(urllib.parse.urlparse(url).path).suffix.lower()161if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}:162return suffix163164return ".png"165166167def extract_board_id(board_url: str) -> str:168"""Extract board ID from Miro board URL."""169# https://miro.com/app/board/uXjVKiV7tuU=/170m = re.search(r"/board/([^/]+)/?", board_url)171if m:172return m.group(1)173return board_url # assume it's already a board ID174175176def api_post(path: str, token: str, payload: dict) -> dict:177url = f"{API_BASE}{path}"178body = json.dumps(payload).encode()179req = urllib.request.Request(180url, data=body,181headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}182)183try:184with urllib.request.urlopen(req) as resp:185return json.loads(resp.read())186except urllib.error.HTTPError as e:187body_text = e.read().decode(errors="replace")188raise RuntimeError(f"Miro API POST failed ({e.code}): {body_text}") from e189except urllib.error.URLError as e:190raise RuntimeError(f"Could not reach Miro API: {e.reason}") from e191192193def strip_html(html: str) -> str:194return re.sub(r"<[^>]+>", "", html).strip()195196197# --- Commands ---198199200def cmd_test_token(args):201token_state = get_token_state()202if not token_state:203print("ERROR: No token found. Run 'oauth' first.")204sys.exit(1)205206token = token_state["access_token"]207try:208# Try listing boards to validate209req = urllib.request.Request(210f"{API_BASE}/boards?limit=1",211headers={"Authorization": f"Bearer {token}"},212)213with urllib.request.urlopen(req) as resp:214data = json.loads(resp.read())215print(f"OK: Token valid. You have access to {data.get('total', '?')} boards.")216except urllib.error.HTTPError as e:217print(f"ERROR: Token invalid ({e.code}). Run 'oauth' to refresh.")218sys.exit(1)219except urllib.error.URLError as e:220print(f"ERROR: Could not reach Miro API: {e.reason}")221sys.exit(1)222223224def cmd_oauth(args):225client_id = args.client_id226client_secret = args.client_secret227redirect_uri = "http://localhost:9876/callback"228captured_code = {}229230class Handler(BaseHTTPRequestHandler):231def do_GET(self):232from urllib.parse import urlparse, parse_qs233234parsed = urlparse(self.path)235if parsed.path == "/callback":236params = parse_qs(parsed.query)237code = params.get("code", [None])[0]238if code:239captured_code["code"] = code240self.send_response(200)241self.send_header("Content-Type", "text/html")242self.end_headers()243self.wfile.write(244b"<h1>Done! You can close this tab.</h1>"245b"<p>Return to Claude Code.</p>"246)247else:248self.send_response(400)249self.end_headers()250self.wfile.write(b"No authorization code received.")251else:252self.send_response(404)253self.end_headers()254255def log_message(self, format, *args):256pass257258# Start server259server = HTTPServer(("localhost", 9876), Handler)260261auth_url = (262f"https://miro.com/oauth/authorize"263f"?response_type=code"264f"&client_id={client_id}"265f"&redirect_uri={urllib.parse.quote(redirect_uri)}"266)267268print(f"\nOpen this URL in your browser to authorize:\n\n{auth_url}\n")269print("Waiting for authorization...")270271# Handle one request (the callback)272server.handle_request()273274if "code" not in captured_code:275print("ERROR: No authorization code received.")276sys.exit(1)277278code = captured_code["code"]279print(f"Authorization code received. Exchanging for token...")280281token_data = exchange_oauth_token(282{283"grant_type": "authorization_code",284"client_id": client_id,285"client_secret": client_secret,286"code": code,287"redirect_uri": redirect_uri,288},289"Token exchange",290)291292access_token = token_data.get("access_token")293if not access_token:294print(f"ERROR: No access_token in response: {json.dumps(token_data)}")295sys.exit(1)296297token_data["client_id"] = client_id298token_data["client_secret"] = client_secret299token_data["saved_at"] = datetime.now(timezone.utc).isoformat()300save_token_state(token_data)301print(f"Authenticated as user {token_data.get('user_id', '?')}")302print(f"Scope: {token_data.get('scope', '?')}")303if token_data.get("refresh_token"):304print("Refresh token saved. Future runs will auto-refresh the access token.")305306307def cmd_board_info(args):308token_state = get_token_state()309if not token_state:310print("ERROR: No token. Run 'oauth' first.")311sys.exit(1)312313token = token_state["access_token"]314board_id = extract_board_id(args.board_url)315data = api_get(f"/boards/{board_id}", token)316317print(f"Board: {data.get('name', 'N/A')}")318print(f"Description: {data.get('description', 'N/A')}")319print(f"Created: {data.get('createdAt', 'N/A')}")320print(f"Modified: {data.get('modifiedAt', 'N/A')}")321322323def cmd_list_items(args):324token_state = get_token_state()325if not token_state:326print("ERROR: No token. Run 'oauth' first.")327sys.exit(1)328329token = token_state["access_token"]330board_id = extract_board_id(args.board_url)331332all_items = []333cursor = None334while True:335path = f"/boards/{board_id}/items?limit=50"336if cursor:337path += f"&cursor={cursor}"338data = api_get(path, token)339all_items.extend(data.get("data", []))340cursor = data.get("cursor")341if not cursor:342break343344print(f"Total items: {len(all_items)}\n")345346# Group by type347types = {}348for item in all_items:349t = item.get("type", "?")350types[t] = types.get(t, 0) + 1351print("Item types:")352for t, count in sorted(types.items()):353print(f" {t}: {count}")354print()355356# Print text items (section headers)357texts = [i for i in all_items if i["type"] == "text"]358if texts:359print("TEXT ITEMS (section headers):")360for t in sorted(texts, key=lambda x: (x["position"]["x"], x["position"]["y"])):361content = strip_html(t.get("data", {}).get("content", ""))362pos = t["position"]363print(f" pos=({pos['x']:.0f},{pos['y']:.0f}) \"{content[:120]}\"")364print()365366# Print shapes with content367if args.verbose:368shapes = [i for i in all_items if i["type"] == "shape"]369if shapes:370print("SHAPES:")371for s in sorted(372shapes, key=lambda x: (x["position"]["x"], x["position"]["y"])373):374content = strip_html(s.get("data", {}).get("content", ""))375if content:376pos = s["position"]377geo = s.get("geometry", {})378print(379f" pos=({pos['x']:.0f},{pos['y']:.0f}) "380f"size=({geo.get('width', 0):.0f}x{geo.get('height', 0):.0f}) "381f"\"{content[:120]}\""382)383print()384385# Print image positions386images = [i for i in all_items if i["type"] == "image"]387if images:388print(f"IMAGES ({len(images)}):")389for img in sorted(390images, key=lambda x: (x["position"]["x"], x["position"]["y"])391):392pos = img["position"]393geo = img.get("geometry", {})394print(395f" id={img['id']} "396f"pos=({pos['x']:.0f},{pos['y']:.0f}) "397f"size=({geo.get('width', 0):.0f}x{geo.get('height', 0):.0f})"398)399400401def cmd_download_images(args):402token_state = get_token_state()403if not token_state:404print("ERROR: No token. Run 'oauth' first.")405sys.exit(1)406407token = token_state["access_token"]408board_id = extract_board_id(args.board_url)409output_dir = Path(args.output_dir)410output_dir.mkdir(parents=True, exist_ok=True)411fmt = args.format # "original" or "preview"412413# Parse region if provided414region = None415if args.region:416parts = [float(x) for x in args.region.split(",")]417if len(parts) == 4:418region = {419"x_min": parts[0],420"y_min": parts[1],421"x_max": parts[2],422"y_max": parts[3],423}424425# Fetch all items426all_items = []427cursor = None428while True:429path = f"/boards/{board_id}/items?limit=50"430if cursor:431path += f"&cursor={cursor}"432data = api_get(path, token)433all_items.extend(data.get("data", []))434cursor = data.get("cursor")435if not cursor:436break437438images = [i for i in all_items if i["type"] == "image"]439440# Filter by region441if region:442images = [443i444for i in images445if region["x_min"] <= i["position"]["x"] <= region["x_max"]446and region["y_min"] <= i["position"]["y"] <= region["y_max"]447]448449print(f"Found {len(images)} images to download")450451for idx, img in enumerate(452sorted(images, key=lambda x: (x["position"]["x"], x["position"]["y"]))453):454pos = img["position"]455geo = img.get("geometry", {})456img_id = img["id"]457458# Get item detail for image URL459item_data = api_get(f"/boards/{board_id}/items/{img_id}", token)460image_url = item_data.get("data", {}).get("imageUrl", "")461if not image_url:462print(f" [{idx}] No imageUrl — skipping")463continue464465# Request original or preview format466if fmt == "original":467image_url = image_url.replace("format=preview", "format=original")468469# Get signed URL470content, final_url, content_type = download_url(471image_url, headers={"Authorization": f"Bearer {token}"}472)473474# Some signed image endpoints return JSON with a temporary redirect URL.475if content_type == "application/json":476redirect_data = json.loads(content)477actual_url = redirect_data.get("url", "")478if actual_url:479content, final_url, content_type = download_url(actual_url)480481extension = detect_extension(content_type, final_url)482fname = output_dir / f"img_{idx:03d}_{pos['x']:.0f}_{pos['y']:.0f}{extension}"483fname.write_bytes(content)484fsize = len(content)485print(486