vicpropertycheck 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. vicpropertycheck-0.1.1/PKG-INFO +97 -0
  2. vicpropertycheck-0.1.1/README.md +57 -0
  3. vicpropertycheck-0.1.1/pyproject.toml +84 -0
  4. vicpropertycheck-0.1.1/setup.cfg +4 -0
  5. vicpropertycheck-0.1.1/src/vic_property_mcp/__init__.py +16 -0
  6. vicpropertycheck-0.1.1/src/vic_property_mcp/__main__.py +6 -0
  7. vicpropertycheck-0.1.1/src/vic_property_mcp/cli.py +412 -0
  8. vicpropertycheck-0.1.1/src/vic_property_mcp/config/__init__.py +39 -0
  9. vicpropertycheck-0.1.1/src/vic_property_mcp/config/schemas.py +387 -0
  10. vicpropertycheck-0.1.1/src/vic_property_mcp/config/settings.py +239 -0
  11. vicpropertycheck-0.1.1/src/vic_property_mcp/exceptions/__init__.py +343 -0
  12. vicpropertycheck-0.1.1/src/vic_property_mcp/middleware/__init__.py +5 -0
  13. vicpropertycheck-0.1.1/src/vic_property_mcp/middleware/auth.py +71 -0
  14. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/__init__.py +23 -0
  15. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/base.py +429 -0
  16. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/google_geocoding.py +287 -0
  17. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/google_places_facilities.py +293 -0
  18. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/google_places_nearby.py +296 -0
  19. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/hosted_proxy.py +283 -0
  20. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/openstats_base.py +362 -0
  21. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/openstats_crime.py +352 -0
  22. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/openstats_housing.py +331 -0
  23. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/vicmap_bushfire.py +501 -0
  24. vicpropertycheck-0.1.1/src/vic_property_mcp/providers/vicmap_planning.py +479 -0
  25. vicpropertycheck-0.1.1/src/vic_property_mcp/server.py +641 -0
  26. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/__init__.py +30 -0
  27. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/address.py +69 -0
  28. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/bushfire.py +87 -0
  29. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/crime.py +75 -0
  30. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/housing.py +73 -0
  31. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/planning.py +85 -0
  32. vicpropertycheck-0.1.1/src/vic_property_mcp/tools/summary.py +179 -0
  33. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/__init__.py +91 -0
  34. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/cache.py +516 -0
  35. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/error_handling.py +11 -0
  36. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/geo.py +422 -0
  37. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/logging.py +439 -0
  38. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/rate_limit.py +374 -0
  39. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/response.py +53 -0
  40. vicpropertycheck-0.1.1/src/vic_property_mcp/utils/validators.py +111 -0
  41. vicpropertycheck-0.1.1/src/vicpropertycheck.egg-info/PKG-INFO +97 -0
  42. vicpropertycheck-0.1.1/src/vicpropertycheck.egg-info/SOURCES.txt +44 -0
  43. vicpropertycheck-0.1.1/src/vicpropertycheck.egg-info/dependency_links.txt +1 -0
  44. vicpropertycheck-0.1.1/src/vicpropertycheck.egg-info/entry_points.txt +3 -0
  45. vicpropertycheck-0.1.1/src/vicpropertycheck.egg-info/requires.txt +27 -0
  46. vicpropertycheck-0.1.1/src/vicpropertycheck.egg-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: vicpropertycheck
