Claude Skill

maps

Geocode, POIs, routes, timezones via OpenStreetMap/OSRM.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download invergent-ai-surogates-skills_productivity_maps-f423b85.zip · 14 KB
Part of invergent-ai/surogates — 21 skills

Install

skills CLI npx skills add https://github.com/invergent-ai/surogates/tree/master/skills/productivity/maps
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install invergent-ai-surogates@llmmart
Git git clone https://github.com/invergent-ai/surogates.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole invergent-ai/surogates collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Maps Skill

Location intelligence using free, open data sources. 8 commands, 44 POI categories, zero dependencies (Python stdlib only), no API key required.

Data sources: OpenStreetMap/Nominatim, Overpass API, OSRM, TimeAPI.io.

This skill supersedes the old find-nearby skill — all of find-nearby's functionality is covered by the nearby command below, with the same --near "<place>" shortcut and multi-category support.

When to Use

  • User sends a Telegram location pin (latitude/longitude in the message) → nearby
  • User wants coordinates for a place name → search
  • User has coordinates and wants the address → reverse
  • User asks for nearby restaurants, hospitals, pharmacies, hotels, etc. → nearby
  • User wants driving/walking/cycling distance or travel time → distance
  • User wants turn-by-turn directions between two places → directions
  • User wants timezone information for a location → timezone
  • User wants to search for POIs within a geographic area → area + bbox

Prerequisites

Python 3.8+ (stdlib only — no pip installs needed).

Script path: ~/.surogate/skills/maps/scripts/maps_client.py

Commands

MAPS=~/.surogate/skills/maps/scripts/maps_client.py

search — Geocode a place name

python3 $MAPS search "Eiffel Tower"
python3 $MAPS search "1600 Pennsylvania Ave, Washington DC"

Returns: lat, lon, display name, type, bounding box, importance score.

reverse — Coordinates to address

python3 $MAPS reverse 48.8584 2.2945

Returns: full address breakdown (street, city, state, country, postcode).

nearby — Find places by category

# By coordinates (from a Telegram location pin, for example)
python3 $MAPS nearby 48.8584 2.2945 restaurant --limit 10
python3 $MAPS nearby 40.7128 -74.0060 hospital --radius 2000

# By address / city / zip / landmark — --near auto-geocodes
python3 $MAPS nearby --near "Times Square, New York" --category cafe
python3 $MAPS nearby --near "90210" --category pharmacy

# Multiple categories merged into one query
python3 $MAPS nearby --near "downtown austin" --category restaurant --category bar --limit 10

46 categories: restaurant, cafe, bar, hospital, pharmacy, hotel, guest_house, camp_site, supermarket, atm, gas_station, parking, museum, park, school, university, bank, police, fire_station, library, airport, train_station, bus_stop, church, mosque, synagogue, dentist, doctor, cinema, theatre, gym, swimming_pool, post_office, convenience_store, bakery, bookshop, laundry, car_wash, car_rental, bicycle_rental, taxi, veterinary, zoo, playground, stadium, nightclub.

Each result includes: name, address, lat/lon, distance_m, maps_url (clickable Google Maps link), directions_url (Google Maps directions from the search point), and promoted tags when available — cuisine, hours (opening_hours), phone, website.

distance — Travel distance and time

python3 $MAPS distance "Paris" --to "Lyon"
python3 $MAPS distance "New York" --to "Boston" --mode driving
python3 $MAPS distance "Big Ben" --to "Tower Bridge" --mode walking

Modes: driving (default), walking, cycling. Returns road distance, duration, and straight-line distance for comparison.

directions — Turn-by-turn navigation

python3 $MAPS directions "Eiffel Tower" --to "Louvre Museum" --mode walking
python3 $MAPS directions "JFK Airport" --to "Times Square" --mode driving

Returns numbered steps with instruction, distance, duration, road name, and maneuver type (turn, depart, arrive, etc.).

timezone — Timezone for coordinates

python3 $MAPS timezone 48.8584 2.2945
python3 $MAPS timezone 35.6762 139.6503

Returns timezone name, UTC offset, and current local time.

area — Bounding box and area for a place

python3 $MAPS area "Manhattan, New York"
python3 $MAPS area "London"

Returns bounding box coordinates, width/height in km, and approximate area. Useful as input for the bbox command.

bbox — Search within a bounding box

python3 $MAPS bbox 40.75 -74.00 40.77 -73.98 restaurant --limit 20

Finds POIs within a geographic rectangle. Use area first to get the bounding box coordinates for a named place.

Working With Telegram Location Pins

When a user sends a location pin, the message contains latitude: and longitude: fields. Extract those and pass them straight to nearby:

# User sent a pin at 36.17, -115.14 and asked "find cafes nearby"
python3 $MAPS nearby 36.17 -115.14 cafe --radius 1500

Present results as a numbered list with names, distances, and the maps_url field so the user gets a tap-to-open link in chat. For "open now?" questions, check the hours field; if missing or unclear, verify with web_search since OSM hours are community-maintained and not always current.

Workflow Examples

"Find Italian restaurants near the Colosseum":

  1. nearby --near "Colosseum Rome" --category restaurant --radius 500 — one command, auto-geocoded

"What's near this location pin they sent?":

  1. Extract lat/lon from the Telegram message
  2. nearby LAT LON cafe --radius 1500

"How do I walk from hotel to conference center?":

  1. directions "Hotel Name" --to "Conference Center" --mode walking

"What restaurants are in downtown Seattle?":

  1. area "Downtown Seattle" → get bounding box
  2. bbox S W N E restaurant --limit 30

Pitfalls

  • Nominatim ToS: max 1 req/s (handled automatically by the script)
  • nearby requires lat/lon OR --near "<address>" — one of the two is needed
  • OSRM routing coverage is best for Europe and North America
  • Overpass API can be slow during peak hours; the script automatically falls back between mirrors (overpass-api.de → overpass.kumi.systems)
  • distance and directions use --to flag for the destination (not positional)
  • If a zip code alone gives ambiguous results globally, include country/state

Verification

python3 ~/.surogate/skills/maps/scripts/maps_client.py search "Statue of Liberty"
# Should return lat ~40.689, lon ~-74.044

