kctl-api 0.2.0__py3-none-any.whl

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 (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,243 @@
1
+ """Production monitoring commands for kctl-api.
2
+
3
+ Real-time metrics, alerts, and uptime monitoring.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from kctl_api.core.callbacks import AppContext
14
+
15
+ app = typer.Typer(name="monitor", help="Production monitoring — live, metrics, uptime.", no_args_is_help=True)
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # live
20
+ # ---------------------------------------------------------------------------
21
+ @app.command()
22
+ def live(
23
+ ctx: typer.Context,
24
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Refresh interval in seconds.")] = 5,
25
+ ) -> None:
26
+ """Live dashboard — API health, DB, Redis, container status refreshed every N seconds."""
27
+ actx: AppContext = ctx.obj
28
+ out = actx.output
29
+
30
+ import asyncio
31
+ import contextlib
32
+ import datetime
33
+ import subprocess
34
+
35
+ from kctl_api.core.utils import find_project_root
36
+
37
+ root = find_project_root()
38
+
39
+ def _get_container_status() -> list[dict]:
40
+ result = subprocess.run(
41
+ ["docker", "compose", "-f", str(root / "docker-compose.yml"), "ps", "--format", "json"],
42
+ capture_output=True,
43
+ text=True,
44
+ )
45
+ if result.returncode != 0:
46
+ return []
47
+ import json
48
+
49
+ containers = []
50
+ for line in result.stdout.strip().splitlines():
51
+ if line.strip():
52
+ with contextlib.suppress(json.JSONDecodeError):
53
+ containers.append(json.loads(line))
54
+ return containers
55
+
56
+ async def _check_services() -> dict:
57
+ checks: dict[str, str] = {}
58
+
59
+ # API
60
+ try:
61
+ healthy, _ = actx.client.health_check()
62
+ checks["api-main"] = "[green]ok[/green]" if healthy else "[red]error[/red]"
63
+ except Exception:
64
+ checks["api-main"] = "[red]down[/red]"
65
+
66
+ # DB
67
+ try:
68
+ from kctl_api.core.db import dispose_engine, execute_query
69
+
70
+ db_url = actx.database_url
71
+ if db_url:
72
+ await execute_query(db_url, "SELECT 1")
73
+ await dispose_engine()
74
+ checks["postgresql"] = "[green]ok[/green]"
75
+ else:
76
+ checks["postgresql"] = "[yellow]no url[/yellow]"
77
+ except Exception:
78
+ checks["postgresql"] = "[red]error[/red]"
79
+
80
+ # Redis
81
+ try:
82
+ from kctl_api.core.redis import close_redis, get_redis
83
+
84
+ redis_url = actx.redis_url
85
+ if redis_url:
86
+ client = get_redis(redis_url)
87
+ await client.ping()
88
+ await close_redis()
89
+ checks["redis"] = "[green]ok[/green]"
90
+ else:
91
+ checks["redis"] = "[yellow]no url[/yellow]"
92
+ except Exception:
93
+ checks["redis"] = "[red]error[/red]"
94
+
95
+ return checks
96
+
97
+ console = out.console # type: ignore[attr-defined]
98
+
99
+ try:
100
+ while True:
101
+ console.clear()
102
+ now = datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
103
+ out.header(f"Live Monitor — {now} (refreshing every {interval}s)")
104
+
105
+ # Service checks
106
+ checks = asyncio.run(_check_services())
107
+ out.text("")
108
+ out.text(" [bold]Services:[/bold]")
109
+ for svc, status in checks.items():
110
+ out.text(f" {svc:<20} {status}")
111
+
112
+ # Container status
113
+ containers = _get_container_status()
114
+ if containers:
115
+ out.text("")
116
+ out.text(" [bold]Containers:[/bold]")
117
+ for c in containers:
118
+ name = c.get("Name", c.get("Service", ""))
119
+ state = c.get("State", c.get("Status", ""))
120
+ health = c.get("Health", "")
121
+ indicator = "[green]up[/green]" if "running" in state.lower() else "[red]down[/red]"
122
+ health_str = f" ({health})" if health else ""
123
+ out.text(f" {name:<30} {indicator}{health_str}")
124
+
125
+ out.text("")
126
+ out.text(" [dim]Press Ctrl+C to stop[/dim]")
127
+
128
+ time.sleep(interval)
129
+ except KeyboardInterrupt:
130
+ out.info("Monitoring stopped.")
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # metrics
135
+ # ---------------------------------------------------------------------------
136
+ @app.command()
137
+ def metrics(ctx: typer.Context) -> None:
138
+ """Fetch metrics from the API (Prometheus endpoint if available)."""
139
+ actx: AppContext = ctx.obj
140
+ out = actx.output
141
+
142
+ # Try /metrics endpoint (Prometheus format)
143
+ metrics_endpoints = ["/metrics", "/api/v1/metrics", "/api/v1/health"]
144
+
145
+ for endpoint in metrics_endpoints:
146
+ try:
147
+ response = actx.client.get_raw(endpoint)
148
+ if response.status_code == 200:
149
+ content_type = response.headers.get("content-type", "")
150
+ if "text/plain" in content_type or "prometheus" in content_type:
151
+ out.header(f"Metrics: {endpoint}")
152
+ # Show first 50 lines
153
+ lines = response.text.splitlines()[:50]
154
+ for line in lines:
155
+ if not line.startswith("#"):
156
+ out.text(f" {line}")
157
+ if len(response.text.splitlines()) > 50:
158
+ out.text(f" ... ({len(response.text.splitlines())} total lines)")
159
+ return
160
+ elif "json" in content_type:
161
+ import json
162
+
163
+ data = json.loads(response.text)
164
+ out.detail(
165
+ title=f"Metrics: {endpoint}",
166
+ sections=[("Data", [(k, str(v)) for k, v in data.items() if not isinstance(v, dict)])],
167
+ data_for_json=data,
168
+ )
169
+ return
170
+ except Exception:
171
+ continue
172
+
173
+ out.warn("No metrics endpoint found. Consider adding prometheus-fastapi-instrumentator.")
174
+ out.info(" uv add prometheus-fastapi-instrumentator")
175
+ out.info(" Exposes: GET /metrics (Prometheus format)")
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # uptime
180
+ # ---------------------------------------------------------------------------
181
+ @app.command()
182
+ def uptime(
183
+ ctx: typer.Context,
184
+ duration: Annotated[int, typer.Option("--duration", help="Monitor uptime for N seconds (0 = single check).")] = 0,
185
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Check interval in seconds.")] = 10,
186
+ ) -> None:
187
+ """Monitor API uptime — single check or continuous with SLA tracking."""
188
+ actx: AppContext = ctx.obj
189
+ out = actx.output
190
+
191
+ def _check() -> tuple[bool, float]:
192
+ start = time.monotonic()
193
+ try:
194
+ healthy, _ = actx.client.health_check()
195
+ elapsed_ms = (time.monotonic() - start) * 1000
196
+ return healthy, round(elapsed_ms, 1)
197
+ except Exception:
198
+ elapsed_ms = (time.monotonic() - start) * 1000
199
+ return False, round(elapsed_ms, 1)
200
+
201
+ if duration == 0:
202
+ # Single check
203
+ healthy, latency_ms = _check()
204
+ status = "[green]UP[/green]" if healthy else "[red]DOWN[/red]"
205
+ out.text(f" Status: {status}")
206
+ out.text(f" Latency: {latency_ms}ms")
207
+ if actx.json_mode:
208
+ out.raw_json({"healthy": healthy, "latency_ms": latency_ms})
209
+ if not healthy:
210
+ raise typer.Exit(1)
211
+ return
212
+
213
+ # Continuous monitoring
214
+ out.info(f"Monitoring uptime for {duration}s (interval={interval}s) ...")
215
+ checks: list[tuple[bool, float]] = []
216
+ deadline = time.monotonic() + duration
217
+
218
+ try:
219
+ while time.monotonic() < deadline:
220
+ healthy, latency_ms = _check()
221
+ checks.append((healthy, latency_ms))
222
+ status = "[green]UP[/green]" if healthy else "[red]DOWN[/red]"
223
+ out.text(f" [{len(checks)}] {status} ({latency_ms}ms)")
224
+ if time.monotonic() < deadline:
225
+ time.sleep(min(interval, deadline - time.monotonic()))
226
+ except KeyboardInterrupt:
227
+ out.info("Stopped.")
228
+
229
+ if not checks:
230
+ return
231
+
232
+ total = len(checks)
233
+ up_count = sum(1 for h, _ in checks if h)
234
+ sla = round(up_count / total * 100, 2)
235
+ avg_lat = round(sum(lat for _, lat in checks) / total, 1)
236
+
237
+ out.text("")
238
+ out.text(f" Checks: {total}")
239
+ out.text(f" Uptime: [{'green' if sla >= 99 else 'yellow'}]{sla}%[/{'green' if sla >= 99 else 'yellow'}]")
240
+ out.text(f" Avg latency: {avg_lat}ms")
241
+
242
+ if actx.json_mode:
243
+ out.raw_json({"total": total, "up": up_count, "sla_pct": sla, "avg_latency_ms": avg_lat})
@@ -0,0 +1,132 @@
1
+ """Notification commands for kctl-api.
2
+
3
+ Send messages via Mattermost, Telegram, broadcast, and test.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_api.core.callbacks import AppContext
13
+ from kctl_api.core.exceptions import APIError, AuthenticationError
14
+ from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
15
+
16
+ app = typer.Typer(
17
+ name="notifications", help="Send notifications — Mattermost, Telegram, broadcast.", no_args_is_help=True
18
+ )
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # mattermost
23
+ # ---------------------------------------------------------------------------
24
+ @app.command()
25
+ def mattermost(
26
+ ctx: typer.Context,
27
+ message: Annotated[str, typer.Argument(help="Message text to send.")],
28
+ channel: Annotated[str, typer.Option("--channel", "-c", help="Channel ID or name.")] = "",
29
+ ) -> None:
30
+ """Send a Mattermost notification via POST /api/v1/notifications/mattermost."""
31
+ actx: AppContext = ctx.obj
32
+ out = actx.output
33
+
34
+ payload: dict = {"message": message}
35
+ if channel:
36
+ payload["channel"] = channel
37
+
38
+ try:
39
+ result = actx.client.post("/api/v1/notifications/mattermost", json=payload)
40
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
41
+ out.error(str(e))
42
+ raise typer.Exit(1) from None
43
+
44
+ out.success("Mattermost notification sent.")
45
+ if actx.json_mode:
46
+ out.raw_json(result)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # telegram
51
+ # ---------------------------------------------------------------------------
52
+ @app.command()
53
+ def telegram(
54
+ ctx: typer.Context,
55
+ message: Annotated[str, typer.Argument(help="Message text to send.")],
56
+ chat_id: Annotated[str, typer.Option("--chat-id", "-c", help="Telegram chat ID.")] = "",
57
+ ) -> None:
58
+ """Send a Telegram notification via POST /api/v1/notifications/telegram."""
59
+ actx: AppContext = ctx.obj
60
+ out = actx.output
61
+
62
+ payload: dict = {"message": message}
63
+ if chat_id:
64
+ payload["chat_id"] = chat_id
65
+
66
+ try:
67
+ result = actx.client.post("/api/v1/notifications/telegram", json=payload)
68
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
69
+ out.error(str(e))
70
+ raise typer.Exit(1) from None
71
+
72
+ out.success("Telegram notification sent.")
73
+ if actx.json_mode:
74
+ out.raw_json(result)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # broadcast
79
+ # ---------------------------------------------------------------------------
80
+ @app.command()
81
+ def broadcast(
82
+ ctx: typer.Context,
83
+ message: Annotated[str, typer.Argument(help="Broadcast message.")],
84
+ channel_type: Annotated[str, typer.Option("--type", "-t", help="Channel type: all, mattermost, telegram.")] = "all",
85
+ ) -> None:
86
+ """Broadcast a message to all notification channels via POST /api/v1/notifications/broadcast."""
87
+ actx: AppContext = ctx.obj
88
+ out = actx.output
89
+
90
+ payload: dict = {"message": message, "channel_type": channel_type}
91
+
92
+ try:
93
+ result = actx.client.post("/api/v1/notifications/broadcast", json=payload)
94
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
95
+ out.error(str(e))
96
+ raise typer.Exit(1) from None
97
+
98
+ out.success(f"Broadcast sent ({channel_type}).")
99
+ if actx.json_mode:
100
+ out.raw_json(result)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # test
105
+ # ---------------------------------------------------------------------------
106
+ @app.command(name="test")
107
+ def test_notification(
108
+ ctx: typer.Context,
109
+ channel_type: Annotated[
110
+ str, typer.Option("--type", "-t", help="Channel type: mattermost, telegram.")
111
+ ] = "mattermost",
112
+ ) -> None:
113
+ """Send a test notification to verify configuration.
114
+
115
+ Sends a test message via the broadcast endpoint.
116
+ """
117
+ actx: AppContext = ctx.obj
118
+ out = actx.output
119
+
120
+ test_msg = f"[kctl-api] Test notification via {channel_type}"
121
+ try:
122
+ result = actx.client.post(
123
+ "/api/v1/notifications/broadcast",
124
+ json={"message": test_msg, "channel_type": channel_type},
125
+ )
126
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
127
+ out.error(str(e))
128
+ raise typer.Exit(1) from None
129
+
130
+ out.success(f"Test notification sent via {channel_type}.")
131
+ if actx.json_mode:
132
+ out.raw_json(result)
@@ -0,0 +1,182 @@
1
+ """Odoo proxy commands for kctl-api.
2
+
3
+ Admin-only JSON-RPC proxy calls to Odoo backend.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_api.core.callbacks import AppContext
13
+ from kctl_api.core.exceptions import APIError, AuthenticationError
14
+ from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
15
+
16
+ app = typer.Typer(name="odoo", help="Odoo proxy — search-read, call, create, write (admin-only).", no_args_is_help=True)
17
+
18
+ _BASE = "/api/v1/odoo"
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # search-read
23
+ # ---------------------------------------------------------------------------
24
+ @app.command(name="search-read")
25
+ def search_read(
26
+ ctx: typer.Context,
27
+ model: Annotated[str, typer.Argument(help="Odoo model name (e.g. res.partner).")],
28
+ domain: Annotated[str, typer.Option("--domain", "-d", help="JSON domain filter.")] = "[]",
29
+ fields: Annotated[str, typer.Option("--fields", "-f", help="Comma-separated field names.")] = "",
30
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max records to return.")] = 20,
31
+ ) -> None:
32
+ """Search and read Odoo records via POST /api/v1/odoo/search-read (admin-only)."""
33
+ actx: AppContext = ctx.obj
34
+ out = actx.output
35
+
36
+ import json
37
+
38
+ try:
39
+ domain_parsed = json.loads(domain)
40
+ except json.JSONDecodeError as e:
41
+ out.error(f"Invalid domain JSON: {e}")
42
+ raise typer.Exit(1) from None
43
+
44
+ payload: dict = {
45
+ "model": model,
46
+ "domain": domain_parsed,
47
+ "limit": limit,
48
+ }
49
+ if fields:
50
+ payload["fields"] = [f.strip() for f in fields.split(",")]
51
+
52
+ try:
53
+ result = actx.client.post(f"{_BASE}/search-read", json=payload)
54
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
55
+ out.error(str(e))
56
+ raise typer.Exit(1) from None
57
+
58
+ records = result if isinstance(result, list) else result.get("records", []) if isinstance(result, dict) else []
59
+ out.info(f"Found {len(records)} record(s).")
60
+
61
+ if actx.json_mode:
62
+ out.raw_json(records)
63
+ elif records:
64
+ # Auto-detect columns from first record
65
+ cols = list(records[0].keys()) if records else []
66
+ rows = [[str(r.get(c, "")) for c in cols] for r in records]
67
+ out.table(
68
+ title=f"{model} ({len(records)} records)",
69
+ columns=[(c, "") for c in cols],
70
+ rows=rows,
71
+ data_for_json=records,
72
+ )
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # call
77
+ # ---------------------------------------------------------------------------
78
+ @app.command()
79
+ def call(
80
+ ctx: typer.Context,
81
+ model: Annotated[str, typer.Argument(help="Odoo model name.")],
82
+ method: Annotated[str, typer.Argument(help="Method to call.")],
83
+ args_json: Annotated[str, typer.Option("--args", "-a", help="JSON args array.")] = "[]",
84
+ kwargs_json: Annotated[str, typer.Option("--kwargs", "-k", help="JSON kwargs object.")] = "{}",
85
+ ) -> None:
86
+ """Call an Odoo model method via POST /api/v1/odoo/call (admin-only)."""
87
+ actx: AppContext = ctx.obj
88
+ out = actx.output
89
+
90
+ import json
91
+
92
+ try:
93
+ args = json.loads(args_json)
94
+ kwargs = json.loads(kwargs_json)
95
+ except json.JSONDecodeError as e:
96
+ out.error(f"Invalid JSON: {e}")
97
+ raise typer.Exit(1) from None
98
+
99
+ payload: dict = {"model": model, "method": method, "args": args, "kwargs": kwargs}
100
+
101
+ try:
102
+ result = actx.client.post(f"{_BASE}/call", json=payload)
103
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
104
+ out.error(str(e))
105
+ raise typer.Exit(1) from None
106
+
107
+ out.success(f"Called {model}.{method}")
108
+ if actx.json_mode:
109
+ out.raw_json(result)
110
+ elif result:
111
+ out.text(str(result))
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # create
116
+ # ---------------------------------------------------------------------------
117
+ @app.command()
118
+ def create(
119
+ ctx: typer.Context,
120
+ model: Annotated[str, typer.Argument(help="Odoo model name.")],
121
+ values_json: Annotated[str, typer.Argument(help="JSON object with field values.")],
122
+ ) -> None:
123
+ """Create an Odoo record via POST /api/v1/odoo/create (admin-only)."""
124
+ actx: AppContext = ctx.obj
125
+ out = actx.output
126
+
127
+ import json
128
+
129
+ try:
130
+ values = json.loads(values_json)
131
+ except json.JSONDecodeError as e:
132
+ out.error(f"Invalid JSON: {e}")
133
+ raise typer.Exit(1) from None
134
+
135
+ payload: dict = {"model": model, "values": values}
136
+
137
+ try:
138
+ result = actx.client.post(f"{_BASE}/create", json=payload)
139
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
140
+ out.error(str(e))
141
+ raise typer.Exit(1) from None
142
+
143
+ out.success(f"Created {model} record.")
144
+ if actx.json_mode:
145
+ out.raw_json(result)
146
+ elif result:
147
+ out.text(str(result))
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # write
152
+ # ---------------------------------------------------------------------------
153
+ @app.command()
154
+ def write(
155
+ ctx: typer.Context,
156
+ model: Annotated[str, typer.Argument(help="Odoo model name.")],
157
+ record_id: Annotated[int, typer.Argument(help="Record ID to update.")],
158
+ values_json: Annotated[str, typer.Argument(help="JSON object with field values.")],
159
+ ) -> None:
160
+ """Update an Odoo record via POST /api/v1/odoo/write (admin-only)."""
161
+ actx: AppContext = ctx.obj
162
+ out = actx.output
163
+
164
+ import json
165
+
166
+ try:
167
+ values = json.loads(values_json)
168
+ except json.JSONDecodeError as e:
169
+ out.error(f"Invalid JSON: {e}")
170
+ raise typer.Exit(1) from None
171
+
172
+ payload: dict = {"model": model, "ids": [record_id], "values": values}
173
+
174
+ try:
175
+ result = actx.client.post(f"{_BASE}/write", json=payload)
176
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
177
+ out.error(str(e))
178
+ raise typer.Exit(1) from None
179
+
180
+ out.success(f"Updated {model} record {record_id}.")
181
+ if actx.json_mode:
182
+ out.raw_json(result)