3
+ Version: 0.1.1
4
+ Summary: Victorian property due diligence — MCP server, CLI, and skill backend (planning, bushfire, crime, housing, facilities)
5
+ Author-email: Roger Liu <zichengliu0226@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Python: >=3.13.2
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: mcp>=1.25.0
15
+ Requires-Dist: pydantic>=2.12.5
16
+ Requires-Dist: httpx>=0.28.1
17
+ Requires-Dist: shapely>=2.0.6
18
+ Requires-Dist: pyproj>=3.7.0
19
+ Requires-Dist: python-dotenv>=1.0.1
20
+ Requires-Dist: structlog>=24.4.0
21
+ Requires-Dist: aiosqlite>=0.20.0
22
+ Requires-Dist: tenacity>=9.0.0
23
+ Requires-Dist: typing-extensions>=4.12.2
24
+ Requires-Dist: email-validator>=2.2.0
25
+ Requires-Dist: uvicorn>=0.40.0
26
+ Requires-Dist: starlette>=0.50.0
27
+ Requires-Dist: sse-starlette>=2.1.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest<8.3,>=8.2.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
31
+ Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
32
+ Requires-Dist: black>=24.10.0; extra == "dev"
33
+ Requires-Dist: isort>=5.13.2; extra == "dev"
34
+ Requires-Dist: flake8>=7.1.1; extra == "dev"
35
+ Requires-Dist: mypy>=1.13.0; extra == "dev"
36
+ Requires-Dist: pylint>=3.3.2; extra == "dev"
37
+ Requires-Dist: pylint-pydantic>=0.3.2; extra == "dev"
38
+ Requires-Dist: pylint-pytest>=1.1.8; extra == "dev"
39
+ Requires-Dist: pre-commit>=4.0.1; extra == "dev"
40
+
41
+ # vicpropertycheck
42
+
43
+ Victorian (Australia) property due diligence — three things in one Python package:
44
+
45
+ 1. **MCP server** (`vic-property-mcp`) — exposes property tools to MCP-aware clients.
46
+ 2. **CLI** (`vic-property`) — same tools as subcommands, for shell/script use.
47
+ 3. **Skill backend** — drives the `vic-property-check` Claude Code skill (see `skill/SKILL.md`).
48
+
49
+ All three share the same providers, cache, and config:
50
+
51
+ - VicMap Planning ArcGIS (zones + overlays)
52
+ - VicMap Bushfire ArcGIS (BMO, prone areas)
53
+ - OpenStats (suburb crime + housing)
54
+ - Google Maps (geocoding + nearby places) — direct API key or hosted proxy with Firebase sign-in
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pipx install vicpropertycheck
60
+ ```
61
+
62
+ This puts `vic-property-mcp` (server) and `vic-property` (CLI) on your `PATH`.
63
+
64
+ ## CLI quick start
65
+
66
+ ```bash
67
+ # VIC government APIs need no auth — these work out of the box:
68
+ vic-property planning --lat -37.8113 --lon 144.9737
69
+ vic-property bushfire --lat -37.8113 --lon 144.9737
70
+ vic-property crime --suburb "Melbourne"
71
+
72
+ # Google-backed commands need either:
73
+ # (a) export GOOGLE_MAPS_API_KEY=... (self-hosted)
74
+ # (b) vic-property login (paste blob from vicpropertycheck.com.au/skill-auth)
75
+ vic-property summarize --address "1 Spring St, Melbourne VIC 3000" --pretty
76
+ ```
77
+
78
+ Every subcommand emits one JSON object on stdout. Errors come back as
79
+ `{"error": "...", "code": "..."}` with a non-zero exit code.
80
+
81
+ ## MCP server
82
+
83
+ ```bash
84
+ vic-property-mcp # stdio transport (default)
85
+ VIC_PROPERTY_TRANSPORT__MODE=http vic-property-mcp # HTTP on :8080
86
+ ```
87
+
88
+ Configuration uses the `VIC_PROPERTY_` env-var prefix; see `.env.example`.
89
+
90
+ ## Skill
91
+
92
+ Drop `skill/` into `~/.claude/skills/vic-property-check/`. See `skill/INSTALL.md` for the
93
+ full install + auth walkthrough.
94
+
95
+ ## License
96
+
97
+ MIT.
@@ -0,0 +1,57 @@
1
+ # vicpropertycheck
2
+
3
+ Victorian (Australia) property due diligence — three things in one Python package:
4
+
5
+ 1. **MCP server** (`vic-property-mcp`) — exposes property tools to MCP-aware clients.
6
+ 2. **CLI** (`vic-property`) — same tools as subcommands, for shell/script use.
7
+ 3. **Skill backend** — drives the `vic-property-check` Claude Code skill (see `skill/SKILL.md`).
8
+
9
+ All three share the same providers, cache, and config:
10
+
11
+ - VicMap Planning ArcGIS (zones + overlays)
12
+ - VicMap Bushfire ArcGIS (BMO, prone areas)
13
+ - OpenStats (suburb crime + housing)
14
+ - Google Maps (geocoding + nearby places) — direct API key or hosted proxy with Firebase sign-in
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pipx install vicpropertycheck
20
+ ```
21
+
22
+ This puts `vic-property-mcp` (server) and `vic-property` (CLI) on your `PATH`.
23
+
24
+ ## CLI quick start
25
+
26
+ ```bash
27
+ # VIC government APIs need no auth — these work out of the box:
28
+ vic-property planning --lat -37.8113 --lon 144.9737
29
+ vic-property bushfire --lat -37.8113 --lon 144.9737
30
+ vic-property crime --suburb "Melbourne"
31
+
32
+ # Google-backed commands need either:
33
+ # (a) export GOOGLE_MAPS_API_KEY=... (self-hosted)
34
+ # (b) vic-property login (paste blob from vicpropertycheck.com.au/skill-auth)
35
+ vic-property summarize --address "1 Spring St, Melbourne VIC 3000" --pretty
36
+ ```
37
+
38
+ Every subcommand emits one JSON object on stdout. Errors come back as
39
+ `{"error": "...", "code": "..."}` with a non-zero exit code.
40
+
41
+ ## MCP server
42
+
43
+ ```bash
44
+ vic-property-mcp # stdio transport (default)
45
+ VIC_PROPERTY_TRANSPORT__MODE=http vic-property-mcp # HTTP on :8080
46
+ ```
47
+
48
+ Configuration uses the `VIC_PROPERTY_` env-var prefix; see `.env.example`.
49
+
50
+ ## Skill
51
+
52
+ Drop `skill/` into `~/.claude/skills/vic-property-check/`. See `skill/INSTALL.md` for the
53
+ full install + auth walkthrough.
54
+
55
+ ## License
56
+
57
+ MIT.
@@ -0,0 +1,84 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vicpropertycheck"
7
+ version = "0.1.1"
8
+ description = "Victorian property due diligence — MCP server, CLI, and skill backend (planning, bushfire, crime, housing, facilities)"
9
+ authors = [{ name = "Roger Liu", email = "zichengliu0226@gmail.com" }]
10
+ license = { text = "MIT" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.13.2"
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.13",
19
+ ]
20
+
21
+ dependencies = [
22
+ "mcp>=1.25.0",
23
+ "pydantic>=2.12.5",
24
+ "httpx>=0.28.1",
25
+ "shapely>=2.0.6",
26
+ "pyproj>=3.7.0",
27
+ "python-dotenv>=1.0.1",
28
+ "structlog>=24.4.0",
29
+ "aiosqlite>=0.20.0",
30
+ "tenacity>=9.0.0",
31
+ "typing-extensions>=4.12.2",
32
+ "email-validator>=2.2.0",
33
+ "uvicorn>=0.40.0",
34
+ "starlette>=0.50.0",
35
+ "sse-starlette>=2.1.0",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=8.2.0,<8.3",
41
+ "pytest-asyncio>=0.24.0",
42
+ "pytest-cov>=6.0.0",
43
+ "black>=24.10.0",
44
+ "isort>=5.13.2",
45
+ "flake8>=7.1.1",
46
+ "mypy>=1.13.0",
47
+ "pylint>=3.3.2",
48
+ "pylint-pydantic>=0.3.2",
49
+ "pylint-pytest>=1.1.8",
50
+ "pre-commit>=4.0.1",
51
+ ]
52
+
53
+ [project.scripts]
54
+ vic-property-mcp = "vic_property_mcp.server:main"
55
+ vic-property = "vic_property_mcp.cli:main"
56
+
57
+ [tool.setuptools.packages.find]
58
+ where = ["src"]
59
+
60
+ [tool.black]
61
+ line-length = 88
62
+ target-version = ['py313']
63
+
64
+ [tool.isort]
65
+ profile = "black"
66
+ multi_line_output = 3
67
+
68
+ [tool.mypy]
69
+ python_version = "3.13"
70
+ warn_return_any = true
71
+ warn_unused_configs = true
72
+ disallow_untyped_defs = true
73
+
74
+ [tool.pytest.ini_options]
75
+ testpaths = ["tests"]
76
+ python_files = ["test_*.py"]
77
+ python_classes = ["Test*"]
78
+ python_functions = ["test_*"]
79
+ addopts = "--cov=src/vic_property_mcp --cov-report=html --cov-report=term-missing"
80
+
81
+ # Pylint configuration
82
+ [tool.pylint.main]
83
+ load-plugins = ["pylint_pydantic", "pylint_pytest"]
84
+ py-version = "3.13"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """VIC Property Due Diligence MCP Server."""
2
+
3
+ from .config import get_settings
4
+ from .server import mcp
5
+
6
+ __version__ = "0.1.0"
7
+ __title__ = "VIC Property Due Diligence MCP Server"
8
+ __description__ = "MCP server for Victorian property due diligence research"
9
+
10
+ __all__ = [
11
+ "mcp",
12
+ "get_settings",
13
+ "__version__",
14
+ "__title__",
15
+ "__description__",
16
+ ]
@@ -0,0 +1,6 @@
1
+ """Entry point for running the VIC Property MCP Server as a module."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,412 @@
1
+ """Command-line interface for VIC Property Due Diligence.
2
+
3
+ Each subcommand maps 1:1 to one of the tools the MCP server exposes.
4
+ The CLI shares the provider stack, cache, and configuration with the server,
5
+ so behaviour is identical — only the transport differs.
6
+
7
+ Designed to back a Claude Code skill: stable JSON on stdout, errors on
8
+ stderr, non-zero exit on failure.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import json
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from typing import Any, Awaitable, Callable, Optional
19
+
20
+ from vic_property_mcp.config.settings import get_settings
21
+ from vic_property_mcp.exceptions import ConfigurationError, PropertyMCPError
22
+ from vic_property_mcp.providers.google_geocoding import GoogleGeocodingProvider
23
+ from vic_property_mcp.providers.google_places_facilities import (
24
+ GooglePlacesFacilitiesProvider,
25
+ )
26
+ from vic_property_mcp.providers.google_places_nearby import GooglePlacesNearbyProvider
27
+ from vic_property_mcp.providers.hosted_proxy import (
28
+ CREDENTIALS_FILE,
29
+ HostedProxyCredentials,
30
+ _exchange_custom_token_for_id_token,
31
+ )
32
+ from vic_property_mcp.providers.openstats_crime import OpenStatsCrimeProvider
33
+ from vic_property_mcp.providers.openstats_housing import OpenStatsHousingProvider
34
+ from vic_property_mcp.providers.vicmap_bushfire import VicMapBushfireProvider
35
+ from vic_property_mcp.providers.vicmap_planning import VicMapPlanningProvider
36
+ from vic_property_mcp.utils.cache import get_cache_manager
37
+ from vic_property_mcp.utils.logging import configure_logging
38
+
39
+
40
+ @dataclass
41
+ class Providers:
42
+ """Bundle of initialised providers held for the lifetime of one CLI invocation."""
43
+
44
+ geocoding: GoogleGeocodingProvider
45
+ planning: VicMapPlanningProvider
46
+ bushfire: VicMapBushfireProvider
47
+ housing: OpenStatsHousingProvider
48
+ crime: OpenStatsCrimeProvider
49
+ places_nearby: GooglePlacesNearbyProvider
50
+ places_facilities: GooglePlacesFacilitiesProvider
51
+
52
+ async def close(self) -> None:
53
+ for provider in (
54
+ self.geocoding,
55
+ self.planning,
56
+ self.bushfire,
57
+ self.housing,
58
+ self.crime,
59
+ self.places_nearby,
60
+ self.places_facilities,
61
+ ):
62
+ try:
63
+ await provider.close()
64
+ except Exception: # noqa: BLE001 — best-effort cleanup
65
+ pass
66
+
67
+
68
+ def _build_providers() -> Providers:
69
+ settings = get_settings()
70
+ cache_manager = get_cache_manager()
71
+
72
+ if not settings.api.google_maps_api_key:
73
+ # Hosted-proxy fallback is wired in providers/google_*.py via the
74
+ # GOOGLE_MAPS_API_KEY-or-proxy mode switch; if neither is configured
75
+ # the providers themselves will raise a clear error at request time.
76
+ pass
77
+
78
+ return Providers(
79
+ geocoding=GoogleGeocodingProvider(
80
+ base_url=settings.api.google_maps_base_url,
81
+ api_key=settings.api.google_maps_api_key,
82
+ cache_manager=cache_manager,
83
+ timeout=settings.api.timeout,
84
+ cache_ttl=settings.cache.google_maps_geocoding_ttl,
85
+ ),
86
+ planning=VicMapPlanningProvider(
87
+ base_url=settings.api.vicmap_planning_base_url,
88
+ cache_manager=cache_manager,
89
+ timeout=settings.api.timeout,
90
+ cache_ttl=settings.cache.planning_ttl,
91
+ ),
92
+ bushfire=VicMapBushfireProvider(
93
+ base_url=settings.api.vicmap_planning_base_url,
94
+ cache_manager=cache_manager,
95
+ timeout=settings.api.timeout,
96
+ cache_ttl=settings.cache.bushfire_ttl,
97
+ ),
98
+ housing=OpenStatsHousingProvider(
99
+ base_url=settings.api.open_stats_base_url,
100
+ cache_manager=cache_manager,
101
+ timeout=settings.api.timeout,
102
+ cache_ttl=settings.cache.openstats_ttl,
103
+ ),
104
+ crime=OpenStatsCrimeProvider(
105
+ base_url=settings.api.open_stats_base_url,
106
+ cache_manager=cache_manager,
107
+ timeout=settings.api.timeout,
108
+ cache_ttl=settings.cache.openstats_ttl,
109
+ ),
110
+ places_nearby=GooglePlacesNearbyProvider(
111
+ api_key=settings.api.google_maps_api_key,
112
+ cache_manager=cache_manager,
113
+ timeout=settings.api.timeout,
114
+ cache_ttl=settings.cache.google_places_ttl,
115
+ ),
116
+ places_facilities=GooglePlacesFacilitiesProvider(
117
+ api_key=settings.api.google_maps_api_key,
118
+ cache_manager=cache_manager,
119
+ timeout=settings.api.timeout,
120
+ cache_ttl=settings.cache.google_places_ttl,
121
+ ),
122
+ )
123
+
124
+
125
+ # -----------------------------------------------------------------------------
126
+ # Command implementations
127
+ # -----------------------------------------------------------------------------
128
+
129
+
130
+ async def cmd_resolve_address(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
131
+ result = await p.geocoding.geocode_address(args.address)
132
+ if result is None:
133
+ return {"error": "Address could not be geocoded", "code": "NOT_FOUND"}
134
+ return result.model_dump()
135
+
136
+
137
+ async def cmd_planning(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
138
+ result = await p.planning.get_planning_info(args.lat, args.lon)
139
+ return result.model_dump()
140
+
141
+
142
+ async def cmd_bushfire(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
143
+ result = await p.bushfire.get_bushfire_info(args.lat, args.lon)
144
+ return result.model_dump()
145
+
146
+
147
+ async def cmd_housing(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
148
+ result = await p.housing.get_suburb_housing_stats(args.suburb)
149
+ payload = result.model_dump()
150
+ if args.lat is not None and args.lon is not None:
151
+ try:
152
+ nearby = await p.places_nearby.search_nearby_public_housing(
153
+ args.lat, args.lon, max_results=5
154
+ )
155
+ payload["nearby_public_housing"] = nearby.model_dump()
156
+ except Exception as exc: # noqa: BLE001
157
+ payload["nearby_public_housing_error"] = str(exc)
158
+ return payload
159
+
160
+
161
+ async def cmd_crime(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
162
+ result = await p.crime.get_suburb_crime_stats(args.suburb)
163
+ return result.model_dump()
164
+
165
+
166
+ async def cmd_facilities(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
167
+ result = await p.places_facilities.search_nearby_facilities(
168
+ args.lat,
169
+ args.lon,
170
+ radius_m=args.radius_m,
171
+ max_per_category=args.max_per_category,
172
+ )
173
+ return result.model_dump()
174
+
175
+
176
+ async def cmd_login(_p: Providers, args: argparse.Namespace) -> dict[str, Any]:
177
+ """Persist hosted-proxy credentials minted by the web /skill-auth page.
178
+
179
+ The user pastes the JSON blob the web page printed. We validate it, exchange
180
+ the custom token for an ID + refresh token, and write everything to
181
+ ~/.config/vicpropertycheck/credentials.json (mode 0600).
182
+ """
183
+ raw = args.credentials
184
+ if raw == "-" or not raw:
185
+ raw = sys.stdin.read()
186
+
187
+ try:
188
+ payload = json.loads(raw)
189
+ except json.JSONDecodeError as exc:
190
+ return {
191
+ "error": f"Could not parse credentials JSON: {exc}",
192
+ "code": "INVALID_INPUT",
193
+ }
194
+
195
+ required = ("customToken", "apiKey", "proxyBaseUrl")
196
+ missing = [key for key in required if not payload.get(key)]
197
+ if missing:
198
+ return {
199
+ "error": f"Missing required fields: {', '.join(missing)}",
200
+ "code": "INVALID_INPUT",
201
+ }
202
+
203
+ creds = HostedProxyCredentials(
204
+ custom_token=payload["customToken"],
205
+ api_key=payload["apiKey"],
206
+ proxy_base_url=str(payload["proxyBaseUrl"]).rstrip("/"),
207
+ uid=payload.get("uid"),
208
+ email=payload.get("email"),
209
+ )
210
+
211
+ # Verify the credentials work BEFORE writing them to disk so the user gets
212
+ # an immediate error if they pasted something stale.
213
+ import httpx as _httpx
214
+
215
+ async with _httpx.AsyncClient() as client:
216
+ await _exchange_custom_token_for_id_token(client, creds)
217
+
218
+ creds.save()
219
+ return {
220
+ "ok": True,
221
+ "credentials_file": str(CREDENTIALS_FILE),
222
+ "uid": creds.uid,
223
+ "email": creds.email,
224
+ }
225
+
226
+
227
+ async def cmd_summarize(p: Providers, args: argparse.Namespace) -> dict[str, Any]:
228
+ geocoding = await p.geocoding.geocode_address(args.address)
229
+ if geocoding is None:
230
+ return {"error": "Address could not be geocoded", "code": "NOT_FOUND"}
231
+
232
+ planning = await p.planning.get_planning_info(geocoding.lat, geocoding.lon)
233
+ bushfire = await p.bushfire.get_bushfire_info(geocoding.lat, geocoding.lon)
234
+
235
+ suburb = geocoding.suburb
236
+ housing: dict[str, Any] = {}
237
+ if suburb:
238
+ housing = (await p.housing.get_suburb_housing_stats(suburb)).model_dump()
239
+
240
+ try:
241
+ nearby_housing = await p.places_nearby.search_nearby_public_housing(
242
+ geocoding.lat, geocoding.lon, max_results=5
243
+ )
244
+ housing["nearby_public_housing"] = nearby_housing.model_dump()
245
+ except Exception as exc: # noqa: BLE001 — non-fatal
246
+ housing["nearby_public_housing_error"] = str(exc)
247
+
248
+ facilities_payload: dict[str, Any] | None
249
+ try:
250
+ facilities = await p.places_facilities.search_nearby_facilities(
251
+ geocoding.lat, geocoding.lon
252
+ )
253
+ facilities_payload = facilities.model_dump()
254
+ except Exception as exc: # noqa: BLE001 — non-fatal
255
+ facilities_payload = {"error": str(exc)}
256
+
257
+ crime: dict[str, Any] = {}
258
+ if suburb:
259
+ crime = (await p.crime.get_suburb_crime_stats(suburb)).model_dump()
260
+
261
+ return {
262
+ "address": geocoding.model_dump(),
263
+ "planning": planning.model_dump(),
264
+ "bushfire": bushfire.model_dump(),
265
+ "housing": housing,
266
+ "facilities": facilities_payload,
267
+ "crime": crime,
268
+ }
269
+
270
+
271
+ # -----------------------------------------------------------------------------
272
+ # Argument parsing
273
+ # -----------------------------------------------------------------------------
274
+
275
+
276
+ CommandHandler = Callable[[Providers, argparse.Namespace], Awaitable[dict[str, Any]]]
277
+
278
+
279
+ def _build_parser() -> tuple[argparse.ArgumentParser, dict[str, CommandHandler]]:
280
+ parser = argparse.ArgumentParser(
281
+ prog="vic-property",
282
+ description="Victorian property due-diligence CLI (planning, bushfire, "
283
+ "crime, housing, facilities). Backs the vic-property-check Claude skill.",
284
+ )
285
+ parser.add_argument(
286
+ "--pretty",
287
+ action="store_true",
288
+ help="Indent JSON output for readability.",
289
+ )
290
+ sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
291
+
292
+ handlers: dict[str, CommandHandler] = {}
293
+
294
+ resolve = sub.add_parser(
295
+ "resolve-address",
296
+ help="Geocode a Victorian address to lat/lon plus suburb/postcode.",
297
+ )
298
+ resolve.add_argument("--address", required=True)
299
+ handlers["resolve-address"] = cmd_resolve_address
300
+
301
+ planning = sub.add_parser(
302
+ "planning", help="Planning zones and overlays at a coordinate."
303
+ )
304
+ planning.add_argument("--lat", type=float, required=True)
305
+ planning.add_argument("--lon", type=float, required=True)
306
+ handlers["planning"] = cmd_planning
307
+
308
+ bushfire = sub.add_parser(
309
+ "bushfire", help="Bushfire management overlays and prone areas."
310
+ )
311
+ bushfire.add_argument("--lat", type=float, required=True)
312
+ bushfire.add_argument("--lon", type=float, required=True)
313
+ handlers["bushfire"] = cmd_bushfire
314
+
315
+ housing = sub.add_parser(
316
+ "housing",
317
+ help="Housing stock breakdown for a suburb (private rental / owner / public).",
318
+ )
319
+ housing.add_argument("--suburb", required=True)
320
+ housing.add_argument("--lat", type=float, default=None)
321
+ housing.add_argument("--lon", type=float, default=None)
322
+ handlers["housing"] = cmd_housing
323
+
324
+ crime = sub.add_parser("crime", help="Crime statistics for a suburb.")
325
+ crime.add_argument("--suburb", required=True)
326
+ handlers["crime"] = cmd_crime
327
+
328
+ facilities = sub.add_parser(
329
+ "facilities", help="Transport, shops, and schools near a coordinate."
330
+ )
331
+ facilities.add_argument("--lat", type=float, required=True)
332
+ facilities.add_argument("--lon", type=float, required=True)
333
+ facilities.add_argument("--radius-m", dest="radius_m", type=int, default=2000)
334
+ facilities.add_argument(
335
+ "--max-per-category", dest="max_per_category", type=int, default=5
336
+ )
337
+ handlers["facilities"] = cmd_facilities
338
+
339
+ summarize = sub.add_parser(
340
+ "summarize",
341
+ help="Run every tool and return a combined property report (one address in, "
342
+ "one report out).",
343
+ )
344
+ summarize.add_argument("--address", required=True)
345
+ handlers["summarize"] = cmd_summarize
346
+
347
+ login = sub.add_parser(
348
+ "login",
349
+ help="Save hosted-proxy credentials. Paste the JSON blob the web "
350
+ "/skill-auth page generated, or pipe it via stdin.",
351
+ )
352
+ login.add_argument(
353
+ "--credentials",
354
+ default="-",
355
+ help="Credentials JSON (or '-' to read stdin, the default).",
356
+ )
357
+ handlers["login"] = cmd_login
358
+
359
+ return parser, handlers
360
+
361
+
362
+ # -----------------------------------------------------------------------------
363
+ # Entry point
364
+ # -----------------------------------------------------------------------------
365
+
366
+
367
+ async def _run(args: argparse.Namespace, handler: CommandHandler) -> int:
368
+ # `login` doesn't need the provider stack — it only writes the credentials
369
+ # file. Calling _build_providers() would fail in proxy-only mode where the
370
+ # user is precisely _trying_ to set up credentials.
371
+ providers: Optional[Providers] = None
372
+ if args.command != "login":
373
+ providers = _build_providers()
374
+ try:
375
+ payload = await handler(providers, args) # type: ignore[arg-type]
376
+ except PropertyMCPError as exc:
377
+ json.dump({"error": str(exc), "code": exc.code.value}, sys.stdout)
378
+ sys.stdout.write("\n")
379
+ return 1
380
+ except ConfigurationError as exc:
381
+ print(f"Configuration error: {exc}", file=sys.stderr)
382
+ return 2
383
+ except Exception as exc: # noqa: BLE001 — surface anything we missed
384
+ print(f"Unexpected error: {exc}", file=sys.stderr)
385
+ return 1
386
+ finally:
387
+ if providers is not None:
388
+ await providers.close()
389
+
390
+ indent = 2 if args.pretty else None
391
+ json.dump(payload, sys.stdout, indent=indent, default=str)
392
+ sys.stdout.write("\n")
393
+ return 0 if "error" not in payload else 1
394
+
395
+
396
+ def main() -> None:
397
+ # Default the CLI to WARNING so the structured JSON output isn't drowned out by
398
+ # provider INFO logs. Users can opt back in via the existing
399
+ # VIC_PROPERTY_LOGGING__LEVEL env var.
400
+ import os
401
+
402
+ os.environ.setdefault("VIC_PROPERTY_LOGGING__LEVEL", "WARNING")
403
+ configure_logging()
404
+ parser, handlers = _build_parser()
405
+ args = parser.parse_args()
406
+ handler = handlers[args.command]
407
+ exit_code = asyncio.run(_run(args, handler))
408
+ sys.exit(exit_code)
409
+
410
+
411
+ if __name__ == "__main__":
412
+ main()