python3 ~/.surogate/skills/maps/scripts/maps_client.py nearby --near "Times Square" --category restaurant --limit 3
# Should return a list of restaurants within ~500m of Times Square
Files (surogates)
  • scripts
    • maps_client.py 45.6 KB
      #!/usr/bin/env python3
      """
      maps_client.py - CLI tool for maps, geocoding, routing, POI search, and more.
      Uses only Python stdlib. Data from OpenStreetMap/Nominatim, Overpass API, OSRM,
      and TimeAPI.io.
      
      Commands:
        search     - Geocode a place name to coordinates
        reverse    - Reverse geocode coordinates to an address
        nearby     - Find nearby POIs by category
        distance   - Road distance and travel time between two places
        directions - Turn-by-turn directions between two places
        timezone   - Timezone info for coordinates
        bbox       - Find POIs within a bounding box
        area       - Get bounding box and area info for a named place
      """
      
      import argparse
      import json
      import math
      import os
      import sys
      import time
      import urllib.error
      import urllib.parse
      import urllib.request
      
      # ---------------------------------------------------------------------------
      # Constants
      # ---------------------------------------------------------------------------
      
      USER_AGENT = "SurogateAgent/1.0 (contact: maintainers@invergent.ai)"
      DATA_SOURCE = "OpenStreetMap/Nominatim"
      
      NOMINATIM_SEARCH  = "https://nominatim.openstreetmap.org/search"
      NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"
      # Public Overpass endpoints. We try them in order so a single server
      # outage doesn't break the skill — kumi.systems is a well-known mirror.
      OVERPASS_URLS = [
          "https://overpass-api.de/api/interpreter",
          "https://overpass.kumi.systems/api/interpreter",
      ]
      # Backward-compat alias for any caller that imports OVERPASS_API directly.
      OVERPASS_API      = OVERPASS_URLS[0]
      OSRM_BASE         = "https://router.project-osrm.org/route/v1"
      TIMEAPI_BASE      = "https://timeapi.io/api/timezone/coordinate"
      
      # Seconds to sleep between Nominatim requests (ToS requirement)
      NOMINATIM_RATE_LIMIT = 1.0
      
      # Maximum retries for HTTP errors
      MAX_RETRIES = 3
      RETRY_DELAY = 2.0  # seconds
      
      # Category -> (OSM tag key, OSM tag value)
      CATEGORY_TAGS = {
          # Food & Drink
          "restaurant":        ("amenity", "restaurant"),
          "cafe":              ("amenity", "cafe"),
          "bar":               ("amenity", "bar"),
          # bakery is tagged as shop=bakery in the OSM wiki, but some mappers use
          # amenity=bakery. Search both so small indie bakeries aren't missed.
          "bakery":            [("shop", "bakery"), ("amenity", "bakery")],
          "convenience_store": ("shop",    "convenience"),
          # Health
          "hospital":          ("amenity", "hospital"),
          "pharmacy":          ("amenity", "pharmacy"),
          "dentist":           ("amenity", "dentist"),
          "doctor":            ("amenity", "doctors"),
          "veterinary":        ("amenity", "veterinary"),
          # Accommodation
          "hotel":             ("tourism", "hotel"),
          "guest_house":       ("tourism", "guest_house"),
          "camp_site":         ("tourism", "camp_site"),
          # Shopping & Services
          "supermarket":       ("shop",    "supermarket"),
          "bookshop":          ("shop",    "books"),
          "laundry":           ("shop",    "laundry"),
          # Finance
          "atm":               ("amenity", "atm"),
          "bank":              ("amenity", "bank"),
          # Transport
          "gas_station":       ("amenity", "fuel"),
          "parking":           ("amenity", "parking"),
          "airport":           ("aeroway", "aerodrome"),
          "train_station":     ("railway", "station"),
          "bus_stop":          ("highway", "bus_stop"),
          "taxi":              ("amenity", "taxi"),
          "car_wash":          ("amenity", "car_wash"),
          "car_rental":        ("amenity", "car_rental"),
          "bicycle_rental":    ("amenity", "bicycle_rental"),
          # Culture & Entertainment
          "museum":            ("tourism", "museum"),
          "cinema":            ("amenity", "cinema"),
          "theatre":           ("amenity", "theatre"),
          "nightclub":         ("amenity", "nightclub"),
          "zoo":               ("tourism", "zoo"),
          # Education
          "school":            ("amenity", "school"),
          "university":        ("amenity", "university"),
          "library":           ("amenity", "library"),
          # Public Services
          "police":            ("amenity", "police"),
          "fire_station":      ("amenity", "fire_station"),
          "post_office":       ("amenity", "post_office"),
          # Religion
          "church":            ("amenity", "place_of_worship"),  # refined by religion tag
          "mosque":            ("amenity", "place_of_worship"),
          "synagogue":         ("amenity", "place_of_worship"),
          # Recreation
          "park":              ("leisure", "park"),
          "gym":               ("leisure", "fitness_centre"),
          "swimming_pool":     ("leisure", "swimming_pool"),
          "playground":        ("leisure", "playground"),
          "stadium":           ("leisure", "stadium"),
      }
      
      # Religion-specific overrides for place_of_worship categories
      RELIGION_FILTER = {
          "church":    "christian",
          "mosque":    "muslim",
          "synagogue": "jewish",
      }
      
      VALID_CATEGORIES = sorted(CATEGORY_TAGS.keys())
      
      
      def _tags_for(category):
          """Return the CATEGORY_TAGS entry as a list of (key, value) pairs.
      
          Most categories map to a single (tag_key, tag_val) tuple, but some
          (e.g. ``bakery``) are tagged under more than one OSM key and are
          represented as a list of tuples. Normalise both forms to a list.
          """
          entry = CATEGORY_TAGS[category]
          if isinstance(entry, list):
              return list(entry)
          return [entry]
      
      OSRM_PROFILES = {
          "driving": "driving",
          "walking": "foot",
          "cycling": "bike",
      }
      
      # ---------------------------------------------------------------------------
      # Output helpers
      # ---------------------------------------------------------------------------
      
      def print_json(data):
          """Print data as pretty-printed JSON to stdout."""
          print(json.dumps(data, indent=2, ensure_ascii=False))
      
      
      def error_exit(message, code=1):
          """Print an error result as JSON and exit."""
          print_json({"error": message, "status": "error"})
          sys.exit(code)
      
      
      # ---------------------------------------------------------------------------
      # HTTP helpers
      # ---------------------------------------------------------------------------
      
      def http_get(url, params=None, retries=MAX_RETRIES, silent=False):
          """
          Perform an HTTP GET request, returning parsed JSON.
          Adds the required User-Agent header. Retries on transient errors.
          If silent=True, raises RuntimeError instead of calling error_exit.
          """
          if params:
              url = url + "?" + urllib.parse.urlencode(params)
      
          req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
      
          last_error = None
          for attempt in range(1, retries + 1):
              try:
                  with urllib.request.urlopen(req, timeout=15) as resp:
                      raw = resp.read().decode("utf-8")
                      return json.loads(raw)
              except urllib.error.HTTPError as exc:
                  last_error = f"HTTP {exc.code}: {exc.reason} for {url}"
                  if exc.code in (429, 503, 502, 504):
                      time.sleep(RETRY_DELAY * attempt)
                  else:
                      if silent:
                          raise RuntimeError(last_error)
                      error_exit(last_error)
              except urllib.error.URLError as exc:
                  last_error = f"URL error: {exc.reason}"
                  time.sleep(RETRY_DELAY * attempt)
              except json.JSONDecodeError as exc:
                  last_error = f"JSON parse error: {exc}"
                  time.sleep(RETRY_DELAY * attempt)
      
          msg = f"Request failed after {retries} attempts. Last error: {last_error}"
          if silent:
              raise RuntimeError(msg)
          error_exit(msg)
      
      
      def http_get_text(url, params=None, retries=MAX_RETRIES, silent=False):
          """
          Like http_get but returns raw text instead of parsed JSON.
          Useful for APIs that may return non-JSON responses.
          """
          if params:
              url = url + "?" + urllib.parse.urlencode(params)
      
          req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
      
          last_error = None
          for attempt in range(1, retries + 1):
              try:
                  with urllib.request.urlopen(req, timeout=15) as resp:
                      return resp.read().decode("utf-8")
              except urllib.error.HTTPError as exc:
                  last_error = f"HTTP {exc.code}: {exc.reason} for {url}"
                  if exc.code in (429, 503, 502, 504):
                      time.sleep(RETRY_DELAY * attempt)
                  else:
                      if silent:
                          raise RuntimeError(last_error)
                      error_exit(last_error)
              except urllib.error.URLError as exc:
                  last_error = f"URL error: {exc.reason}"
                  time.sleep(RETRY_DELAY * attempt)
      
          msg = f"Request failed after {retries} attempts. Last error: {last_error}"
          if silent:
              raise RuntimeError(msg)
          error_exit(msg)
      
      
      def http_post(url, data_str, retries=MAX_RETRIES):
          """
          Perform an HTTP POST with a plain-text body (for Overpass QL).
          Returns parsed JSON.
          """
          encoded = data_str.encode("utf-8")
          req = urllib.request.Request(
              url,
              data=encoded,
              headers={
                  "User-Agent": USER_AGENT,
                  "Content-Type": "application/x-www-form-urlencoded",
              },
          )
      
          last_error = None
          for attempt in range(1, retries + 1):
              try:
                  with urllib.request.urlopen(req, timeout=30) as resp:
                      raw = resp.read().decode("utf-8")
                      return json.loads(raw)
              except urllib.error.HTTPError as exc:
                  last_error = f"HTTP {exc.code}: {exc.reason}"
                  if exc.code in (429, 503, 502, 504):
                      time.sleep(RETRY_DELAY * attempt)
                  else:
                      error_exit(last_error)
              except urllib.error.URLError as exc:
                  last_error = f"URL error: {exc.reason}"
                  time.sleep(RETRY_DELAY * attempt)
              except json.JSONDecodeError as exc:
                  last_error = f"JSON parse error: {exc}"
                  time.sleep(RETRY_DELAY * attempt)
      
          error_exit(f"POST failed after {retries} attempts. Last error: {last_error}")
      
      
      def overpass_query(query):
          """POST an Overpass QL query, trying each URL in OVERPASS_URLS in turn.
      
          A single public Overpass mirror can be rate-limited or down; trying the
          next mirror before giving up turns a flaky outage into a retry. Returns
          parsed JSON. Falls through to error_exit if every mirror fails.
          """
          post_data = "data=" + urllib.parse.quote(query)
          last_error = None
          for url in OVERPASS_URLS:
              try:
                  return http_post(url, post_data, retries=1)
              except SystemExit:
                  # error_exit inside http_post — keep trying the next mirror.
                  last_error = f"mirror {url} exhausted retries"
                  continue
              except Exception as exc:
                  last_error = f"{url}: {exc}"
                  continue
          error_exit(
              f"All Overpass mirrors failed. Last error: {last_error or 'unknown'}"
          )
      
      
      # ---------------------------------------------------------------------------
      # Geo math
      # ---------------------------------------------------------------------------
      
      def haversine_m(lat1, lon1, lat2, lon2):
          """Return distance in metres between two lat/lon points (Haversine)."""
          R = 6_371_000  # Earth mean radius in metres
          phi1 = math.radians(lat1)
          phi2 = math.radians(lat2)
          dphi = math.radians(lat2 - lat1)
          dlam = math.radians(lon2 - lon1)
          a = (math.sin(dphi / 2) ** 2
               + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2)
          return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
      
      
      # ---------------------------------------------------------------------------
      # Nominatim helpers
      # ---------------------------------------------------------------------------
      
      def nominatim_search(query, limit=5):
          """Geocode a free-text query. Returns list of result dicts."""
          params = {
              "q":              query,
              "format":         "json",
              "limit":          limit,
              "addressdetails": 1,
          }
          time.sleep(NOMINATIM_RATE_LIMIT)
          return http_get(NOMINATIM_SEARCH, params=params)
      
      
      def nominatim_reverse(lat, lon):
          """Reverse geocode lat/lon. Returns a single result dict."""
          params = {
              "lat":            lat,
              "lon":            lon,
              "format":         "json",
              "addressdetails": 1,
          }
          time.sleep(NOMINATIM_RATE_LIMIT)
          return http_get(NOMINATIM_REVERSE, params=params)
      
      
      def geocode_single(query):
          """
          Geocode a query and return (lat, lon, display_name).
          Exits with error if nothing found.
          """
          results = nominatim_search(query, limit=1)
          if not results:
              error_exit(f"Could not geocode: {query}")
          r = results[0]
          return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
      
      
      # ---------------------------------------------------------------------------
      # Overpass helpers
      # ---------------------------------------------------------------------------
      
      def build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit,
                                religion=None, tag_pairs=None):
          """Build an Overpass QL query for nearby POIs around a point.
      
          If ``tag_pairs`` is provided, the query unions across every
          ``(key, value)`` pair (used for categories like ``bakery`` that are
          tagged under more than one OSM key). Otherwise falls back to the
          single ``tag_key``/``tag_val`` pair for back-compat.
          """
          pairs = tag_pairs if tag_pairs else [(tag_key, tag_val)]
          religion_filter = ""
          if religion:
              religion_filter = f'["religion"="{religion}"]'
          body_lines = []
          for k, v in pairs:
              body_lines.append(
                  f'  node["{k}"="{v}"]{religion_filter}'
                  f'(around:{radius},{lat},{lon});'
              )
              body_lines.append(
                  f'  way["{k}"="{v}"]{religion_filter}'
                  f'(around:{radius},{lat},{lon});'
              )
          body = "\n".join(body_lines)
          return (
              f'[out:json][timeout:25];\n'
              f'(\n'
              f'{body}\n'
              f');\n'
              f'out center {limit};\n'
          )
      
      
      def build_overpass_bbox(tag_key, tag_val, south, west, north, east, limit,
                              religion=None, tag_pairs=None):
          """Build an Overpass QL query for POIs within a bounding box.
      
          See ``build_overpass_nearby`` for ``tag_pairs`` semantics.
          """
          pairs = tag_pairs if tag_pairs else [(tag_key, tag_val)]
          religion_filter = ""
          if religion:
              religion_filter = f'["religion"="{religion}"]'
          body_lines = []
          for k, v in pairs:
              body_lines.append(
                  f'  node["{k}"="{v}"]{religion_filter}'
                  f'({south},{west},{north},{east});'
              )
              body_lines.append(
                  f'  way["{k}"="{v}"]{religion_filter}'
                  f'({south},{west},{north},{east});'
              )
          body = "\n".join(body_lines)
          return (
              f'[out:json][timeout:25];\n'
              f'(\n'
              f'{body}\n'
              f');\n'
              f'out center {limit};\n'
          )
      
      
      def parse_overpass_elements(elements, ref_lat=None, ref_lon=None):
          """
          Parse Overpass elements into a clean list of POI dicts.
          If ref_lat/ref_lon are provided, computes distance and sorts by it.
          """
          places = []
          for el in elements:
              # Ways have a "center" sub-dict; nodes have lat/lon directly
              if el["type"] == "way":
                  center = el.get("center", {})
                  el_lat = center.get("lat")
                  el_lon = center.get("lon")
              else:
                  el_lat = el.get("lat")
                  el_lon = el.get("lon")
      
              if el_lat is None or el_lon is None:
                  continue
      
              tags = el.get("tags", {})
              name = tags.get("name") or tags.get("name:en") or ""
      
              # Build a short address from available tags
              addr_parts = []
              for part_key in ("addr:housenumber", "addr:street", "addr:city"):
                  val = tags.get(part_key)
                  if val:
                      addr_parts.append(val)
              address_str = ", ".join(addr_parts) if addr_parts else ""
      
              place = {
                  "name":     name,
                  "address":  address_str,
                  "lat":      el_lat,
                  "lon":      el_lon,
                  "osm_type": el.get("type", ""),
                  "osm_id":   el.get("id", ""),
                  # Clickable Google Maps link so the agent can render a tap-to-open
                  # URL in chat without composing one downstream.
                  "maps_url": f"https://www.google.com/maps/search/?api=1&query={el_lat},{el_lon}",
                  "tags": {
                      k: v for k, v in tags.items()
                      if k not in ("name", "name:en",
                                   "addr:housenumber", "addr:street", "addr:city")
                  },
              }
      
              # Promote commonly-useful tags to top-level fields so agents can
              # reference them without digging into the raw ``tags`` dict.
              for src_key, dst_key in (
                  ("cuisine",        "cuisine"),
                  ("opening_hours",  "hours"),
                  ("phone",          "phone"),
                  ("website",        "website"),
              ):
                  val = tags.get(src_key)
                  if val:
                      place[dst_key] = val
      
              if ref_lat is not None and ref_lon is not None:
                  dist_m = haversine_m(ref_lat, ref_lon, el_lat, el_lon)
                  place["distance_m"] = round(dist_m, 1)
                  # With a reference point we can also hand back a directions URL.
                  place["directions_url"] = (
                      f"https://www.google.com/maps/dir/?api=1"
                      f"&origin={ref_lat},{ref_lon}"
                      f"&destination={el_lat},{el_lon}"
                  )
      
              places.append(place)
      
          # Sort by distance if available
          if places and "distance_m" in places[0]:
              places.sort(key=lambda p: p["distance_m"])
      
          return places
      
      
      # ---------------------------------------------------------------------------
      # Command: search
      # ---------------------------------------------------------------------------
      
      def cmd_search(args):
          """Geocode a place name and return top results."""
          query = " ".join(args.query)
          raw   = nominatim_search(query, limit=5)
      
          if not raw:
              print_json({
                  "query":       query,
                  "results":     [],
                  "count":       0,
                  "data_source": DATA_SOURCE,
              })
              return
      
          results = []
          for item in raw:
              bb = item.get("boundingbox", [])
              results.append({
                  "name":         item.get("name") or item.get("display_name", ""),
                  "display_name": item.get("display_name", ""),
                  "lat":          float(item["lat"]),
                  "lon":          float(item["lon"]),
                  "type":         item.get("type", ""),
                  "category":     item.get("category", ""),
                  "osm_type":     item.get("osm_type", ""),
                  "osm_id":       item.get("osm_id", ""),
                  "bounding_box": {
                      "min_lat": float(bb[0]) if len(bb) > 0 else None,
                      "max_lat": float(bb[1]) if len(bb) > 1 else None,
                      "min_lon": float(bb[2]) if len(bb) > 2 else None,
                      "max_lon": float(bb[3]) if len(bb) > 3 else None,
                  },
                  "importance":   item.get("importance"),
              })
      
          print_json({
              "query":       query,
              "results":     results,
              "count":       len(results),
              "data_source": DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: reverse
      # ---------------------------------------------------------------------------
      
      def cmd_reverse(args):
          """Reverse geocode coordinates to a human-readable address."""
          try:
              lat = float(args.lat)
              lon = float(args.lon)
          except ValueError:
              error_exit("LAT and LON must be numeric values.")
      
          if not (-90 <= lat <= 90):
              error_exit("Latitude must be between -90 and 90.")
          if not (-180 <= lon <= 180):
              error_exit("Longitude must be between -180 and 180.")
      
          data = nominatim_reverse(lat, lon)
      
          if "error" in data:
              error_exit(f"Reverse geocode failed: {data['error']}")
      
          address = data.get("address", {})
      
          print_json({
              "lat":          lat,
              "lon":          lon,
              "display_name": data.get("display_name", ""),
              "address": {
                  "house_number":  address.get("house_number", ""),
                  "road":          address.get("road", ""),
                  "neighbourhood": address.get("neighbourhood", ""),
                  "suburb":        address.get("suburb", ""),
                  "city":          (address.get("city")
                                    or address.get("town")
                                    or address.get("village", "")),
                  "county":        address.get("county", ""),
                  "state":         address.get("state", ""),
                  "postcode":      address.get("postcode", ""),
                  "country":       address.get("country", ""),
                  "country_code":  address.get("country_code", ""),
              },
              "osm_type":    data.get("osm_type", ""),
              "osm_id":      data.get("osm_id", ""),
              "data_source": DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: nearby
      # ---------------------------------------------------------------------------
      
      def cmd_nearby(args):
          """Find nearby POIs using the Overpass API.
      
          Accepts either explicit coordinates (``lat``/``lon``) or a free-form
          address via ``--near`` (auto-geocoded through Nominatim). Supports
          multiple categories in one call — results are merged, deduplicated
          by ``osm_type+osm_id``, sorted by distance.
          """
          # Resolve the center point. --near takes precedence if provided so the
          # agent can ask "cafes near Times Square" in one command without having
          # to geocode first.
          if getattr(args, "near", None):
              near_query = " ".join(args.near).strip() if isinstance(args.near, list) else str(args.near).strip()
              if not near_query:
                  error_exit("--near must be a non-empty address or place name.")
              lat, lon, _ = geocode_single(near_query)
          else:
              try:
                  lat = float(args.lat)
                  lon = float(args.lon)
              except (TypeError, ValueError):
                  error_exit("Provide numeric LAT and LON, or use --near \"<address>\".")
      
          # Categories: support both legacy single positional ``category`` and the
          # new repeatable ``--category`` flag. Users can ask for multiple place
          # types in one query.
          categories = []
          if getattr(args, "category_list", None):
              categories.extend(args.category_list)
          if getattr(args, "category", None):
              categories.append(args.category)
          # Deduplicate, preserve order, lower-case.
          categories = list(dict.fromkeys(c.lower() for c in categories if c))
          if not categories:
              error_exit("Provide at least one category (positional or --category).")
          unknown = [c for c in categories if c not in CATEGORY_TAGS]
          if unknown:
              error_exit(
                  f"Unknown categor{'ies' if len(unknown) > 1 else 'y'} "
                  f"{', '.join(repr(c) for c in unknown)}. "
                  f"Valid categories: {', '.join(VALID_CATEGORIES)}"
              )
      
          radius = int(args.radius)
          limit  = int(args.limit)
          if radius <= 0:
              error_exit("Radius must be a positive integer (metres).")
          if limit <= 0:
              error_exit("Limit must be a positive integer.")
      
          # Query each category against the Overpass fallback chain, merge results,
          # dedupe by OSM identity so POIs tagged under multiple categories don't
          # appear twice.
          merged = {}
          for category in categories:
              tag_pairs = _tags_for(category)
              religion = RELIGION_FILTER.get(category)
              query = build_overpass_nearby(None, None, lat, lon, radius, limit,
                                            religion=religion, tag_pairs=tag_pairs)
              raw = overpass_query(query)
              elements = raw.get("elements", [])
              for place in parse_overpass_elements(elements, ref_lat=lat, ref_lon=lon):
                  place["category"] = category
                  key = (place.get("osm_type", ""), place.get("osm_id", ""))
                  # Prefer the entry that actually has a distance_m attached (first
                  # pass through the ref_lat/ref_lon branch), then first-seen wins.
                  if key not in merged:
                      merged[key] = place
      
          # Sort merged by distance when we have ref lat/lon, then cap at ``limit``.
          places = sorted(
              merged.values(),
              key=lambda p: p.get("distance_m", float("inf")),
          )[:limit]
      
          print_json({
              "center_lat":  lat,
              "center_lon":  lon,
              "categories":  categories,
              "radius_m":    radius,
              "count":       len(places),
              "results":     places,
              "data_source": DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: distance
      # ---------------------------------------------------------------------------
      
      def cmd_distance(args):
          """Calculate road distance and travel time between two places."""
          origin_query      = " ".join(args.origin)
          destination_query = " ".join(args.to)
          mode              = args.mode.lower()
      
          if mode not in OSRM_PROFILES:
              error_exit(f"Invalid mode '{mode}'. Choose from: {', '.join(OSRM_PROFILES)}")
      
          # Geocode origin and destination
          o_lat, o_lon, o_name = geocode_single(origin_query)
          d_lat, d_lon, d_name = geocode_single(destination_query)
      
          profile = OSRM_PROFILES[mode]
          url = (
              f"{OSRM_BASE}/{profile}/"
              f"{o_lon},{o_lat};{d_lon},{d_lat}"
              f"?overview=false&steps=false"
          )
      
          osrm_data = http_get(url)
      
          if osrm_data.get("code") != "Ok":
              error_exit(
                  f"OSRM routing failed: "
                  f"{osrm_data.get('message', osrm_data.get('code', 'unknown error'))}"
              )
      
          routes = osrm_data.get("routes", [])
          if not routes:
              error_exit("No route found between the two locations.")
      
          route        = routes[0]
          distance_m   = route.get("distance", 0)
          duration_s   = route.get("duration", 0)
          distance_km  = round(distance_m / 1000, 3)
          duration_min = round(duration_s / 60, 2)
      
          # Straight-line distance for reference
          straight_m = haversine_m(o_lat, o_lon, d_lat, d_lon)
      
          print_json({
              "origin": {
                  "query":        origin_query,
                  "display_name": o_name,
                  "lat":          o_lat,
                  "lon":          o_lon,
              },
              "destination": {
                  "query":        destination_query,
                  "display_name": d_name,
                  "lat":          d_lat,
                  "lon":          d_lon,
              },
              "mode":             mode,
              "distance_km":      distance_km,
              "distance_m":       round(distance_m, 1),
              "duration_minutes": duration_min,
              "duration_seconds": round(duration_s, 1),
              "straight_line_km": round(straight_m / 1000, 3),
              "data_source":      DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: directions
      # ---------------------------------------------------------------------------
      
      def _format_duration(seconds):
          """Format seconds into a human-readable string."""
          if seconds < 60:
              return f"{round(seconds)}s"
          minutes = seconds / 60
          if minutes < 60:
              return f"{round(minutes, 1)} min"
          hours = int(minutes // 60)
          remaining = round(minutes % 60)
          return f"{hours}h {remaining}min"
      
      
      def _format_distance(metres):
          """Format metres into a human-readable string."""
          if metres < 1000:
              return f"{round(metres)} m"
          return f"{round(metres / 1000, 2)} km"
      
      
      def cmd_directions(args):
          """Get turn-by-turn directions between two places via OSRM."""
          origin_query      = " ".join(args.origin)
          destination_query = " ".join(args.to)
          mode              = args.mode.lower()
      
          if mode not in OSRM_PROFILES:
              error_exit(f"Invalid mode '{mode}'. Choose from: {', '.join(OSRM_PROFILES)}")
      
          # Geocode origin and destination
          o_lat, o_lon, o_name = geocode_single(origin_query)
          d_lat, d_lon, d_name = geocode_single(destination_query)
      
          profile = OSRM_PROFILES[mode]
          url = (
              f"{OSRM_BASE}/{profile}/"
              f"{o_lon},{o_lat};{d_lon},{d_lat}"
              f"?overview=false&steps=true"
          )
      
          osrm_data = http_get(url)
      
          if osrm_data.get("code") != "Ok":
              error_exit(
                  f"OSRM routing failed: "
                  f"{osrm_data.get('message', osrm_data.get('code', 'unknown error'))}"
              )
      
          routes = osrm_data.get("routes", [])
          if not routes:
              error_exit("No route found between the two locations.")
      
          route        = routes[0]
          distance_m   = route.get("distance", 0)
          duration_s   = route.get("duration", 0)
      
          # Extract steps from all legs
          steps = []
          step_num = 0
          for leg in route.get("legs", []):
              for step in leg.get("steps", []):
                  maneuver = step.get("maneuver", {})
                  step_dist = step.get("distance", 0)
                  step_dur  = step.get("duration", 0)
                  step_name = step.get("name", "")
                  modifier  = maneuver.get("modifier", "")
                  m_type    = maneuver.get("type", "")
      
                  # Build instruction text
                  if m_type == "depart":
                      instruction = f"Depart on {step_name}" if step_name else "Depart"
                  elif m_type == "arrive":
                      instruction = "Arrive at destination"
                  elif m_type == "turn":
                      instruction = f"Turn {modifier} onto {step_name}" if step_name else f"Turn {modifier}"
                  elif m_type == "new name":
                      instruction = f"Continue onto {step_name}" if step_name else "Continue"
                  elif m_type == "merge":
                      instruction = f"Merge {modifier} onto {step_name}" if step_name else f"Merge {modifier}"
                  elif m_type == "fork":
                      instruction = f"Take the {modifier} fork onto {step_name}" if step_name else f"Take the {modifier} fork"
                  elif m_type == "roundabout":
                      instruction = f"Enter roundabout, exit onto {step_name}" if step_name else "Enter roundabout"
                  elif m_type == "rotary":
                      instruction = f"Enter rotary, exit onto {step_name}" if step_name else "Enter rotary"
                  elif m_type == "end of road":
                      instruction = f"At end of road, turn {modifier} onto {step_name}" if step_name else f"At end of road, turn {modifier}"
                  elif m_type == "continue":
                      instruction = f"Continue {modifier} on {step_name}" if step_name else f"Continue {modifier}"
                  elif m_type == "on ramp":
                      instruction = f"Take ramp onto {step_name}" if step_name else "Take ramp"
                  elif m_type == "off ramp":
                      instruction = f"Take exit onto {step_name}" if step_name else "Take exit"
                  else:
                      instruction = f"{m_type} {modifier} {step_name}".strip()
      
                  step_num += 1
                  steps.append({
                      "step":        step_num,
                      "instruction": instruction,
                      "distance":    _format_distance(step_dist),
                      "distance_m":  round(step_dist, 1),
                      "duration":    _format_duration(step_dur),
                      "duration_s":  round(step_dur, 1),
                      "road_name":   step_name,
                      "maneuver":    m_type,
                  })
      
          print_json({
              "origin": {
                  "query":        origin_query,
                  "display_name": o_name,
                  "lat":          o_lat,
                  "lon":          o_lon,
              },
              "destination": {
                  "query":        destination_query,
                  "display_name": d_name,
                  "lat":          d_lat,
                  "lon":          d_lon,
              },
              "mode":               mode,
              "total_distance":     _format_distance(distance_m),
              "total_distance_m":   round(distance_m, 1),
              "total_duration":     _format_duration(duration_s),
              "total_duration_s":   round(duration_s, 1),
              "steps":              steps,
              "step_count":         len(steps),
              "data_source":        DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: timezone
      # ---------------------------------------------------------------------------
      
      def cmd_timezone(args):
          """
          Get timezone information for a lat/lon coordinate.
      
          Strategy:
            1. Try TimeAPI.io (free, no key, supports coordinate-based lookup).
            2. Fallback: derive UTC offset approximation from longitude.
          """
          try:
              lat = float(args.lat)
              lon = float(args.lon)
          except ValueError:
              error_exit("LAT and LON must be numeric values.")
      
          if not (-90 <= lat <= 90):
              error_exit("Latitude must be between -90 and 90.")
          if not (-180 <= lon <= 180):
              error_exit("Longitude must be between -180 and 180.")
      
          timezone_str = None
          timezone_src = None
          current_time = None
          utc_offset   = None
      
          # --- Strategy 1: TimeAPI.io coordinate lookup ---
          try:
              params = {"latitude": lat, "longitude": lon}
              tz_data = http_get(TIMEAPI_BASE, params=params, silent=True)
              if isinstance(tz_data, dict):
                  timezone_str = tz_data.get("timeZone")
                  current_time = tz_data.get("currentLocalTime")
                  # Build utc_offset from currentUtcOffset if available
                  offset_info = tz_data.get("currentUtcOffset", {})
                  if isinstance(offset_info, dict):
                      oh = offset_info.get("hours", 0)
                      om = abs(offset_info.get("minutes", 0))
                      os_ = offset_info.get("seconds", 0)
                      sign = "+" if oh >= 0 else "-"
                      utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"
                      if os_:
                          utc_offset = f"{utc_offset}:{os_:02d}"
                  elif tz_data.get("standardUtcOffset"):
                      offset_info2 = tz_data["standardUtcOffset"]
                      if isinstance(offset_info2, dict):
                          oh = offset_info2.get("hours", 0)
                          om = abs(offset_info2.get("minutes", 0))
                          os_ = offset_info2.get("seconds", 0)
                          sign = "+" if oh >= 0 else "-"
                          utc_offset = f"{sign}{abs(oh):02d}:{om:02d}"
                          if os_:
                              utc_offset = f"{utc_offset}:{os_:02d}"
                  timezone_src = "timeapi.io"
          except (RuntimeError, KeyError, TypeError):
              pass  # API may be down; continue to fallback
      
          # --- Strategy 2: longitude-based UTC offset approximation ---
          if not timezone_str:
              approx_offset_h = round(lon / 15)
              if approx_offset_h >= 0:
                  utc_offset = f"+{approx_offset_h:02d}:00"
              else:
                  utc_offset = f"-{abs(approx_offset_h):02d}:00"
              timezone_str = f"UTC{utc_offset}"
              timezone_src = "longitude approximation (longitude/15)"
      
          print_json({
              "lat":          lat,
              "lon":          lon,
              "timezone":     timezone_str,
              "utc_offset":   utc_offset,
              "current_time": current_time,
              "source":       timezone_src,
              "data_source":  DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: bbox
      # ---------------------------------------------------------------------------
      
      def cmd_bbox(args):
          """Find POIs within a bounding box using the Overpass API."""
          try:
              lat1 = float(args.lat1)
              lon1 = float(args.lon1)
              lat2 = float(args.lat2)
              lon2 = float(args.lon2)
          except ValueError:
              error_exit("All coordinate arguments must be numeric values.")
      
          # Normalize: south/west < north/east
          south = min(lat1, lat2)
          north = max(lat1, lat2)
          west  = min(lon1, lon2)
          east  = max(lon1, lon2)
      
          category = args.category.lower()
          if category not in CATEGORY_TAGS:
              error_exit(
                  f"Unknown category '{category}'. "
                  f"Valid categories: {', '.join(VALID_CATEGORIES)}"
              )
      
          limit = int(args.limit)
          if limit <= 0:
              error_exit("Limit must be a positive integer.")
      
          tag_pairs = _tags_for(category)
          religion = RELIGION_FILTER.get(category)
          query = build_overpass_bbox(None, None, south, west, north, east,
                                      limit, religion=religion, tag_pairs=tag_pairs)
      
          raw = overpass_query(query)
      
          elements = raw.get("elements", [])
      
          # Use center of bbox as reference for distance sorting
          center_lat = (south + north) / 2
          center_lon = (west + east) / 2
          places = parse_overpass_elements(elements, ref_lat=center_lat,
                                           ref_lon=center_lon)
      
          for p in places:
              p["category"] = category
      
          print_json({
              "bounding_box": {
                  "south": south,
                  "west":  west,
                  "north": north,
                  "east":  east,
              },
              "category":    category,
              "count":       len(places),
              "results":     places,
              "data_source": DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # Command: area
      # ---------------------------------------------------------------------------
      
      def cmd_area(args):
          """Get bounding box and area info for a named place."""
          query = " ".join(args.place)
          raw = nominatim_search(query, limit=1)
      
          if not raw:
              error_exit(f"Could not find place: {query}")
      
          item = raw[0]
          bb = item.get("boundingbox", [])
      
          if len(bb) < 4:
              error_exit(f"No bounding box data available for: {query}")
      
          min_lat = float(bb[0])
          max_lat = float(bb[1])
          min_lon = float(bb[2])
          max_lon = float(bb[3])
      
          # Approximate area in km² using the bounding box
          # Width in km at the average latitude
          avg_lat = (min_lat + max_lat) / 2
          height_km = haversine_m(min_lat, min_lon, max_lat, min_lon) / 1000
          width_km  = haversine_m(avg_lat, min_lon, avg_lat, max_lon) / 1000
          approx_area_km2 = round(height_km * width_km, 3)
      
          print_json({
              "query":        query,
              "display_name": item.get("display_name", ""),
              "lat":          float(item["lat"]),
              "lon":          float(item["lon"]),
              "type":         item.get("type", ""),
              "category":     item.get("category", ""),
              "bounding_box": {
                  "south": min_lat,
                  "north": max_lat,
                  "west":  min_lon,
                  "east":  max_lon,
              },
              "dimensions": {
                  "width_km":  round(width_km, 3),
                  "height_km": round(height_km, 3),
              },
              "approx_area_km2": approx_area_km2,
              "osm_type":        item.get("osm_type", ""),
              "osm_id":          item.get("osm_id", ""),
              "data_source":     DATA_SOURCE,
          })
      
      
      # ---------------------------------------------------------------------------
      # CLI setup
      # ---------------------------------------------------------------------------
      
      def build_parser():
          parser = argparse.ArgumentParser(
              prog="maps_client.py",
              description=(
                  "CLI maps tool: geocoding, reverse geocoding, POI search, "
                  "routing, directions, timezone, and area lookup. "
                  "Powered by OpenStreetMap, OSRM, Overpass, and TimeAPI.io. "
                  "No API keys required."
              ),
              formatter_class=argparse.RawDescriptionHelpFormatter,
              epilog=(
                  "Examples:\n"
                  "  maps_client.py search Times Square\n"
                  "  maps_client.py reverse 40.758 -73.985\n"
                  "  maps_client.py nearby 40.758 -73.985 restaurant --radius 800\n"
                  "  maps_client.py distance New York --to Los Angeles --mode driving\n"
                  "  maps_client.py directions Paris --to Berlin --mode driving\n"
                  "  maps_client.py timezone 48.8566 2.3522\n"
                  "  maps_client.py bbox 40.70 -74.02 40.78 -73.95 restaurant\n"
                  "  maps_client.py area Manhattan"
              ),
          )
          sub = parser.add_subparsers(dest="command", required=True,
                                       metavar="COMMAND")
      
          # -- search --
          p_search = sub.add_parser(
              "search",
              help="Geocode a place name to coordinates.",
              description="Search for a place by name and return coordinates and details.",
          )
          p_search.add_argument(
              "query", nargs="+",
              help="Place name or address to search.",
          )
      
          # -- reverse --
          p_reverse = sub.add_parser(
              "reverse",
              help="Reverse geocode coordinates to an address.",
              description="Convert latitude/longitude coordinates to a human-readable address.",
          )
          p_reverse.add_argument("lat", help="Latitude (decimal degrees).")
          p_reverse.add_argument("lon", help="Longitude (decimal degrees).")
      
          # -- nearby --
          p_nearby = sub.add_parser(
              "nearby",
              help="Find nearby places of a given category.",
              description=(
                  "Find points of interest near a location using the Overpass API.\n"
                  "Provide either LAT/LON, or use --near \"<address>\" to auto-geocode.\n"
                  "Categories can be specified positionally OR repeated via --category\n"
                  "to merge multiple types in one query (e.g. --category bar --category cafe).\n"
                  f"Categories: {', '.join(VALID_CATEGORIES)}"
              ),
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          p_nearby.add_argument(
              "lat", nargs="?", default=None,
              help="Center latitude (decimal degrees). Omit if using --near.",
          )
          p_nearby.add_argument(
              "lon", nargs="?", default=None,
              help="Center longitude (decimal degrees). Omit if using --near.",
          )
          p_nearby.add_argument(
              "category", nargs="?", default=None,
              help="POI category (use --help for full list). Omit if using --category flags.",
          )
          p_nearby.add_argument(
              "--near", nargs="+", metavar="PLACE",
              help="Address, city, or landmark to search around (geocoded via Nominatim).",
          )
          p_nearby.add_argument(
              "--category", action="append", dest="category_list", default=[],
              metavar="CAT",
              help="POI category (repeatable — adds a type to the search).",
          )
          p_nearby.add_argument(
              "--radius", "-r",
              default=500, type=int, metavar="METRES",
              help="Search radius in metres (default: 500).",
          )
          p_nearby.add_argument(
              "--limit", "-n",
              default=10, type=int, metavar="N",
              help="Maximum number of results (default: 10).",
          )
      
          # -- distance --
          p_dist = sub.add_parser(
              "distance",
              help="Calculate road distance and travel time.",
              description=(
                  "Calculate road distance and estimated travel time between two places.\n"
                  "Example: maps_client.py distance New York --to Los Angeles"
              ),
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          p_dist.add_argument(
              "origin", nargs="+",
              help="Origin address or place name.",
          )
          p_dist.add_argument(
              "--to", nargs="+", required=True, metavar="DEST",
              help="Destination address or place name (required).",
          )
          p_dist.add_argument(
              "--mode", "-m",
              default="driving",
              choices=list(OSRM_PROFILES.keys()),
              help="Travel mode (default: driving).",
          )
      
          # -- directions --
          p_dir = sub.add_parser(
              "directions",
              help="Get turn-by-turn directions between two places.",
              description=(
                  "Get step-by-step navigation directions between two places.\n"
                  "Example: maps_client.py directions Paris --to Berlin --mode driving"
              ),
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          p_dir.add_argument(
              "origin", nargs="+",
              help="Origin address or place name.",
          )
          p_dir.add_argument(
              "--to", nargs="+", required=True, metavar="DEST",
              help="Destination address or place name (required).",
          )
          p_dir.add_argument(
              "--mode", "-m",
              default="driving",
              choices=list(OSRM_PROFILES.keys()),
              help="Travel mode (default: driving).",
          )
      
          # -- timezone --
          p_tz = sub.add_parser(
              "timezone",
              help="Get timezone information for coordinates.",
              description="Look up timezone and current local time for a lat/lon coordinate.",
          )
          p_tz.add_argument("lat", help="Latitude (decimal degrees).")
          p_tz.add_argument("lon", help="Longitude (decimal degrees).")
      
          # -- bbox --
          p_bbox = sub.add_parser(
              "bbox",
              help="Find POIs within a bounding box.",
              description=(
                  "Search for points of interest within a geographic bounding box.\n"
                  "Tip: use the 'area' command to find bounding boxes for named places.\n"
                  f"Categories: {', '.join(VALID_CATEGORIES)}"
              ),
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          p_bbox.add_argument("lat1", help="First corner latitude.")
          p_bbox.add_argument("lon1", help="First corner longitude.")
          p_bbox.add_argument("lat2", help="Second corner latitude.")
          p_bbox.add_argument("lon2", help="Second corner longitude.")
          p_bbox.add_argument("category", help="POI category to search for.")
          p_bbox.add_argument(
              "--limit", "-n",
              default=20, type=int, metavar="N",
              help="Maximum number of results (default: 20).",
          )
      
          # -- area --
          p_area = sub.add_parser(
              "area",
              help="Get bounding box and area info for a named place.",
              description=(
                  "Look up a place by name and return its bounding box, dimensions, "
                  "and approximate area. Useful as input to the 'bbox' command."
              ),
          )
          p_area.add_argument(
              "place", nargs="+",
              help="Place name to look up (e.g., 'Manhattan' or 'downtown Seattle').",
          )
      
          return parser
      
      
      def main():
          parser = build_parser()
          args   = parser.parse_args()
      
          dispatch = {
              "search":     cmd_search,
              "reverse":    cmd_reverse,
              "nearby":     cmd_nearby,
              "distance":   cmd_distance,
              "directions": cmd_directions,
              "timezone":   cmd_timezone,
              "bbox":       cmd_bbox,
              "area":       cmd_area,
          }
      
          handler = dispatch.get(args.command)
          if handler is None:
              error_exit(f"Unknown command: {args.command}")
      
          handler(args)
      
      
      if __name__ == "__main__":
          main()
      
  • SKILL.md 6.3 KB
    ---
    name: maps
    description: "Geocode, POIs, routes, timezones via OpenStreetMap/OSRM."
    version: 1.2.0
    author: Mibayy
    license: MIT
    ---
    
    # Maps Skill
    
    Location intelligence using free, open data sources. 8 commands, 44 POI
    categories, zero dependencies (Python stdlib only), no API key required.
    
    Data sources: OpenStreetMap/Nominatim, Overpass API, OSRM, TimeAPI.io.
    
    This skill supersedes the old `find-nearby` skill — all of find-nearby's
    functionality is covered by the `nearby` command below, with the same
    `--near "<place>"` shortcut and multi-category support.
    
    ## When to Use
    
    - User sends a Telegram location pin (latitude/longitude in the message) → `nearby`
    - User wants coordinates for a place name → `search`
    - User has coordinates and wants the address → `reverse`
    - User asks for nearby restaurants, hospitals, pharmacies, hotels, etc. → `nearby`
    - User wants driving/walking/cycling distance or travel time → `distance`
    - User wants turn-by-turn directions between two places → `directions`
    - User wants timezone information for a location → `timezone`
    - User wants to search for POIs within a geographic area → `area` + `bbox`
    
    ## Prerequisites
    
    Python 3.8+ (stdlib only — no pip installs needed).
    
    Script path: `~/.surogate/skills/maps/scripts/maps_client.py`
    
    ## Commands
    
    ```bash
    MAPS=~/.surogate/skills/maps/scripts/maps_client.py
    ```
    
    ### search — Geocode a place name
    
    ```bash
    python3 $MAPS search "Eiffel Tower"
    python3 $MAPS search "1600 Pennsylvania Ave, Washington DC"
    ```
    
    Returns: lat, lon, display name, type, bounding box, importance score.
    
    ### reverse — Coordinates to address
    
    ```bash
    python3 $MAPS reverse 48.8584 2.2945
    ```
    
    Returns: full address breakdown (street, city, state, country, postcode).
    
    ### nearby — Find places by category
    
    ```bash
    # By coordinates (from a Telegram location pin, for example)
    python3 $MAPS nearby 48.8584 2.2945 restaurant --limit 10
    python3 $MAPS nearby 40.7128 -74.0060 hospital --radius 2000
    
    # By address / city / zip / landmark — --near auto-geocodes
    python3 $MAPS nearby --near "Times Square, New York" --category cafe
    python3 $MAPS nearby --near "90210" --category pharmacy
    
    # Multiple categories merged into one query
    python3 $MAPS nearby --near "downtown austin" --category restaurant --category bar --limit 10
    ```
    
    46 categories: restaurant, cafe, bar, hospital, pharmacy, hotel, guest_house,
    camp_site, supermarket, atm, gas_station, parking, museum, park, school,
    university, bank, police, fire_station, library, airport, train_station,
    bus_stop, church, mosque, synagogue, dentist, doctor, cinema, theatre, gym,
    swimming_pool, post_office, convenience_store, bakery, bookshop, laundry,
    car_wash, car_rental, bicycle_rental, taxi, veterinary, zoo, playground,
    stadium, nightclub.
    
    Each result includes: `name`, `address`, `lat`/`lon`, `distance_m`,
    `maps_url` (clickable Google Maps link), `directions_url` (Google Maps
    directions from the search point), and promoted tags when available —
    `cuisine`, `hours` (opening_hours), `phone`, `website`.
    
    ### distance — Travel distance and time
    
    ```bash
    python3 $MAPS distance "Paris" --to "Lyon"
    python3 $MAPS distance "New York" --to "Boston" --mode driving
    python3 $MAPS distance "Big Ben" --to "Tower Bridge" --mode walking
    ```
    
    Modes: driving (default), walking, cycling. Returns road distance, duration,
    and straight-line distance for comparison.
    
    ### directions — Turn-by-turn navigation
    
    ```bash
    python3 $MAPS directions "Eiffel Tower" --to "Louvre Museum" --mode walking
    python3 $MAPS directions "JFK Airport" --to "Times Square" --mode driving
    ```
    
    Returns numbered steps with instruction, distance, duration, road name, and
    maneuver type (turn, depart, arrive, etc.).
    
    ### timezone — Timezone for coordinates
    
    ```bash
    python3 $MAPS timezone 48.8584 2.2945
    python3 $MAPS timezone 35.6762 139.6503
    ```
    
    Returns timezone name, UTC offset, and current local time.
    
    ### area — Bounding box and area for a place
    
    ```bash
    python3 $MAPS area "Manhattan, New York"
    python3 $MAPS area "London"
    ```
    
    Returns bounding box coordinates, width/height in km, and approximate area.
    Useful as input for the bbox command.
    
    ### bbox — Search within a bounding box
    
    ```bash
    python3 $MAPS bbox 40.75 -74.00 40.77 -73.98 restaurant --limit 20
    ```
    
    Finds POIs within a geographic rectangle. Use `area` first to get the
    bounding box coordinates for a named place.
    
    ## Working With Telegram Location Pins
    
    When a user sends a location pin, the message contains `latitude:` and
    `longitude:` fields. Extract those and pass them straight to `nearby`:
    
    ```bash
    # User sent a pin at 36.17, -115.14 and asked "find cafes nearby"
    python3 $MAPS nearby 36.17 -115.14 cafe --radius 1500
    ```
    
    Present results as a numbered list with names, distances, and the
    `maps_url` field so the user gets a tap-to-open link in chat. For "open
    now?" questions, check the `hours` field; if missing or unclear, verify
    with `web_search` since OSM hours are community-maintained and not always
    current.
    
    ## Workflow Examples
    
    **"Find Italian restaurants near the Colosseum":**
    1. `nearby --near "Colosseum Rome" --category restaurant --radius 500`
       — one command, auto-geocoded
    
    **"What's near this location pin they sent?":**
    1. Extract lat/lon from the Telegram message
    2. `nearby LAT LON cafe --radius 1500`
    
    **"How do I walk from hotel to conference center?":**
    1. `directions "Hotel Name" --to "Conference Center" --mode walking`
    
    **"What restaurants are in downtown Seattle?":**
    1. `area "Downtown Seattle"` → get bounding box
    2. `bbox S W N E restaurant --limit 30`
    
    ## Pitfalls
    
    - Nominatim ToS: max 1 req/s (handled automatically by the script)
    - `nearby` requires lat/lon OR `--near "<address>"` — one of the two is needed
    - OSRM routing coverage is best for Europe and North America
    - Overpass API can be slow during peak hours; the script automatically
      falls back between mirrors (overpass-api.de → overpass.kumi.systems)
    - `distance` and `directions` use `--to` flag for the destination (not positional)
    - If a zip code alone gives ambiguous results globally, include country/state
    
    ## Verification
    
    ```bash
    python3 ~/.surogate/skills/maps/scripts/maps_client.py search "Statue of Liberty"
    # Should return lat ~40.689, lon ~-74.044
    
    python3 ~/.surogate/skills/maps/scripts/maps_client.py nearby --near "Times Square" --category restaurant --limit 3
    # Should return a list of restaurants within ~500m of Times Square
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related