ventilatepro-cli 0.2.0__tar.gz → 0.3.0__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 (32) hide show
  1. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/PKG-INFO +48 -2
  2. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/README.md +45 -0
  3. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/pyproject.toml +3 -1
  4. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/tests/test_cli.py +158 -4
  5. ventilatepro_cli-0.3.0/tests/test_mcp_server.py +73 -0
  6. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/__init__.py +1 -1
  7. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/api_client.py +201 -0
  8. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/cli.py +68 -1
  9. ventilatepro_cli-0.3.0/ventilatepro/entities.py +538 -0
  10. ventilatepro_cli-0.3.0/ventilatepro/mcp_cli.py +192 -0
  11. ventilatepro_cli-0.3.0/ventilatepro/mcp_server.py +558 -0
  12. ventilatepro_cli-0.3.0/ventilatepro/payloads.py +43 -0
  13. ventilatepro_cli-0.3.0/ventilatepro/projects.py +120 -0
  14. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/queue.py +8 -1
  15. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/PKG-INFO +48 -2
  16. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/SOURCES.txt +4 -0
  17. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/entry_points.txt +1 -0
  18. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/requires.txt +1 -0
  19. ventilatepro_cli-0.2.0/ventilatepro/entities.py +0 -210
  20. ventilatepro_cli-0.2.0/ventilatepro/projects.py +0 -55
  21. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/setup.cfg +0 -0
  22. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/tests/test_config.py +0 -0
  23. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/__main__.py +0 -0
  24. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/auth.py +0 -0
  25. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/calc.py +0 -0
  26. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/config.py +0 -0
  27. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/design_day.py +0 -0
  28. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/notes.py +0 -0
  29. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/output.py +0 -0
  30. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/systems.py +0 -0
  31. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  32. {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
@@ -1,16 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
7
7
  Project-URL: Support, https://ventilatepro.com/exam/support/
8
- Requires-Python: >=3.9
8
+ Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  Requires-Dist: typer>=0.16.0
11
11
  Requires-Dist: requests>=2.32.0
12
12
  Requires-Dist: keyring>=25.6.0
13
13
  Requires-Dist: platformdirs>=4.3.0
14
+ Requires-Dist: mcp>=1.23.0
14
15
 
15
16
  # VentilatePro CLI
16
17
 
@@ -51,8 +52,53 @@ ventilatepro calc ahu-properties --ahu 1
51
52
  ventilatepro design-day context --project 1
52
53
  ventilatepro notes create --project 1 --body "Captured from terminal"
53
54
  ventilatepro notes sync
55
+ ventilatepro mcp doctor
56
+ ventilatepro-mcp
54
57
  ```
55
58
 
59
+ `ventilatepro-mcp` starts the local stdio MCP server for Codex, Claude Desktop,
60
+ and other MCP clients. It uses the same saved base URL and CLI token created by
61
+ `ventilatepro auth login`.
62
+
63
+ ## MCP setup
64
+
65
+ 1. In the VentilatePro web app, open Account Settings and create a CLI token with
66
+ the `Agent Editor` preset.
67
+ 2. Log in locally:
68
+
69
+ ```bash
70
+ ventilatepro auth login --url https://app.ventilatepro.com --token vpcli_...
71
+ ```
72
+
73
+ 3. Confirm the local setup:
74
+
75
+ ```bash
76
+ ventilatepro mcp doctor
77
+ ```
78
+
79
+ 4. Add the stdio server to Codex:
80
+
81
+ ```bash
82
+ codex mcp add ventilatepro -- ventilatepro-mcp
83
+ ```
84
+
85
+ For JSON-based MCP clients, use:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "ventilatepro": {
91
+ "command": "ventilatepro-mcp",
92
+ "args": []
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ If the client cannot find `ventilatepro-mcp`, run `where ventilatepro-mcp` on
99
+ Windows or `which ventilatepro-mcp` on macOS/Linux and use the full path as the
100
+ command.
101
+
56
102
  If the hidden token prompt is awkward in your terminal, use either:
57
103
 
58
104
  ```bash
@@ -37,8 +37,53 @@ ventilatepro calc ahu-properties --ahu 1
37
37
  ventilatepro design-day context --project 1
38
38
  ventilatepro notes create --project 1 --body "Captured from terminal"
39
39
  ventilatepro notes sync
40
+ ventilatepro mcp doctor
41
+ ventilatepro-mcp
40
42
  ```
41
43
 
44
+ `ventilatepro-mcp` starts the local stdio MCP server for Codex, Claude Desktop,
45
+ and other MCP clients. It uses the same saved base URL and CLI token created by
46
+ `ventilatepro auth login`.
47
+
48
+ ## MCP setup
49
+
50
+ 1. In the VentilatePro web app, open Account Settings and create a CLI token with
51
+ the `Agent Editor` preset.
52
+ 2. Log in locally:
53
+
54
+ ```bash
55
+ ventilatepro auth login --url https://app.ventilatepro.com --token vpcli_...
56
+ ```
57
+
58
+ 3. Confirm the local setup:
59
+
60
+ ```bash
61
+ ventilatepro mcp doctor
62
+ ```
63
+
64
+ 4. Add the stdio server to Codex:
65
+
66
+ ```bash
67
+ codex mcp add ventilatepro -- ventilatepro-mcp
68
+ ```
69
+
70
+ For JSON-based MCP clients, use:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "ventilatepro": {
76
+ "command": "ventilatepro-mcp",
77
+ "args": []
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ If the client cannot find `ventilatepro-mcp`, run `where ventilatepro-mcp` on
84
+ Windows or `which ventilatepro-mcp` on macOS/Linux and use the full path as the
85
+ command.
86
+
42
87
  If the hidden token prompt is awkward in your terminal, use either:
43
88
 
44
89
  ```bash
@@ -6,13 +6,14 @@ build-backend = "setuptools.build_meta"
6
6
  name = "ventilatepro-cli"
7
7
  dynamic = ["version"]
8
8
  description = "VentilatePro CLI"
9
- requires-python = ">=3.9"
9
+ requires-python = ">=3.10"
10
10
  readme = "README.md"
11
11
  dependencies = [
12
12
  "typer>=0.16.0",
13
13
  "requests>=2.32.0",
14
14
  "keyring>=25.6.0",
15
15
  "platformdirs>=4.3.0",
16
+ "mcp>=1.23.0",
16
17
  ]
17
18
 
18
19
  [project.urls]
@@ -22,6 +23,7 @@ Support = "https://ventilatepro.com/exam/support/"
22
23
 
23
24
  [project.scripts]
24
25
  ventilatepro = "ventilatepro.cli:main"
26
+ ventilatepro-mcp = "ventilatepro.mcp_server:main"
25
27
 
26
28
  [tool.setuptools.packages.find]
27
29
  where = ["."]
@@ -101,6 +101,41 @@ class CliTests(unittest.TestCase):
101
101
  os.environ.pop(config.CONFIG_ENV_VAR, None)
102
102
  os.environ.pop(config.DATA_ENV_VAR, None)
103
103
 
104
+ def test_mcp_doctor_json_reports_ok_status(self):
105
+ doctor_payload = {
106
+ "status": "ok",
107
+ "checks": [
108
+ {"name": "saved_login", "ok": True, "required": True, "message": "Saved login found."},
109
+ {"name": "mcp_catalog", "ok": True, "required": True, "message": "MCP catalog loaded."},
110
+ ],
111
+ "summary": {"mcp_catalog": {"tools": 17}},
112
+ "next_steps": [],
113
+ }
114
+
115
+ with mock.patch("ventilatepro.mcp_cli.run_doctor", return_value=doctor_payload):
116
+ result = self.runner.invoke(app, ["mcp", "doctor", "--json"])
117
+
118
+ self.assertEqual(result.exit_code, 0)
119
+ payload = json.loads(result.stdout)
120
+ self.assertEqual(payload["status"], "ok")
121
+ self.assertEqual(payload["summary"]["mcp_catalog"]["tools"], 17)
122
+
123
+ def test_mcp_doctor_exits_nonzero_on_required_failure(self):
124
+ doctor_payload = {
125
+ "status": "failed",
126
+ "checks": [
127
+ {"name": "saved_login", "ok": False, "required": True, "message": "Run login first."},
128
+ ],
129
+ "summary": {},
130
+ "next_steps": ["Run login first."],
131
+ }
132
+
133
+ with mock.patch("ventilatepro.mcp_cli.run_doctor", return_value=doctor_payload):
134
+ result = self.runner.invoke(app, ["mcp", "doctor"])
135
+
136
+ self.assertEqual(result.exit_code, 1)
137
+ self.assertIn("FAIL saved_login", result.output)
138
+
104
139
  def test_notes_create_queues_on_network_error(self):
105
140
  with tempfile.TemporaryDirectory() as tmpdir:
106
141
  os.environ[config.CONFIG_ENV_VAR] = tmpdir
@@ -326,6 +361,65 @@ class CliTests(unittest.TestCase):
326
361
  self.assertIn("Hospital South Wing", result.stdout)
327
362
  fake_client.project_show.assert_called_once_with(7)
328
363
 
364
+ def test_projects_create_reads_json_payload_from_inline_data(self):
365
+ fake_client = mock.Mock()
366
+ fake_client.project_create.return_value = {
367
+ "operation": "created",
368
+ "project": {"id": 9, "name": "CLI Write Project"},
369
+ }
370
+
371
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
372
+ result = self.runner.invoke(
373
+ app,
374
+ ["projects", "create", "--data", '{"name":"CLI Write Project","time_zone":"America/Los_Angeles"}', "--json"],
375
+ )
376
+
377
+ self.assertEqual(result.exit_code, 0)
378
+ fake_client.project_create.assert_called_once_with(
379
+ {"name": "CLI Write Project", "time_zone": "America/Los_Angeles"}
380
+ )
381
+
382
+ def test_notes_update_passes_patch_payload_to_api_client(self):
383
+ fake_client = mock.Mock()
384
+ fake_client.update_note.return_value = {
385
+ "operation": "updated",
386
+ "note": {"note_ref": "general-info:1"},
387
+ }
388
+
389
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
390
+ result = self.runner.invoke(
391
+ app,
392
+ [
393
+ "notes",
394
+ "update",
395
+ "general-info:1",
396
+ "--project",
397
+ "1",
398
+ "--title",
399
+ "Updated note",
400
+ "--tags",
401
+ "field,urgent",
402
+ "--json",
403
+ ],
404
+ )
405
+
406
+ self.assertEqual(result.exit_code, 0)
407
+ fake_client.update_note.assert_called_once_with(
408
+ "general-info:1",
409
+ project=1,
410
+ payload={"title": "Updated note", "tags": ["field", "urgent"]},
411
+ )
412
+
413
+ def test_rooms_delete_requires_yes_confirmation(self):
414
+ fake_client = mock.Mock()
415
+
416
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
417
+ result = self.runner.invoke(app, ["rooms", "delete", "12"])
418
+
419
+ self.assertNotEqual(result.exit_code, 0)
420
+ self.assertIn("requires --yes", result.output)
421
+ fake_client.delete_room.assert_not_called()
422
+
329
423
  def test_rooms_list_passes_filters_to_api_client(self):
330
424
  fake_client = mock.Mock()
331
425
  fake_client.list_rooms.return_value = {
@@ -357,6 +451,65 @@ class CliTests(unittest.TestCase):
357
451
  self.assertEqual(result.exit_code, 0)
358
452
  fake_client.list_rooms.assert_called_once_with(1, limit=5, offset=10, query="operating")
359
453
 
454
+ def test_terminal_units_create_includes_zone_in_payload(self):
455
+ fake_client = mock.Mock()
456
+ fake_client.create_terminal_unit.return_value = {
457
+ "operation": "created",
458
+ "terminal_unit": {"id": 3, "tag": "TU-CLI"},
459
+ }
460
+
461
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
462
+ result = self.runner.invoke(
463
+ app,
464
+ [
465
+ "terminal-units",
466
+ "create",
467
+ "--zone",
468
+ "7",
469
+ "--data",
470
+ '{"tag":"TU-CLI","unit_type":"FCU"}',
471
+ "--json",
472
+ ],
473
+ )
474
+
475
+ self.assertEqual(result.exit_code, 0)
476
+ fake_client.create_terminal_unit.assert_called_once_with(
477
+ {"zone": 7, "tag": "TU-CLI", "unit_type": "FCU"}
478
+ )
479
+
480
+ def test_zones_bulk_assign_ahu_accepts_assignment_list(self):
481
+ fake_client = mock.Mock()
482
+ fake_client.assign_zones_to_ahu.return_value = {
483
+ "operation": "assigned",
484
+ "updated": 2,
485
+ "errors": 0,
486
+ }
487
+
488
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
489
+ result = self.runner.invoke(
490
+ app,
491
+ [
492
+ "zones",
493
+ "bulk-assign-ahu",
494
+ "--project",
495
+ "1",
496
+ "--data",
497
+ '[{"zone_id":1,"ahu_id":2},{"zone_id":3,"ahu_id":2}]',
498
+ "--json",
499
+ ],
500
+ )
501
+
502
+ self.assertEqual(result.exit_code, 0)
503
+ fake_client.assign_zones_to_ahu.assert_called_once_with(
504
+ {
505
+ "project": 1,
506
+ "assignments": [
507
+ {"zone_id": 1, "ahu_id": 2},
508
+ {"zone_id": 3, "ahu_id": 2},
509
+ ],
510
+ }
511
+ )
512
+
360
513
  def test_calc_recalculate_stale_passes_mode_scope_and_force(self):
361
514
  fake_client = mock.Mock()
362
515
  fake_client.calc_recalculate_stale.return_value = {
@@ -390,14 +543,15 @@ class CliTests(unittest.TestCase):
390
543
  "state": "QUEUED",
391
544
  }
392
545
 
393
- with tempfile.NamedTemporaryFile("w+", encoding="utf-8") as handle:
394
- json.dump({"zones": [{"zone_id": 1}]}, handle)
395
- handle.flush()
546
+ with tempfile.TemporaryDirectory() as tmpdir:
547
+ payload_path = os.path.join(tmpdir, "design-day-request.json")
548
+ with open(payload_path, "w", encoding="utf-8") as handle:
549
+ json.dump({"zones": [{"zone_id": 1}]}, handle)
396
550
 
397
551
  with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
398
552
  result = self.runner.invoke(
399
553
  app,
400
- ["design-day", "run", "--project", "1", "--input", handle.name, "--json"],
554
+ ["design-day", "run", "--project", "1", "--input", payload_path, "--json"],
401
555
  )
402
556
 
403
557
  self.assertEqual(result.exit_code, 0)
@@ -0,0 +1,73 @@
1
+ import asyncio
2
+ import unittest
3
+ from unittest import mock
4
+
5
+ from ventilatepro.mcp_server import _requires_confirmation, build_server
6
+
7
+
8
+ class FakeClient:
9
+ def __init__(self):
10
+ self.calls = []
11
+
12
+ def schemas(self):
13
+ return {"schemas": {"api_request": {"description": "ok"}}}
14
+
15
+ def schema(self, schema_key):
16
+ return {"key": schema_key}
17
+
18
+ def auth_me(self):
19
+ return {"username": "agent", "scopes": ["projects:read"]}
20
+
21
+ def projects_list(self, *, limit=20, offset=0, query=None):
22
+ return {"count": 0, "limit": limit, "offset": offset, "query": query, "results": []}
23
+
24
+ def cli_proxy(self, method, api_path, *, json_payload=None, params=None):
25
+ self.calls.append((method, api_path, json_payload, params))
26
+ return {"method": method, "path": api_path, "payload": json_payload, "params": params}
27
+
28
+
29
+ class McpServerTests(unittest.TestCase):
30
+ def test_tool_catalog_contains_core_tools(self):
31
+ async def run():
32
+ server = build_server()
33
+ tools = await server.list_tools()
34
+ names = {tool.name for tool in tools}
35
+ self.assertIn("vp_whoami", names)
36
+ self.assertIn("vp_api_request", names)
37
+ self.assertIn("vp_list_entities", names)
38
+ self.assertIn("vp_run_workflow", names)
39
+
40
+ asyncio.run(run())
41
+
42
+ def test_api_request_tool_uses_cli_proxy(self):
43
+ async def run():
44
+ server = build_server()
45
+ fake_client = FakeClient()
46
+ with mock.patch(
47
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
48
+ return_value=(fake_client, "http://localhost:8000"),
49
+ ):
50
+ _content, structured = await server.call_tool(
51
+ "vp_api_request",
52
+ {
53
+ "method": "GET",
54
+ "path": "projects/1/",
55
+ "params": {"include": "summary"},
56
+ },
57
+ )
58
+
59
+ self.assertEqual(structured["result"]["path"], "projects/1/")
60
+ self.assertEqual(fake_client.calls[0], ("GET", "projects/1/", None, {"include": "summary"}))
61
+
62
+ asyncio.run(run())
63
+
64
+ def test_guarded_operations_require_confirmation(self):
65
+ self.assertTrue(_requires_confirmation("DELETE", "rooms/1/", None))
66
+ self.assertTrue(_requires_confirmation("POST", "projects/1/recalculate-stale/", {}))
67
+ self.assertTrue(_requires_confirmation("POST", "projects/1/design-day/run/", {}))
68
+ self.assertTrue(_requires_confirmation("PATCH", "projects/1/", {"force": True}))
69
+ self.assertFalse(_requires_confirmation("GET", "projects/1/", None))
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.0"
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from pathlib import Path
3
4
  from typing import Any, Dict, Optional
5
+ from urllib.parse import quote
4
6
 
5
7
  import requests
6
8
 
@@ -78,9 +80,130 @@ class ApiClient:
78
80
  )
79
81
  return payload if payload is not None else {}
80
82
 
83
+ def raw_request(
84
+ self,
85
+ method: str,
86
+ path: str,
87
+ *,
88
+ json_payload: Optional[Dict[str, Any]] = None,
89
+ headers: Optional[Dict[str, str]] = None,
90
+ params: Optional[Dict[str, Any]] = None,
91
+ files: Optional[Dict[str, Any]] = None,
92
+ ):
93
+ final_headers = {
94
+ "Accept": "*/*",
95
+ }
96
+ if headers:
97
+ final_headers.update(headers)
98
+ if self.token:
99
+ final_headers["Authorization"] = f"Bearer {self.token}"
100
+ try:
101
+ response = requests.request(
102
+ method=method,
103
+ url=f"{self.base_url}{path}",
104
+ json=json_payload if files is None else None,
105
+ data=None if files is None else (json_payload or {}),
106
+ files=files,
107
+ headers=final_headers,
108
+ params=params,
109
+ timeout=self.timeout,
110
+ )
111
+ except requests.RequestException as exc:
112
+ raise NetworkError(str(exc)) from exc
113
+
114
+ if not response.ok:
115
+ payload: Any = None
116
+ if response.content:
117
+ try:
118
+ payload = response.json()
119
+ except ValueError:
120
+ payload = response.text
121
+ raise ApiError(
122
+ status_code=response.status_code,
123
+ message=extract_error_message(payload, f"Request failed with status {response.status_code}."),
124
+ payload=payload,
125
+ )
126
+ return response
127
+
128
+ @staticmethod
129
+ def _proxy_path(api_path: str) -> str:
130
+ normalized = (api_path or "").strip().lstrip("/")
131
+ if not normalized:
132
+ raise ValueError("api_path is required.")
133
+ return f"/api/cli/proxy/{quote(normalized, safe='/:-_')}"
134
+
81
135
  def auth_me(self):
82
136
  return self.request("GET", "/api/cli/auth/me/")
83
137
 
138
+ def schemas(self):
139
+ return self.request("GET", "/api/cli/schemas/")
140
+
141
+ def schema(self, schema_key: str):
142
+ return self.request("GET", f"/api/cli/schemas/{quote(schema_key, safe='')}/")
143
+
144
+ def cli_proxy(
145
+ self,
146
+ method: str,
147
+ api_path: str,
148
+ *,
149
+ json_payload: Optional[Dict[str, Any]] = None,
150
+ params: Optional[Dict[str, Any]] = None,
151
+ ):
152
+ return self.request(
153
+ method.upper(),
154
+ self._proxy_path(api_path),
155
+ json_payload=json_payload,
156
+ params=params,
157
+ )
158
+
159
+ def cli_proxy_download(
160
+ self,
161
+ method: str,
162
+ api_path: str,
163
+ output_path: str,
164
+ *,
165
+ json_payload: Optional[Dict[str, Any]] = None,
166
+ params: Optional[Dict[str, Any]] = None,
167
+ ):
168
+ response = self.raw_request(
169
+ method.upper(),
170
+ self._proxy_path(api_path),
171
+ json_payload=json_payload,
172
+ params=params,
173
+ )
174
+ target = Path(output_path).expanduser()
175
+ target.parent.mkdir(parents=True, exist_ok=True)
176
+ target.write_bytes(response.content)
177
+ return {
178
+ "path": str(target),
179
+ "size_bytes": len(response.content),
180
+ "content_type": response.headers.get("content-type", ""),
181
+ "content_disposition": response.headers.get("content-disposition", ""),
182
+ }
183
+
184
+ def cli_proxy_upload(
185
+ self,
186
+ api_path: str,
187
+ file_path: str,
188
+ *,
189
+ fields: Optional[Dict[str, Any]] = None,
190
+ ):
191
+ target = Path(file_path).expanduser()
192
+ if not target.exists():
193
+ raise ValueError(f"File does not exist: {target}")
194
+ with target.open("rb") as handle:
195
+ response = self.raw_request(
196
+ "POST",
197
+ self._proxy_path(api_path),
198
+ json_payload={key: str(value) for key, value in (fields or {}).items()},
199
+ files={"file": (target.name, handle)},
200
+ headers={"Accept": "application/json"},
201
+ )
202
+ try:
203
+ return response.json() if response.content else {}
204
+ except ValueError:
205
+ return {"text": response.text}
206
+
84
207
  def projects_list(self, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
85
208
  params: Dict[str, Any] = {"limit": limit, "offset": offset}
86
209
  if query:
@@ -90,6 +213,15 @@ class ApiClient:
90
213
  def project_show(self, project_id: int):
91
214
  return self.request("GET", f"/api/cli/projects/{project_id}/")
92
215
 
216
+ def project_create(self, payload: Dict[str, Any]):
217
+ return self.request("POST", "/api/cli/projects/", json_payload=payload)
218
+
219
+ def project_update(self, project_id: int, payload: Dict[str, Any]):
220
+ return self.request("PATCH", f"/api/cli/projects/{project_id}/", json_payload=payload)
221
+
222
+ def project_delete(self, project_id: int):
223
+ return self.request("DELETE", f"/api/cli/projects/{project_id}/")
224
+
93
225
  def _list_project_entities(
94
226
  self,
95
227
  entity_name: str,
@@ -107,30 +239,93 @@ class ApiClient:
107
239
  def _show_entity(self, entity_name: str, entity_id: int):
108
240
  return self.request("GET", f"/api/cli/{entity_name}/{entity_id}/")
109
241
 
242
+ def _create_entity(self, entity_name: str, payload: Dict[str, Any]):
243
+ return self.request("POST", f"/api/cli/{entity_name}/", json_payload=payload)
244
+
245
+ def _update_entity(self, entity_name: str, entity_id: int, payload: Dict[str, Any]):
246
+ return self.request("PATCH", f"/api/cli/{entity_name}/{entity_id}/", json_payload=payload)
247
+
248
+ def _delete_entity(self, entity_name: str, entity_id: int):
249
+ return self.request("DELETE", f"/api/cli/{entity_name}/{entity_id}/")
250
+
110
251
  def list_rooms(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
111
252
  return self._list_project_entities("rooms", project, limit=limit, offset=offset, query=query)
112
253
 
113
254
  def show_room(self, room_id: int):
114
255
  return self._show_entity("rooms", room_id)
115
256
 
257
+ def create_room(self, payload: Dict[str, Any]):
258
+ return self._create_entity("rooms", payload)
259
+
260
+ def update_room(self, room_id: int, payload: Dict[str, Any]):
261
+ return self._update_entity("rooms", room_id, payload)
262
+
263
+ def delete_room(self, room_id: int):
264
+ return self._delete_entity("rooms", room_id)
265
+
116
266
  def list_zones(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
117
267
  return self._list_project_entities("zones", project, limit=limit, offset=offset, query=query)
118
268
 
119
269
  def show_zone(self, zone_id: int):
120
270
  return self._show_entity("zones", zone_id)
121
271
 
272
+ def create_zone(self, payload: Dict[str, Any]):
273
+ return self._create_entity("zones", payload)
274
+
275
+ def update_zone(self, zone_id: int, payload: Dict[str, Any]):
276
+ return self._update_entity("zones", zone_id, payload)
277
+
278
+ def delete_zone(self, zone_id: int):
279
+ return self._delete_entity("zones", zone_id)
280
+
281
+ def assign_zones_to_ahu(self, payload: Dict[str, Any]):
282
+ return self.request("POST", "/api/cli/zones/assign-ahu/", json_payload=payload)
283
+
122
284
  def list_ahus(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
123
285
  return self._list_project_entities("ahus", project, limit=limit, offset=offset, query=query)
124
286
 
125
287
  def show_ahu(self, ahu_id: int):
126
288
  return self._show_entity("ahus", ahu_id)
127
289
 
290
+ def create_ahu(self, payload: Dict[str, Any]):
291
+ return self._create_entity("ahus", payload)
292
+
293
+ def update_ahu(self, ahu_id: int, payload: Dict[str, Any]):
294
+ return self._update_entity("ahus", ahu_id, payload)
295
+
296
+ def delete_ahu(self, ahu_id: int):
297
+ return self._delete_entity("ahus", ahu_id)
298
+
299
+ def list_terminal_units(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
300
+ return self._list_project_entities("terminal-units", project, limit=limit, offset=offset, query=query)
301
+
302
+ def show_terminal_unit(self, terminal_unit_id: int):
303
+ return self._show_entity("terminal-units", terminal_unit_id)
304
+
305
+ def create_terminal_unit(self, payload: Dict[str, Any]):
306
+ return self._create_entity("terminal-units", payload)
307
+
308
+ def update_terminal_unit(self, terminal_unit_id: int, payload: Dict[str, Any]):
309
+ return self._update_entity("terminal-units", terminal_unit_id, payload)
310
+
311
+ def delete_terminal_unit(self, terminal_unit_id: int):
312
+ return self._delete_entity("terminal-units", terminal_unit_id)
313
+
128
314
  def list_plants(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
129
315
  return self._list_project_entities("plants", project, limit=limit, offset=offset, query=query)
130
316
 
131
317
  def show_plant(self, plant_ref: str):
132
318
  return self.request("GET", f"/api/cli/plants/{plant_ref}/")
133
319
 
320
+ def create_plant(self, payload: Dict[str, Any]):
321
+ return self.request("POST", "/api/cli/plants/", json_payload=payload)
322
+
323
+ def update_plant(self, plant_ref: str, payload: Dict[str, Any]):
324
+ return self.request("PATCH", f"/api/cli/plants/{plant_ref}/", json_payload=payload)
325
+
326
+ def delete_plant(self, plant_ref: str):
327
+ return self.request("DELETE", f"/api/cli/plants/{plant_ref}/")
328
+
134
329
  def system_hierarchy(self, project_id: int, *, include_envelope: bool = True):
135
330
  return self.request(
136
331
  "GET",
@@ -219,3 +414,9 @@ class ApiClient:
219
414
 
220
415
  def show_note(self, note_ref: str, *, project: int):
221
416
  return self.request("GET", f"/api/cli/notes/{note_ref}/", params={"project": project})
417
+
418
+ def update_note(self, note_ref: str, *, project: int, payload: Dict[str, Any]):
419
+ return self.request("PATCH", f"/api/cli/notes/{note_ref}/", json_payload=payload, params={"project": project})
420
+
421
+ def delete_note(self, note_ref: str, *, project: int):
422
+ return self.request("DELETE", f"/api/cli/notes/{note_ref}/", params={"project": project})