flowbase-cli 0.1.12__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.
flowbase_cli/main.py ADDED
@@ -0,0 +1,1500 @@
1
+ """Command line client for the FlowBase OpenAPI."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from http.client import HTTPException as TransportError
6
+ from decimal import Decimal
7
+ import json
8
+ import mimetypes
9
+ import os
10
+ import secrets
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.error import HTTPError, URLError
15
+ from urllib.parse import quote, urlencode
16
+ from urllib.request import Request, urlopen
17
+
18
+ from flowbase_cli.endpoints import (
19
+ CATALOGS,
20
+ AUTHORING,
21
+ ENDPOINTS,
22
+ ENDPOINT_BY_KEY,
23
+ PENDING_MANAGEMENT_OPERATIONS,
24
+ Endpoint,
25
+ )
26
+
27
+ DEFAULT_HOST = "http://flowbase.localhost:5001"
28
+ API_PREFIX = "/api/openapi/v1"
29
+ ENV_HOST = "FLOWBASE_HOST"
30
+ ENV_API_KEY = "FLOWBASE_API_KEY"
31
+
32
+
33
+ class CliError(Exception):
34
+ """Expected user-facing CLI failure."""
35
+
36
+
37
+ def api_root(host: str) -> str:
38
+ normalized = host.strip().rstrip("/")
39
+ if not normalized:
40
+ raise CliError("Host cannot be empty.")
41
+ if normalized.endswith(API_PREFIX):
42
+ return normalized
43
+ return f"{normalized}{API_PREFIX}"
44
+
45
+
46
+ def get_endpoint(key: str) -> Endpoint:
47
+ try:
48
+ return ENDPOINT_BY_KEY[key]
49
+ except KeyError as exc:
50
+ raise CliError(f"Unknown endpoint: {key}") from exc
51
+
52
+
53
+ def load_json_payload(json_text: str | None, json_file: str | None) -> dict[str, Any]:
54
+ if json_text and json_file:
55
+ raise CliError("--json and --json-file cannot be used together.")
56
+ if not json_text and not json_file:
57
+ return {}
58
+ try:
59
+ source = Path(json_file).read_text(encoding="utf-8") if json_file else (json_text or "{}")
60
+ except OSError as exc:
61
+ raise CliError(f"Cannot read JSON file: {exc}") from exc
62
+ try:
63
+ payload = json.loads(source, parse_float=Decimal)
64
+ except json.JSONDecodeError as exc:
65
+ raise CliError(f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
66
+ if not isinstance(payload, dict):
67
+ raise CliError("JSON payload must be an object.")
68
+ return payload
69
+
70
+
71
+ def require_fields(payload: dict[str, Any], *names: str) -> None:
72
+ missing = [name for name in names if name not in payload or payload[name] is None]
73
+ if missing:
74
+ raise CliError(f"Missing request field(s): {', '.join(missing)}")
75
+
76
+
77
+ class OpenApiClient:
78
+ def __init__(self, host: str, api_key: str | None) -> None:
79
+ self.root = api_root(host)
80
+ self.api_key = api_key
81
+
82
+ def build_url(
83
+ self,
84
+ endpoint: Endpoint,
85
+ path_params: dict[str, Any] | None = None,
86
+ query: dict[str, Any] | None = None,
87
+ ) -> str:
88
+ path = endpoint.path
89
+ values = path_params or {}
90
+ for name in _path_parameter_names(endpoint.path):
91
+ if name not in values:
92
+ raise CliError(f"Missing path parameter: {name}")
93
+ path = path.replace("{" + name + "}", quote(str(values[name]), safe=""))
94
+ clean_query = {key: value for key, value in (query or {}).items() if value is not None}
95
+ suffix = f"?{urlencode(clean_query, doseq=True)}" if clean_query else ""
96
+ return f"{self.root}{path}{suffix}"
97
+
98
+ def request(
99
+ self,
100
+ endpoint_key: str,
101
+ *,
102
+ path_params: dict[str, Any] | None = None,
103
+ query: dict[str, Any] | None = None,
104
+ body: dict[str, Any] | None = None,
105
+ raw_body: bytes | None = None,
106
+ content_type: str | None = None,
107
+ accept: str = "application/json",
108
+ ) -> bytes:
109
+ endpoint = get_endpoint(endpoint_key)
110
+ if not self.api_key:
111
+ raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
112
+ url = self.build_url(endpoint, path_params, query)
113
+ headers = {
114
+ "Accept": accept,
115
+ "Authorization": f"Bearer {self.api_key}",
116
+ "X-FlowBase-Channel": "cli",
117
+ }
118
+ if body is not None and raw_body is not None:
119
+ raise CliError("JSON and raw request bodies cannot be used together.")
120
+ data = raw_body
121
+ if body is not None:
122
+ data = exact_json_dumps(body).encode("utf-8")
123
+ headers["Content-Type"] = "application/json"
124
+ elif raw_body is not None and content_type:
125
+ headers["Content-Type"] = content_type
126
+ request = Request(url, data=data, headers=headers, method=endpoint.method)
127
+ try:
128
+ with urlopen(request, timeout=60) as response: # noqa: S310 - the user selects the FlowBase host.
129
+ return response.read()
130
+ except HTTPError as exc:
131
+ payload = exc.read().decode("utf-8", errors="replace")
132
+ raise CliError(f"HTTP {exc.code} for {endpoint.key}: {_error_detail(payload)}") from exc
133
+ except (OSError, TransportError) as exc:
134
+ reason = str(exc.reason if isinstance(exc, URLError) else exc)
135
+ reason = reason.replace(self.api_key, "<redacted>")
136
+ recovery = (
137
+ " The write result is unknown; read the resource and its revision before retrying."
138
+ if endpoint.method not in ("GET", "HEAD")
139
+ else " Retry the read after checking connectivity."
140
+ )
141
+ raise CliError(f"Network error for {endpoint.key}: {reason}.{recovery}") from exc
142
+
143
+
144
+ def _path_parameter_names(path: str) -> list[str]:
145
+ return [part[1:-1] for part in path.split("/") if part.startswith("{") and part.endswith("}")]
146
+
147
+
148
+ def _error_detail(payload: str) -> str:
149
+ try:
150
+ parsed = json.loads(payload)
151
+ except json.JSONDecodeError:
152
+ return payload or "empty response"
153
+ if isinstance(parsed, dict) and "detail" in parsed:
154
+ detail = parsed["detail"]
155
+ return detail if isinstance(detail, str) else json.dumps(detail, ensure_ascii=False)
156
+ return json.dumps(parsed, ensure_ascii=False)
157
+
158
+
159
+ def exact_json_dumps(value: Any) -> str:
160
+ if value is None:
161
+ return "null"
162
+ if value is True:
163
+ return "true"
164
+ if value is False:
165
+ return "false"
166
+ if isinstance(value, str):
167
+ return json.dumps(value, ensure_ascii=False)
168
+ if isinstance(value, Decimal):
169
+ if not value.is_finite():
170
+ raise CliError("JSON numbers must be finite.")
171
+ return format(value, "f")
172
+ if isinstance(value, int):
173
+ return str(value)
174
+ if isinstance(value, float):
175
+ raise CliError("Binary floating-point values are not accepted.")
176
+ if isinstance(value, list):
177
+ return "[" + ",".join(exact_json_dumps(item) for item in value) + "]"
178
+ if isinstance(value, dict):
179
+ return "{" + ",".join(
180
+ f"{json.dumps(str(key), ensure_ascii=False)}:{exact_json_dumps(item)}"
181
+ for key, item in value.items()
182
+ ) + "}"
183
+ raise CliError(f"Unsupported JSON value: {type(value).__name__}")
184
+
185
+
186
+ def multipart_upload(path_value: str) -> tuple[bytes, str]:
187
+ path = Path(path_value)
188
+ try:
189
+ content = path.read_bytes()
190
+ except OSError as exc:
191
+ raise CliError(f"Cannot read attachment: {exc}") from exc
192
+ filename = path.name.replace('"', "_").replace("\r", "_").replace("\n", "_") or "file"
193
+ boundary = f"flowbase-{secrets.token_hex(16)}"
194
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
195
+ ascii_name = filename.encode("ascii", errors="replace").decode("ascii")
196
+ disposition = (
197
+ f'Content-Disposition: form-data; name="upload"; filename="{ascii_name}"; '
198
+ f"filename*=UTF-8''{quote(filename, safe='')}\r\n"
199
+ ).encode("ascii")
200
+ payload = b"".join([
201
+ f"--{boundary}\r\n".encode("ascii"),
202
+ disposition,
203
+ f"Content-Type: {content_type}\r\n\r\n".encode("ascii"),
204
+ content,
205
+ f"\r\n--{boundary}--\r\n".encode("ascii"),
206
+ ])
207
+ return payload, f"multipart/form-data; boundary={boundary}"
208
+
209
+
210
+ def print_output(data: bytes, *, pretty: bool, raw: bool) -> None:
211
+ text = data.decode("utf-8", errors="replace")
212
+ if raw:
213
+ print(text, end="" if text.endswith("\n") else "\n")
214
+ return
215
+ try:
216
+ parsed = json.loads(text)
217
+ except json.JSONDecodeError:
218
+ print(text, end="" if text.endswith("\n") else "\n")
219
+ return
220
+ indent = 2 if pretty else None
221
+ separators = None if pretty else (",", ":")
222
+ print(json.dumps(parsed, ensure_ascii=False, indent=indent, separators=separators))
223
+
224
+
225
+ def confirm_change(endpoint: Endpoint, yes: bool) -> None:
226
+ if not endpoint.requires_confirmation or yes:
227
+ return
228
+ if not sys.stdin.isatty():
229
+ raise CliError("Refusing a state-changing command in non-interactive mode without --yes.")
230
+ answer = input(f"{endpoint.summary} Continue? [y/N] ").strip().lower()
231
+ if answer not in {"y", "yes"}:
232
+ raise CliError("Cancelled.")
233
+
234
+
235
+ def client_from_args(args: argparse.Namespace) -> OpenApiClient:
236
+ host = args.host or os.getenv(ENV_HOST) or DEFAULT_HOST
237
+ api_key = args.api_key or os.getenv(ENV_API_KEY)
238
+ return OpenApiClient(host, api_key)
239
+
240
+
241
+ def run_request(
242
+ args: argparse.Namespace,
243
+ endpoint_key: str,
244
+ *,
245
+ path_params: dict[str, Any] | None = None,
246
+ query: dict[str, Any] | None = None,
247
+ body: dict[str, Any] | None = None,
248
+ ) -> int:
249
+ endpoint = get_endpoint(endpoint_key)
250
+ confirm_change(endpoint, getattr(args, "yes", False))
251
+ data = client_from_args(args).request(endpoint_key, path_params=path_params, query=query, body=body)
252
+ print_output(data, pretty=args.pretty, raw=args.raw)
253
+ return 0
254
+
255
+
256
+ def handle_organization(args: argparse.Namespace) -> int:
257
+ return run_request(args, f"organization.{args.organization_action}", query={"query": args.query, "page": args.page} if args.organization_action == "employees" else None)
258
+
259
+
260
+ def handle_guide(args: argparse.Namespace) -> int:
261
+ from flowbase_cli.guide import render
262
+ print(render(args.format))
263
+ return 0
264
+
265
+
266
+ def handle_docs(args: argparse.Namespace) -> int:
267
+ if args.docs_action == "example":
268
+ return handle_docs_example(args)
269
+ if args.docs_action == "catalogs":
270
+ return handle_docs_catalogs(args)
271
+ if args.docs_action == "operations":
272
+ delivered = [
273
+ {
274
+ "key": endpoint.key,
275
+ "status": "delivered",
276
+ "operationId": endpoint.operation_id,
277
+ "method": endpoint.method,
278
+ "path": endpoint.path,
279
+ "scope": endpoint.scope,
280
+ "environments": list(endpoint.environments),
281
+ "requiresPreflight": endpoint.preflight_operation_id is not None,
282
+ "requiresConfirmation": endpoint.requires_confirmation,
283
+ "auditEvent": endpoint.audit_event,
284
+ }
285
+ for endpoint in ENDPOINTS
286
+ if endpoint.scope.startswith("metadata:")
287
+ ]
288
+ print(
289
+ json.dumps(
290
+ delivered + list(PENDING_MANAGEMENT_OPERATIONS),
291
+ ensure_ascii=False,
292
+ indent=2 if args.pretty else None,
293
+ )
294
+ )
295
+ return 0
296
+ if args.docs_action == "list":
297
+ payload = [
298
+ {
299
+ "key": e.key,
300
+ "operationId": e.operation_id,
301
+ "method": e.method,
302
+ "path": e.path,
303
+ "scope": e.scope,
304
+ "environments": list(e.environments),
305
+ "summary": e.summary,
306
+ "hasRequestBody": e.request_body is not None,
307
+ "requiresConfirmation": e.requires_confirmation,
308
+ }
309
+ for e in ENDPOINTS
310
+ ]
311
+ print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
312
+ return 0
313
+ if args.docs_action == "catalogs":
314
+ return handle_docs_catalogs(args)
315
+ endpoint = get_endpoint(args.endpoint)
316
+ payload = {
317
+ "key": endpoint.key,
318
+ "operationId": endpoint.operation_id,
319
+ "method": endpoint.method,
320
+ "path": endpoint.path,
321
+ "scope": endpoint.scope,
322
+ "summary": endpoint.summary,
323
+ "requiresConfirmation": endpoint.requires_confirmation,
324
+ "environments": list(endpoint.environments),
325
+ "preflightOperationId": endpoint.preflight_operation_id,
326
+ "auditEvent": endpoint.audit_event,
327
+ "example": endpoint.example,
328
+ "requestBody": endpoint.request_body,
329
+ "responseShape": AUTHORING["responseShapes"].get(endpoint.key),
330
+ }
331
+ print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
332
+ return 0
333
+
334
+
335
+ def handle_docs_catalogs(args: argparse.Namespace) -> int:
336
+ if args.name is None:
337
+ print(
338
+ json.dumps(
339
+ sorted(CATALOGS),
340
+ ensure_ascii=False,
341
+ indent=2 if args.pretty else None,
342
+ )
343
+ )
344
+ return 0
345
+ try:
346
+ payload = CATALOGS[args.name]
347
+ except KeyError as exc:
348
+ raise CliError(
349
+ f"Unknown catalog: {args.name}. Available: {', '.join(sorted(CATALOGS))}"
350
+ ) from exc
351
+ print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
352
+ return 0
353
+
354
+
355
+ def handle_docs_example(args: argparse.Namespace) -> int:
356
+ endpoint = get_endpoint(args.endpoint)
357
+ body = endpoint.request_body or "{}"
358
+ target = args.output
359
+ if target:
360
+ try:
361
+ Path(target).write_text(body + "\n", encoding="utf-8")
362
+ except OSError as exc:
363
+ raise CliError(f"Cannot write example: {exc}") from exc
364
+ print(target)
365
+ else:
366
+ print(body)
367
+ return 0
368
+
369
+
370
+ def _payload_from_args(args: argparse.Namespace, direct: dict[str, Any], required: tuple[str, ...]) -> dict[str, Any]:
371
+ payload = load_json_payload(args.json, args.json_file)
372
+ payload.update({key: value for key, value in direct.items() if value is not None})
373
+ require_fields(payload, *required)
374
+ return payload
375
+
376
+
377
+ def handle_apps(args: argparse.Namespace) -> int:
378
+ action = args.apps_action
379
+ if action == "list":
380
+ return run_request(args, "apps.list")
381
+ if action == "get":
382
+ return run_request(args, "apps.get", path_params={"app_id": args.app_id})
383
+ if action == "create":
384
+ body = _payload_from_args(
385
+ args,
386
+ {"label": args.label, "key": args.key, "icon": args.icon, "color": args.color},
387
+ ("label", "key"),
388
+ )
389
+ return run_request(args, "apps.create", body=body)
390
+ if action == "update":
391
+ body = _payload_from_args(
392
+ args,
393
+ {"label": args.label, "icon": args.icon, "color": args.color},
394
+ ("label", "icon", "color"),
395
+ )
396
+ return run_request(args, "apps.update", path_params={"app_id": args.app_id}, body=body)
397
+ if action == "set-enabled":
398
+ return run_request(
399
+ args,
400
+ "apps.set-enabled",
401
+ path_params={"app_id": args.app_id},
402
+ body={"enabled": args.enabled},
403
+ )
404
+ if action == "add-objects":
405
+ ids = [item.strip() for item in (args.object_ids or "").split(",") if item.strip()]
406
+ if not ids:
407
+ raise CliError("Provide --object-ids as a comma-separated list.")
408
+ return run_request(
409
+ args,
410
+ "apps.objects.add",
411
+ path_params={"app_id": args.app_id},
412
+ body={"objectIds": ids},
413
+ )
414
+ if action == "remove-object":
415
+ return run_request(
416
+ args,
417
+ "apps.objects.remove",
418
+ path_params={"app_id": args.app_id, "object_id": args.object_id},
419
+ )
420
+ if action == "set-group":
421
+ if args.ungrouped:
422
+ body = {"groupId": None}
423
+ elif args.group_id is not None:
424
+ body = {"groupId": args.group_id}
425
+ else:
426
+ raise CliError("Provide --group-id or --ungrouped.")
427
+ return run_request(
428
+ args,
429
+ "apps.objects.set-group",
430
+ path_params={"app_id": args.app_id, "object_id": args.object_id},
431
+ body=body,
432
+ )
433
+ if action == "reorder-objects":
434
+ body = _payload_from_args(args, {}, ("objectIds",))
435
+ return run_request(
436
+ args,
437
+ "apps.objects.reorder",
438
+ path_params={"app_id": args.app_id},
439
+ body=body,
440
+ )
441
+ if action == "add-dashboards":
442
+ ids = [item.strip() for item in args.dashboard_ids if item.strip()]
443
+ if not ids:
444
+ raise CliError("Provide at least one dashboard ID.")
445
+ return run_request(
446
+ args,
447
+ "apps.dashboards.add",
448
+ path_params={"app_id": args.app_id},
449
+ body={"dashboardIds": ids},
450
+ )
451
+ if action == "remove-dashboard":
452
+ return run_request(
453
+ args,
454
+ "apps.dashboards.remove",
455
+ path_params={"app_id": args.app_id, "dashboard_id": args.dashboard_id},
456
+ )
457
+ if action == "set-dashboard-group":
458
+ if args.ungrouped:
459
+ body = {"groupId": None}
460
+ elif args.group_id is not None:
461
+ body = {"groupId": args.group_id}
462
+ else:
463
+ raise CliError("Provide --group-id or --ungrouped.")
464
+ return run_request(
465
+ args,
466
+ "apps.dashboards.set-group",
467
+ path_params={"app_id": args.app_id, "dashboard_id": args.dashboard_id},
468
+ body=body,
469
+ )
470
+ if action == "reorder-members":
471
+ body = _payload_from_args(args, {}, ("items",))
472
+ if not isinstance(body["items"], list):
473
+ raise CliError("Request field 'items' must be an array.")
474
+ return run_request(
475
+ args,
476
+ "apps.members.reorder",
477
+ path_params={"app_id": args.app_id},
478
+ body=body,
479
+ )
480
+ return run_request(args, "apps.delete", path_params={"app_id": args.app_id})
481
+
482
+
483
+ def handle_dashboards(args: argparse.Namespace) -> int:
484
+ action = args.dashboards_action
485
+ if action == "run":
486
+ return run_request(args, "dashboards.run", path_params={"dashboard_id": args.dashboard_id, "component_id": args.component_id}, body=load_json_payload(args.json, args.json_file))
487
+ if action == "list":
488
+ return run_request(args, "dashboards.list")
489
+ if action == "get":
490
+ return run_request(args, "dashboards.get", path_params={"dashboard_id": args.dashboard_id})
491
+ if action == "create":
492
+ body = _payload_from_args(
493
+ args,
494
+ {
495
+ "label": args.label,
496
+ "key": args.key,
497
+ "icon": args.icon,
498
+ "description": args.description,
499
+ },
500
+ ("label", "key"),
501
+ )
502
+ return run_request(args, "dashboards.create", body=body)
503
+ if action == "update":
504
+ body = load_json_payload(args.json, args.json_file)
505
+ require_fields(body, "label")
506
+ return run_request(
507
+ args,
508
+ "dashboards.update",
509
+ path_params={"dashboard_id": args.dashboard_id},
510
+ body=body,
511
+ )
512
+ if action == "copy":
513
+ return run_request(
514
+ args,
515
+ "dashboards.copy",
516
+ path_params={"dashboard_id": args.dashboard_id},
517
+ )
518
+ return run_request(
519
+ args,
520
+ "dashboards.delete",
521
+ path_params={"dashboard_id": args.dashboard_id},
522
+ )
523
+
524
+
525
+ def handle_groups(args: argparse.Namespace) -> int:
526
+ action = args.groups_action
527
+ path = {"app_id": args.app_id}
528
+ if action == "create":
529
+ body = _payload_from_args(args, {"name": args.name, "icon": args.icon}, ("name",))
530
+ return run_request(args, "apps.groups.create", path_params=path, body=body)
531
+ if action == "reorder":
532
+ body = _payload_from_args(args, {}, ("groupIds",))
533
+ return run_request(args, "apps.groups.reorder", path_params=path, body=body)
534
+ if action == "update":
535
+ path["group_id"] = args.group_id
536
+ body = _payload_from_args(args, {"name": args.name, "icon": args.icon}, ("name", "icon"))
537
+ return run_request(args, "apps.groups.update", path_params=path, body=body)
538
+ path["group_id"] = args.group_id
539
+ return run_request(args, "apps.groups.delete", path_params=path)
540
+
541
+
542
+ def handle_objects(args: argparse.Namespace) -> int:
543
+ action = args.objects_action
544
+ if action == "list":
545
+ return run_request(args, "objects.list", query={"status": args.status})
546
+ if action == "get":
547
+ return run_request(args, "objects.get", path_params={"object_id": args.object_id}, query={"status": args.status})
548
+ if action == "cli-edit-policy":
549
+ path = {"object_id": args.object_id}
550
+ if args.objects_cli_edit_policy_action == "get":
551
+ return run_request(
552
+ args, "objects.cli-edit-policy.get", path_params=path
553
+ )
554
+ body = load_json_payload(args.json, args.json_file)
555
+ require_fields(body, "baseRevision", "policies")
556
+ if not isinstance(body["policies"], dict):
557
+ raise CliError("Request field 'policies' must be an object.")
558
+ return run_request(
559
+ args,
560
+ "objects.cli-edit-policy.update",
561
+ path_params=path,
562
+ body=body,
563
+ )
564
+ if action == "create":
565
+ body = _payload_from_args(
566
+ args,
567
+ {
568
+ "label": args.label,
569
+ "key": args.key,
570
+ "description": args.description,
571
+ },
572
+ ("label", "key", "description"),
573
+ )
574
+ return run_request(args, "objects.create", body=body)
575
+ if action == "update":
576
+ body = _payload_from_args(
577
+ args,
578
+ {
579
+ "label": args.label,
580
+ "description": args.description,
581
+ },
582
+ ("label", "description"),
583
+ )
584
+ return run_request(args, "objects.update", path_params={"object_id": args.object_id}, body=body)
585
+ return run_request(args, f"objects.{action}", path_params={"object_id": args.object_id})
586
+
587
+
588
+ def handle_fields(args: argparse.Namespace) -> int:
589
+ action = args.fields_action
590
+ path = {"object_id": args.object_id}
591
+ if hasattr(args, "field_id"):
592
+ path["field_id"] = args.field_id
593
+ if action == "create":
594
+ body = _payload_from_args(
595
+ args,
596
+ {"label": args.label, "key": args.key, "description": args.description},
597
+ ("label", "key", "description"),
598
+ )
599
+ if not body.get("type"):
600
+ raise CliError(
601
+ "Missing request field(s): type. "
602
+ "Pass --json/--json-file with type, typeVersion, permission, and constraints. "
603
+ "See `flowbase docs catalogs field-types` and `flowbase docs example fields.create`."
604
+ )
605
+ return run_request(args, "fields.create", path_params=path, body=body)
606
+ if action == "update":
607
+ body = _payload_from_args(args, {"label": args.label, "description": args.description}, ("label", "description"))
608
+ return run_request(args, "fields.update", path_params=path, body=body)
609
+ return run_request(args, f"fields.{action}", path_params=path)
610
+
611
+
612
+ def handle_views(args: argparse.Namespace) -> int:
613
+ action = args.views_action
614
+ path = {"object_id": args.object_id}
615
+ if hasattr(args, "view_id"):
616
+ path["view_id"] = args.view_id
617
+ if action == "list":
618
+ return run_request(args, "views.list", path_params=path)
619
+ if action in ("create", "update"):
620
+ body = load_json_payload(args.json, args.json_file)
621
+ require_fields(body, "baseRevision", "name", "defaultFieldIds")
622
+ if not isinstance(body["defaultFieldIds"], list):
623
+ raise CliError("Request field 'defaultFieldIds' must be an array.")
624
+ return run_request(args, f"views.{action}", path_params=path, body=body)
625
+ if action == "reorder":
626
+ body = load_json_payload(args.json, args.json_file)
627
+ require_fields(body, "baseRevision", "viewIds")
628
+ if not isinstance(body["viewIds"], list):
629
+ raise CliError("Request field 'viewIds' must be an array.")
630
+ return run_request(args, "views.reorder", path_params=path, body=body)
631
+ if action == "set-enabled":
632
+ body = {
633
+ "baseRevision": args.base_revision,
634
+ "enabled": args.enabled,
635
+ }
636
+ return run_request(args, "views.set-enabled", path_params=path, body=body)
637
+ return run_request(
638
+ args,
639
+ "views.delete",
640
+ path_params=path,
641
+ body={"baseRevision": args.base_revision},
642
+ )
643
+
644
+
645
+ def handle_workflows(args: argparse.Namespace) -> int:
646
+ action = args.workflows_action
647
+ path = {"object_id": args.object_id}
648
+ if hasattr(args, "workflow_id"):
649
+ path["workflow_id"] = args.workflow_id
650
+ if action == "list":
651
+ return run_request(args, "workflows.list", path_params=path)
652
+ if action == "get":
653
+ return run_request(args, "workflows.get", path_params=path)
654
+ if action == "executions":
655
+ return run_request(
656
+ args,
657
+ "workflows.executions",
658
+ path_params=path,
659
+ query={"result": args.result},
660
+ )
661
+ if action in ("create", "update"):
662
+ body = load_json_payload(args.json, args.json_file)
663
+ require_fields(body, "label")
664
+ return run_request(args, f"workflows.{action}", path_params=path, body=body)
665
+ if action == "reorder":
666
+ body = load_json_payload(args.json, args.json_file)
667
+ require_fields(body, "baseRevision", "workflowIds")
668
+ if not isinstance(body["workflowIds"], list):
669
+ raise CliError("Request field 'workflowIds' must be an array.")
670
+ return run_request(args, "workflows.reorder", path_params=path, body=body)
671
+ if action == "set-enabled":
672
+ body = {
673
+ "baseRevision": args.base_revision,
674
+ "enabled": args.enabled,
675
+ }
676
+ return run_request(args, "workflows.set-enabled", path_params=path, body=body)
677
+ return run_request(
678
+ args,
679
+ "workflows.delete",
680
+ path_params=path,
681
+ body={"baseRevision": args.base_revision},
682
+ )
683
+
684
+
685
+ def handle_approvals(args: argparse.Namespace) -> int:
686
+ action = args.approvals_action
687
+ path = {"object_id": args.object_id}
688
+ if hasattr(args, "approval_id"):
689
+ path["approval_id"] = args.approval_id
690
+ if action in ("list", "get"):
691
+ return run_request(args, f"approvals.{action}", path_params=path)
692
+ if action in ("create", "update"):
693
+ body = load_json_payload(args.json, args.json_file)
694
+ require_fields(body, "label", "trigger", "displayFieldIds", "nodes", "edges")
695
+ return run_request(args, f"approvals.{action}", path_params=path, body=body)
696
+ if action == "reorder":
697
+ body = load_json_payload(args.json, args.json_file)
698
+ require_fields(body, "baseRevision", "approvalIds")
699
+ if not isinstance(body["approvalIds"], list):
700
+ raise CliError("Request field 'approvalIds' must be an array.")
701
+ return run_request(args, "approvals.reorder", path_params=path, body=body)
702
+ if action == "set-enabled":
703
+ return run_request(
704
+ args, "approvals.set-enabled", path_params=path,
705
+ body={"baseRevision": args.base_revision, "enabled": args.enabled},
706
+ )
707
+ return run_request(
708
+ args, "approvals.delete", path_params=path,
709
+ body={"baseRevision": args.base_revision},
710
+ )
711
+
712
+
713
+ def handle_print_templates(args: argparse.Namespace) -> int:
714
+ action = args.print_templates_action
715
+ path = {"object_id": args.object_id}
716
+ if hasattr(args, "template_id"):
717
+ path["template_id"] = args.template_id
718
+ if action in ("list", "get"):
719
+ return run_request(args, f"print-templates.{action}", path_params=path)
720
+ if action == "create":
721
+ body = load_json_payload(args.json, args.json_file)
722
+ require_fields(body, "name", "html")
723
+ return run_request(args, "print-templates.create", path_params=path, body=body)
724
+ if action == "update":
725
+ body = load_json_payload(args.json, args.json_file)
726
+ return run_request(args, "print-templates.update", path_params=path, body=body)
727
+ if action == "reorder":
728
+ body = load_json_payload(args.json, args.json_file)
729
+ require_fields(body, "baseRevision", "templateIds")
730
+ if not isinstance(body["templateIds"], list):
731
+ raise CliError("Request field 'templateIds' must be an array.")
732
+ return run_request(args, "print-templates.reorder", path_params=path, body=body)
733
+ if action == "set-enabled":
734
+ return run_request(
735
+ args, "print-templates.set-enabled", path_params=path,
736
+ body={"baseRevision": args.base_revision, "enabled": args.enabled},
737
+ )
738
+ return run_request(
739
+ args, "print-templates.delete", path_params=path,
740
+ body={"baseRevision": args.base_revision},
741
+ )
742
+
743
+
744
+ def handle_pages(args: argparse.Namespace) -> int:
745
+ action = args.pages_action
746
+ if action == "evaluate":
747
+ return run_request(args, "pages.evaluate", path_params={"page_id": args.page_id}, body=load_json_payload(args.json, args.json_file))
748
+ if action == "list":
749
+ return run_request(
750
+ args,
751
+ "pages.list",
752
+ query={
753
+ "status": args.status,
754
+ "objectId": args.object_id,
755
+ "type": args.type,
756
+ "keyword": args.keyword,
757
+ "page": args.page,
758
+ },
759
+ )
760
+ if action == "options":
761
+ return run_request(args, "pages.options")
762
+ if action == "get":
763
+ return run_request(args, "pages.get", path_params={"page_id": args.page_id})
764
+ if action in ("create", "update"):
765
+ body = load_json_payload(args.json, args.json_file)
766
+ require_fields(body, "baseRevision", "page")
767
+ if not isinstance(body["page"], dict):
768
+ raise CliError("Request field 'page' must be an object.")
769
+ return run_request(
770
+ args,
771
+ f"pages.{action}",
772
+ path_params={"page_id": args.page_id} if action == "update" else None,
773
+ body=body,
774
+ )
775
+ return run_request(
776
+ args,
777
+ f"pages.{action}",
778
+ path_params={"page_id": args.page_id},
779
+ body={"baseRevision": args.base_revision},
780
+ )
781
+
782
+
783
+ def handle_changes(args: argparse.Namespace) -> int:
784
+ if args.changes_action == "direct-release":
785
+ client = client_from_args(args)
786
+ body = {"message": args.message}
787
+ preflight_data = client.request(
788
+ "changes.direct-release-preflight",
789
+ body=body,
790
+ )
791
+ try:
792
+ preflight = json.loads(preflight_data)
793
+ except json.JSONDecodeError as exc:
794
+ raise CliError("Direct-release preflight returned invalid JSON.") from exc
795
+ token = preflight.get("confirmationToken")
796
+ if not isinstance(token, str):
797
+ raise CliError("Direct-release preflight did not return a confirmation token.")
798
+ _print_direct_release_preflight(preflight)
799
+ endpoint = get_endpoint("changes.direct-release")
800
+ confirm_change(endpoint, args.yes)
801
+ data = client.request(
802
+ "changes.direct-release",
803
+ body={"message": args.message, "confirmationToken": token},
804
+ )
805
+ print_output(data, pretty=args.pretty, raw=args.raw)
806
+ return 0
807
+ return run_request(args, f"changes.{args.changes_action}", body={"message": args.message})
808
+
809
+
810
+ def handle_versions(args: argparse.Namespace) -> int:
811
+ if args.versions_action == "show":
812
+ return run_request(
813
+ args,
814
+ "versions.get",
815
+ path_params={"version_id": args.version_id},
816
+ )
817
+ if args.versions_action == "releases":
818
+ return run_request(args, "releases.list")
819
+ return run_request(
820
+ args,
821
+ "versions.list",
822
+ query={
823
+ "q": args.query,
824
+ "resource": args.resource,
825
+ "changeType": args.change_type,
826
+ "releaseStatus": args.release_status,
827
+ "channel": args.channel,
828
+ },
829
+ )
830
+
831
+
832
+ def _print_direct_release_preflight(preflight: dict[str, Any]) -> None:
833
+ print("Production direct-release preflight:", file=sys.stderr)
834
+ print(f" Current main: {preflight.get('mainCommit', 'unknown')}", file=sys.stderr)
835
+ print(f" Draft revision: {preflight.get('draftRevision', 'unknown')}", file=sys.stderr)
836
+ print(
837
+ f" Changes: {preflight.get('semanticCount', 0)} semantic, "
838
+ f"{preflight.get('fileCount', 0)} files",
839
+ file=sys.stderr,
840
+ )
841
+ for check in preflight.get("checks", []):
842
+ if isinstance(check, dict):
843
+ mark = "OK" if check.get("ok") else "FAIL"
844
+ print(f" [{mark}] {check.get('label', '')}", file=sys.stderr)
845
+ print(f" RISK: {preflight.get('risk', 'This bypasses test environments.')}", file=sys.stderr)
846
+
847
+
848
+ def _environment_confirmation(args: argparse.Namespace, label: str) -> str:
849
+ value = (args.confirm or "").strip()
850
+ if not value:
851
+ if not sys.stdin.isatty():
852
+ raise CliError(
853
+ "Refusing to restart without --confirm '<environment name>' in non-interactive mode."
854
+ )
855
+ value = input(f"Type {label} to confirm: ").strip()
856
+ if value != label:
857
+ raise CliError("Environment confirmation does not match the preflight result.")
858
+ return value
859
+
860
+
861
+ def _print_environment_restart_preflight(preflight: dict[str, Any]) -> None:
862
+ current = str(preflight.get("currentCommit", "unknown"))
863
+ main = str(preflight.get("mainCommit", "unknown"))
864
+ print("Test-environment restart preflight:", file=sys.stderr)
865
+ print(f" Environment: {preflight.get('environmentLabel', 'unknown')}", file=sys.stderr)
866
+ print(f" Current test version: {current[:7]}", file=sys.stderr)
867
+ print(f" Latest production version: {main[:7]}", file=sys.stderr)
868
+ print(f" Drafts: {preflight.get('draftCount', 0)}", file=sys.stderr)
869
+ print(f" Test records: {preflight.get('recordCount', 0)}", file=sys.stderr)
870
+ for check in preflight.get("checks", []):
871
+ if isinstance(check, dict):
872
+ mark = "OK" if check.get("ok") else "FAIL"
873
+ print(f" [{mark}] {check.get('label', '')}", file=sys.stderr)
874
+ print(
875
+ f" RISK: {preflight.get('risk', 'All test configuration and records will be cleared.')}",
876
+ file=sys.stderr,
877
+ )
878
+
879
+
880
+ def handle_environments(args: argparse.Namespace) -> int:
881
+ client = client_from_args(args)
882
+ preflight_data = client.request("environments.restart-preflight")
883
+ try:
884
+ preflight = json.loads(preflight_data)
885
+ except json.JSONDecodeError as exc:
886
+ raise CliError("Environment restart preflight returned invalid JSON.") from exc
887
+ _print_environment_restart_preflight(preflight)
888
+ if not preflight.get("canRestart"):
889
+ raise CliError("Environment restart preflight failed. Resolve failed checks first.")
890
+ label = preflight.get("environmentLabel")
891
+ if not isinstance(label, str) or not label:
892
+ raise CliError("Environment restart preflight did not return an environment label.")
893
+ confirm = _environment_confirmation(args, label)
894
+ endpoint = get_endpoint("environments.restart")
895
+ confirm_change(endpoint, args.yes)
896
+ body = {
897
+ "confirm": confirm,
898
+ "expectedCurrentCommit": preflight.get("currentCommit"),
899
+ "expectedMainCommit": preflight.get("mainCommit"),
900
+ "expectedDraftRevision": preflight.get("draftRevision"),
901
+ "expectedRecordCount": preflight.get("recordCount"),
902
+ }
903
+ if any(value is None for value in body.values()):
904
+ raise CliError("Environment restart preflight returned incomplete binding state.")
905
+ data = client.request("environments.restart", body=body)
906
+ print_output(data, pretty=args.pretty, raw=args.raw)
907
+ return 0
908
+
909
+
910
+ def handle_records(args: argparse.Namespace) -> int:
911
+ action = args.records_action
912
+ path = {"object_id": args.object_id}
913
+ if action == "upload-attachment":
914
+ body, content_type = multipart_upload(args.file)
915
+ data = client_from_args(args).request(
916
+ "records.upload-attachment",
917
+ path_params={**path, "field_id": args.field_id},
918
+ raw_body=body,
919
+ content_type=content_type,
920
+ )
921
+ print_output(data, pretty=args.pretty, raw=args.raw)
922
+ return 0
923
+ if action == "download-attachment":
924
+ data = client_from_args(args).request(
925
+ "records.download-attachment",
926
+ path_params={
927
+ **path,
928
+ "record_id": args.record_id,
929
+ "attachment_id": args.attachment_id,
930
+ },
931
+ accept="application/octet-stream",
932
+ )
933
+ try:
934
+ Path(args.output).write_bytes(data)
935
+ except OSError as exc:
936
+ raise CliError(f"Cannot write attachment: {exc}") from exc
937
+ return 0
938
+ if hasattr(args, "record_id"):
939
+ path["record_id"] = args.record_id
940
+ if action == "list":
941
+ return run_request(args, "records.list", path_params=path, query={"page": args.page, "viewId": args.view_id})
942
+ if action == "get" or action == "delete":
943
+ return run_request(args, f"records.{action}", path_params=path)
944
+ body = load_json_payload(args.json, args.json_file)
945
+ require_fields(body, "values")
946
+ if not isinstance(body["values"], dict):
947
+ raise CliError("Request field 'values' must be an object.")
948
+ return run_request(args, f"records.{action}", path_params=path, body=body)
949
+
950
+
951
+ def handle_roles(args: argparse.Namespace) -> int:
952
+ action = args.roles_action
953
+ if action == "list":
954
+ return run_request(args, "roles.list", query={"q": args.q or "", "page": args.page})
955
+ if action == "options":
956
+ return run_request(args, "roles.options")
957
+ if action == "get":
958
+ return run_request(args, "roles.get", path_params={"role_id": args.role_id})
959
+ if action == "delete":
960
+ return run_request(args, "roles.delete", path_params={"role_id": args.role_id})
961
+ body = load_json_payload(args.json, args.json_file)
962
+ require_fields(body, "name", "permissions")
963
+ if not isinstance(body["permissions"], dict):
964
+ raise CliError("Request field 'permissions' must be an object.")
965
+ if action == "create":
966
+ return run_request(args, "roles.create", body=body)
967
+ if "updatedAt" not in body and "updated_at" not in body:
968
+ raise CliError("Missing request field(s): updatedAt")
969
+ return run_request(args, "roles.update", path_params={"role_id": args.role_id}, body=body)
970
+
971
+
972
+ def add_json_args(parser: argparse.ArgumentParser, *, endpoint_key: str | None = None) -> None:
973
+ group = parser.add_mutually_exclusive_group()
974
+ body_help = "Request body as a JSON object."
975
+ if endpoint_key is not None:
976
+ endpoint = get_endpoint(endpoint_key)
977
+ if endpoint.request_body:
978
+ body_help += f" Example: {endpoint.request_body}"
979
+ group.add_argument("--json", help=body_help)
980
+ group.add_argument("--json-file", help="Read the request body from a UTF-8 JSON file.")
981
+
982
+
983
+ def add_object_fields(
984
+ parser: argparse.ArgumentParser,
985
+ *,
986
+ include_key: bool,
987
+ endpoint_key: str | None = None,
988
+ ) -> None:
989
+ parser.add_argument("--label")
990
+ if include_key:
991
+ parser.add_argument("--key")
992
+ parser.add_argument("--description")
993
+ add_json_args(parser, endpoint_key=endpoint_key)
994
+
995
+
996
+ def add_yes(parser: argparse.ArgumentParser) -> None:
997
+ parser.add_argument("--yes", action="store_true", help="Skip the interactive confirmation.")
998
+
999
+
1000
+ def build_parser() -> argparse.ArgumentParser:
1001
+ parser = argparse.ArgumentParser(prog="flowbase", description="FlowBase low-code platform management CLI. Start with: flowbase guide")
1002
+ parser.add_argument("--host", help=f"Environment Base URL. Defaults to ${ENV_HOST} or {DEFAULT_HOST}.")
1003
+ parser.add_argument("--api-key", help=f"API key. Defaults to ${ENV_API_KEY}.")
1004
+ parser.add_argument("--pretty", action=argparse.BooleanOptionalAction, default=True, help="Pretty-print JSON output.")
1005
+ parser.add_argument("--raw", action="store_true", help="Print the response body without JSON formatting.")
1006
+ groups = parser.add_subparsers(dest="group", required=True)
1007
+
1008
+ organization = groups.add_parser("organization", help="Discover employee and department references for approvals.")
1009
+ organization_actions = organization.add_subparsers(dest="organization_action", required=True)
1010
+ employees = organization_actions.add_parser("employees")
1011
+ employees.add_argument("--query", default="")
1012
+ employees.add_argument("--page", type=int, default=1)
1013
+ organization_actions.add_parser("departments")
1014
+ organization.set_defaults(handler=handle_organization)
1015
+
1016
+ guide = groups.add_parser("guide", help="Read the offline product and task guide first.")
1017
+ guide.add_argument("--format", choices=("markdown", "json"), default="markdown")
1018
+ guide.set_defaults(handler=handle_guide)
1019
+
1020
+ docs = groups.add_parser("docs", help="Inspect the offline endpoint catalog.")
1021
+ docs_actions = docs.add_subparsers(dest="docs_action", required=True)
1022
+ docs_actions.add_parser("list")
1023
+ docs_actions.add_parser("operations")
1024
+ docs_catalogs = docs_actions.add_parser(
1025
+ "catalogs",
1026
+ help="List offline authoring catalogs, or dump one by name.",
1027
+ )
1028
+ docs_catalogs.add_argument("name", nargs="?", default=None)
1029
+ docs_show = docs_actions.add_parser("show")
1030
+ docs_show.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
1031
+ docs_example = docs_actions.add_parser(
1032
+ "example",
1033
+ help="Print or write the representative request body for an endpoint.",
1034
+ )
1035
+ docs_example.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
1036
+ docs_example.add_argument(
1037
+ "--output",
1038
+ help="Write the JSON body to this file instead of stdout.",
1039
+ )
1040
+ docs.set_defaults(handler=handle_docs)
1041
+
1042
+ apps = groups.add_parser("apps", help="Manage app metadata.")
1043
+ app_actions = apps.add_subparsers(dest="apps_action", required=True)
1044
+ app_actions.add_parser("list")
1045
+ app_get = app_actions.add_parser("get")
1046
+ app_get.add_argument("app_id")
1047
+ app_create = app_actions.add_parser("create")
1048
+ app_create.add_argument("--label")
1049
+ app_create.add_argument("--key")
1050
+ app_create.add_argument("--icon")
1051
+ app_create.add_argument("--color")
1052
+ add_json_args(app_create, endpoint_key="apps.create")
1053
+ app_update = app_actions.add_parser("update", description="Update app appearance. Supply label, icon and color together via flags or JSON; read apps get first.")
1054
+ app_update.add_argument("app_id")
1055
+ app_update.add_argument("--label")
1056
+ app_update.add_argument("--icon")
1057
+ app_update.add_argument("--color")
1058
+ add_json_args(app_update, endpoint_key="apps.update")
1059
+ app_enabled = app_actions.add_parser("set-enabled")
1060
+ app_enabled.add_argument("app_id")
1061
+ app_enabled.add_argument(
1062
+ "--enabled",
1063
+ action=argparse.BooleanOptionalAction,
1064
+ required=True,
1065
+ )
1066
+ add_yes(app_enabled)
1067
+ app_delete = app_actions.add_parser("delete")
1068
+ app_delete.add_argument("app_id")
1069
+ add_yes(app_delete)
1070
+ app_add_objects = app_actions.add_parser("add-objects")
1071
+ app_add_objects.add_argument("app_id")
1072
+ app_add_objects.add_argument("--object-ids", required=True, help="Comma-separated object IDs.")
1073
+ app_remove_object = app_actions.add_parser("remove-object")
1074
+ app_remove_object.add_argument("app_id")
1075
+ app_remove_object.add_argument("object_id")
1076
+ add_yes(app_remove_object)
1077
+ app_set_group = app_actions.add_parser("set-group")
1078
+ app_set_group.add_argument("app_id")
1079
+ app_set_group.add_argument("object_id")
1080
+ app_set_group.add_argument("--group-id", help="Target group ID. Omit with --ungrouped.")
1081
+ app_set_group.add_argument(
1082
+ "--ungrouped",
1083
+ action="store_true",
1084
+ help="Remove the object from its current group in this app.",
1085
+ )
1086
+ app_reorder_objects = app_actions.add_parser("reorder-objects")
1087
+ app_reorder_objects.add_argument("app_id")
1088
+ add_json_args(app_reorder_objects, endpoint_key="apps.objects.reorder")
1089
+ app_add_dashboards = app_actions.add_parser("add-dashboards")
1090
+ app_add_dashboards.add_argument("app_id")
1091
+ app_add_dashboards.add_argument(
1092
+ "dashboard_ids",
1093
+ nargs="+",
1094
+ help="One or more dashboard IDs to associate with the app.",
1095
+ )
1096
+ app_remove_dashboard = app_actions.add_parser("remove-dashboard")
1097
+ app_remove_dashboard.add_argument("app_id")
1098
+ app_remove_dashboard.add_argument("dashboard_id")
1099
+ add_yes(app_remove_dashboard)
1100
+ app_set_dashboard_group = app_actions.add_parser("set-dashboard-group")
1101
+ app_set_dashboard_group.add_argument("app_id")
1102
+ app_set_dashboard_group.add_argument("dashboard_id")
1103
+ app_set_dashboard_group.add_argument("--group-id", help="Target group ID. Omit with --ungrouped.")
1104
+ app_set_dashboard_group.add_argument(
1105
+ "--ungrouped",
1106
+ action="store_true",
1107
+ help="Remove the dashboard from its current group in this app.",
1108
+ )
1109
+ app_reorder_members = app_actions.add_parser("reorder-members")
1110
+ app_reorder_members.add_argument("app_id")
1111
+ add_json_args(app_reorder_members, endpoint_key="apps.members.reorder")
1112
+ apps.set_defaults(handler=handle_apps)
1113
+
1114
+ dashboards = groups.add_parser("dashboards", help="Manage dashboard metadata.")
1115
+ dashboard_actions = dashboards.add_subparsers(dest="dashboards_action", required=True)
1116
+ dashboard_run = dashboard_actions.add_parser("run", help="Run a saved draft component; requires metadata:read and records:read.")
1117
+ dashboard_run.add_argument("dashboard_id")
1118
+ dashboard_run.add_argument("component_id")
1119
+ add_json_args(dashboard_run, endpoint_key="dashboards.run")
1120
+ dashboard_actions.add_parser("list")
1121
+ dashboard_get = dashboard_actions.add_parser("get")
1122
+ dashboard_get.add_argument("dashboard_id")
1123
+ dashboard_create = dashboard_actions.add_parser("create")
1124
+ dashboard_create.add_argument("--label")
1125
+ dashboard_create.add_argument("--key")
1126
+ dashboard_create.add_argument("--icon")
1127
+ dashboard_create.add_argument("--description")
1128
+ add_json_args(dashboard_create, endpoint_key="dashboards.create")
1129
+ dashboard_update = dashboard_actions.add_parser("update")
1130
+ dashboard_update.add_argument("dashboard_id")
1131
+ add_json_args(dashboard_update, endpoint_key="dashboards.update")
1132
+ dashboard_copy = dashboard_actions.add_parser("copy")
1133
+ dashboard_copy.add_argument("dashboard_id")
1134
+ dashboard_delete = dashboard_actions.add_parser("delete")
1135
+ dashboard_delete.add_argument("dashboard_id")
1136
+ add_yes(dashboard_delete)
1137
+ dashboards.set_defaults(handler=handle_dashboards)
1138
+
1139
+ app_groups = groups.add_parser("groups", help="Manage app object groups.")
1140
+ group_actions = app_groups.add_subparsers(dest="groups_action", required=True)
1141
+ group_create = group_actions.add_parser("create")
1142
+ group_create.add_argument("app_id")
1143
+ group_create.add_argument("--name")
1144
+ group_create.add_argument("--icon")
1145
+ add_json_args(group_create, endpoint_key="apps.groups.create")
1146
+ group_reorder = group_actions.add_parser("reorder")
1147
+ group_reorder.add_argument("app_id")
1148
+ add_json_args(group_reorder, endpoint_key="apps.groups.reorder")
1149
+ group_update = group_actions.add_parser("update")
1150
+ group_update.add_argument("app_id")
1151
+ group_update.add_argument("group_id")
1152
+ group_update.add_argument("--name")
1153
+ group_update.add_argument("--icon")
1154
+ add_json_args(group_update, endpoint_key="apps.groups.update")
1155
+ group_delete = group_actions.add_parser("delete")
1156
+ group_delete.add_argument("app_id")
1157
+ group_delete.add_argument("group_id")
1158
+ add_yes(group_delete)
1159
+ app_groups.set_defaults(handler=handle_groups)
1160
+
1161
+ objects = groups.add_parser("objects", help="Manage object metadata.")
1162
+ object_actions = objects.add_subparsers(dest="objects_action", required=True)
1163
+ object_list = object_actions.add_parser("list")
1164
+ object_list.add_argument("--status", choices=("active", "deleted"), default="active")
1165
+ object_get = object_actions.add_parser("get")
1166
+ object_get.add_argument("object_id")
1167
+ object_get.add_argument("--status", choices=("active", "deleted"), default="active")
1168
+ add_object_fields(
1169
+ object_actions.add_parser("create"), include_key=True, endpoint_key="objects.create"
1170
+ )
1171
+ object_update = object_actions.add_parser("update")
1172
+ object_update.add_argument("object_id")
1173
+ add_object_fields(object_update, include_key=False, endpoint_key="objects.update")
1174
+ for action in ("delete", "restore"):
1175
+ item = object_actions.add_parser(action)
1176
+ item.add_argument("object_id")
1177
+ add_yes(item)
1178
+ object_cli_policy = object_actions.add_parser(
1179
+ "cli-edit-policy", help="Read or update user CLI field edit policies."
1180
+ )
1181
+ object_cli_policy_actions = object_cli_policy.add_subparsers(
1182
+ dest="objects_cli_edit_policy_action", required=True
1183
+ )
1184
+ object_cli_policy_get = object_cli_policy_actions.add_parser("get")
1185
+ object_cli_policy_get.add_argument("object_id")
1186
+ object_cli_policy_update = object_cli_policy_actions.add_parser("update")
1187
+ object_cli_policy_update.add_argument("object_id")
1188
+ add_json_args(
1189
+ object_cli_policy_update,
1190
+ endpoint_key="objects.cli-edit-policy.update",
1191
+ )
1192
+ add_yes(object_cli_policy_update)
1193
+ objects.set_defaults(handler=handle_objects)
1194
+
1195
+ fields = groups.add_parser("fields", help="Manage field metadata.")
1196
+ field_actions = fields.add_subparsers(dest="fields_action", required=True)
1197
+ field_create = field_actions.add_parser("create")
1198
+ field_create.add_argument("object_id")
1199
+ add_object_fields(field_create, include_key=True, endpoint_key="fields.create")
1200
+ field_update = field_actions.add_parser("update")
1201
+ field_update.add_argument("object_id")
1202
+ field_update.add_argument("field_id")
1203
+ add_object_fields(field_update, include_key=False, endpoint_key="fields.update")
1204
+ for action in ("delete", "restore"):
1205
+ item = field_actions.add_parser(action)
1206
+ item.add_argument("object_id")
1207
+ item.add_argument("field_id")
1208
+ add_yes(item)
1209
+ fields.set_defaults(handler=handle_fields)
1210
+
1211
+ views = groups.add_parser("views", help="Manage object view metadata.")
1212
+ view_actions = views.add_subparsers(dest="views_action", required=True)
1213
+ view_list = view_actions.add_parser("list")
1214
+ view_list.add_argument("object_id")
1215
+ for action in ("create", "reorder"):
1216
+ item = view_actions.add_parser(action)
1217
+ item.add_argument("object_id")
1218
+ add_json_args(item, endpoint_key=f"views.{action}")
1219
+ view_update = view_actions.add_parser("update")
1220
+ view_update.add_argument("object_id")
1221
+ view_update.add_argument("view_id")
1222
+ add_json_args(view_update, endpoint_key="views.update")
1223
+ view_enabled = view_actions.add_parser("set-enabled")
1224
+ view_enabled.add_argument("object_id")
1225
+ view_enabled.add_argument("view_id")
1226
+ view_enabled.add_argument("--base-revision", required=True, type=int)
1227
+ view_enabled.add_argument(
1228
+ "--enabled",
1229
+ action=argparse.BooleanOptionalAction,
1230
+ required=True,
1231
+ )
1232
+ add_yes(view_enabled)
1233
+ view_delete = view_actions.add_parser("delete")
1234
+ view_delete.add_argument("object_id")
1235
+ view_delete.add_argument("view_id")
1236
+ view_delete.add_argument("--base-revision", required=True, type=int)
1237
+ add_yes(view_delete)
1238
+ views.set_defaults(handler=handle_views)
1239
+
1240
+ workflows = groups.add_parser("workflows", help="Manage object workflow metadata.")
1241
+ workflow_actions = workflows.add_subparsers(dest="workflows_action", required=True)
1242
+ workflow_list = workflow_actions.add_parser("list")
1243
+ workflow_list.add_argument("object_id")
1244
+ workflow_get = workflow_actions.add_parser("get")
1245
+ workflow_get.add_argument("object_id")
1246
+ workflow_get.add_argument("workflow_id")
1247
+ for action in ("create", "reorder"):
1248
+ item = workflow_actions.add_parser(action)
1249
+ item.add_argument("object_id")
1250
+ add_json_args(item, endpoint_key=f"workflows.{action}")
1251
+ workflow_update = workflow_actions.add_parser("update")
1252
+ workflow_update.add_argument("object_id")
1253
+ workflow_update.add_argument("workflow_id")
1254
+ add_json_args(workflow_update, endpoint_key="workflows.update")
1255
+ workflow_enabled = workflow_actions.add_parser("set-enabled")
1256
+ workflow_enabled.add_argument("object_id")
1257
+ workflow_enabled.add_argument("workflow_id")
1258
+ workflow_enabled.add_argument("--base-revision", required=True, type=int)
1259
+ workflow_enabled.add_argument(
1260
+ "--enabled",
1261
+ action=argparse.BooleanOptionalAction,
1262
+ required=True,
1263
+ )
1264
+ add_yes(workflow_enabled)
1265
+ workflow_delete = workflow_actions.add_parser("delete")
1266
+ workflow_delete.add_argument("object_id")
1267
+ workflow_delete.add_argument("workflow_id")
1268
+ workflow_delete.add_argument("--base-revision", required=True, type=int)
1269
+ add_yes(workflow_delete)
1270
+ workflow_executions = workflow_actions.add_parser("executions")
1271
+ workflow_executions.add_argument("object_id")
1272
+ workflow_executions.add_argument("workflow_id")
1273
+ workflow_executions.add_argument("--result", choices=("success", "failure"))
1274
+ workflows.set_defaults(handler=handle_workflows)
1275
+
1276
+ approvals = groups.add_parser("approvals", help="Manage object approval flow metadata.")
1277
+ approval_actions = approvals.add_subparsers(dest="approvals_action", required=True)
1278
+ approval_list = approval_actions.add_parser("list")
1279
+ approval_list.add_argument("object_id")
1280
+ approval_get = approval_actions.add_parser("get")
1281
+ approval_get.add_argument("object_id")
1282
+ approval_get.add_argument("approval_id")
1283
+ for action in ("create", "reorder"):
1284
+ item = approval_actions.add_parser(action)
1285
+ item.add_argument("object_id")
1286
+ add_json_args(item, endpoint_key=f"approvals.{action}")
1287
+ approval_update = approval_actions.add_parser("update")
1288
+ approval_update.add_argument("object_id")
1289
+ approval_update.add_argument("approval_id")
1290
+ add_json_args(approval_update, endpoint_key="approvals.update")
1291
+ approval_enabled = approval_actions.add_parser("set-enabled")
1292
+ approval_enabled.add_argument("object_id")
1293
+ approval_enabled.add_argument("approval_id")
1294
+ approval_enabled.add_argument("--base-revision", required=True, type=int)
1295
+ approval_enabled.add_argument(
1296
+ "--enabled", action=argparse.BooleanOptionalAction, required=True,
1297
+ )
1298
+ add_yes(approval_enabled)
1299
+ approval_delete = approval_actions.add_parser("delete")
1300
+ approval_delete.add_argument("object_id")
1301
+ approval_delete.add_argument("approval_id")
1302
+ approval_delete.add_argument("--base-revision", required=True, type=int)
1303
+ add_yes(approval_delete)
1304
+ approvals.set_defaults(handler=handle_approvals)
1305
+
1306
+ print_templates = groups.add_parser(
1307
+ "print-templates", help="Manage object print template metadata (HTML + data code).",
1308
+ )
1309
+ print_template_actions = print_templates.add_subparsers(
1310
+ dest="print_templates_action", required=True,
1311
+ )
1312
+ print_template_list = print_template_actions.add_parser("list")
1313
+ print_template_list.add_argument("object_id")
1314
+ print_template_get = print_template_actions.add_parser("get")
1315
+ print_template_get.add_argument("object_id")
1316
+ print_template_get.add_argument("template_id")
1317
+ for action in ("create", "reorder"):
1318
+ item = print_template_actions.add_parser(action)
1319
+ item.add_argument("object_id")
1320
+ add_json_args(item, endpoint_key=f"print-templates.{action}")
1321
+ print_template_update = print_template_actions.add_parser("update")
1322
+ print_template_update.add_argument("object_id")
1323
+ print_template_update.add_argument("template_id")
1324
+ add_json_args(print_template_update, endpoint_key="print-templates.update")
1325
+ print_template_enabled = print_template_actions.add_parser("set-enabled")
1326
+ print_template_enabled.add_argument("object_id")
1327
+ print_template_enabled.add_argument("template_id")
1328
+ print_template_enabled.add_argument("--base-revision", required=True, type=int)
1329
+ print_template_enabled.add_argument(
1330
+ "--enabled", action=argparse.BooleanOptionalAction, required=True,
1331
+ )
1332
+ add_yes(print_template_enabled)
1333
+ print_template_delete = print_template_actions.add_parser("delete")
1334
+ print_template_delete.add_argument("object_id")
1335
+ print_template_delete.add_argument("template_id")
1336
+ print_template_delete.add_argument("--base-revision", required=True, type=int)
1337
+ add_yes(print_template_delete)
1338
+ print_templates.set_defaults(handler=handle_print_templates)
1339
+
1340
+ pages = groups.add_parser("pages", help="Manage custom Page metadata.")
1341
+ page_actions = pages.add_subparsers(dest="pages_action", required=True)
1342
+ page_evaluate = page_actions.add_parser("evaluate", help="Evaluate sample rule effects, not visual rendering or record validation.")
1343
+ page_evaluate.add_argument("page_id")
1344
+ add_json_args(page_evaluate, endpoint_key="pages.evaluate")
1345
+ page_list = page_actions.add_parser("list")
1346
+ page_list.add_argument("--status", choices=("active", "deleted"), default="active")
1347
+ page_list.add_argument("--object-id")
1348
+ page_list.add_argument("--type", choices=("create", "detail"))
1349
+ page_list.add_argument("--keyword")
1350
+ page_list.add_argument("--page", type=int, default=1)
1351
+ page_actions.add_parser("options")
1352
+ page_get = page_actions.add_parser("get")
1353
+ page_get.add_argument("page_id")
1354
+ page_create = page_actions.add_parser("create")
1355
+ add_json_args(page_create, endpoint_key="pages.create")
1356
+ page_update = page_actions.add_parser("update")
1357
+ page_update.add_argument("page_id")
1358
+ add_json_args(page_update, endpoint_key="pages.update")
1359
+ for action in ("set-default", "clear-default", "delete", "restore"):
1360
+ item = page_actions.add_parser(action)
1361
+ item.add_argument("page_id")
1362
+ item.add_argument("--base-revision", required=True, type=int)
1363
+ add_yes(item)
1364
+ pages.set_defaults(handler=handle_pages)
1365
+
1366
+ changes = groups.add_parser("changes", help="Commit or release metadata versions.")
1367
+ change_actions = changes.add_subparsers(dest="changes_action", required=True)
1368
+ for action in ("commit", "release", "direct-release"):
1369
+ item = change_actions.add_parser(action)
1370
+ item.add_argument("--message", required=True)
1371
+ add_yes(item)
1372
+ changes.set_defaults(handler=handle_changes)
1373
+
1374
+ versions = groups.add_parser("versions", help="Inspect read-only configuration history.")
1375
+ version_actions = versions.add_subparsers(dest="versions_action", required=True)
1376
+ version_list = version_actions.add_parser("list")
1377
+ version_list.add_argument("--query")
1378
+ version_list.add_argument("--resource")
1379
+ version_list.add_argument(
1380
+ "--change-type",
1381
+ choices=("business-change", "format-migration"),
1382
+ )
1383
+ version_list.add_argument(
1384
+ "--release-status",
1385
+ choices=("released", "unreleased"),
1386
+ )
1387
+ version_list.add_argument(
1388
+ "--channel",
1389
+ choices=("web", "openapi", "cli", "ai", "system-migration"),
1390
+ )
1391
+ version_show = version_actions.add_parser("show")
1392
+ version_show.add_argument("version_id")
1393
+ version_actions.add_parser("releases")
1394
+ versions.set_defaults(handler=handle_versions)
1395
+
1396
+ environments = groups.add_parser("environments", help="Manage the current test environment.")
1397
+ environment_actions = environments.add_subparsers(dest="environments_action", required=True)
1398
+ environment_restart = environment_actions.add_parser("restart")
1399
+ environment_restart.add_argument(
1400
+ "--confirm",
1401
+ help="Exact environment name returned by preflight. Required in non-interactive mode.",
1402
+ )
1403
+ add_yes(environment_restart)
1404
+ environments.set_defaults(handler=handle_environments)
1405
+
1406
+ records = groups.add_parser("records", help="Manage business records.")
1407
+ record_actions = records.add_subparsers(dest="records_action", required=True)
1408
+ record_list = record_actions.add_parser("list")
1409
+ record_list.add_argument("object_id")
1410
+ record_list.add_argument("--page", type=int, default=1)
1411
+ record_list.add_argument("--view-id", help="Select a view returned by views list.")
1412
+ record_get = record_actions.add_parser("get")
1413
+ record_get.add_argument("object_id")
1414
+ record_get.add_argument("record_id")
1415
+ record_upload = record_actions.add_parser("upload-attachment")
1416
+ record_upload.add_argument("object_id")
1417
+ record_upload.add_argument("field_id")
1418
+ record_upload.add_argument("file")
1419
+ record_download = record_actions.add_parser("download-attachment")
1420
+ record_download.add_argument("object_id")
1421
+ record_download.add_argument("record_id")
1422
+ record_download.add_argument("attachment_id")
1423
+ record_download.add_argument("--output", required=True)
1424
+ record_create = record_actions.add_parser("create")
1425
+ record_create.add_argument("object_id")
1426
+ add_json_args(record_create, endpoint_key="records.create")
1427
+ record_update = record_actions.add_parser("update")
1428
+ record_update.add_argument("object_id")
1429
+ record_update.add_argument("record_id")
1430
+ add_json_args(record_update, endpoint_key="records.update")
1431
+ record_delete = record_actions.add_parser("delete")
1432
+ record_delete.add_argument("object_id")
1433
+ record_delete.add_argument("record_id")
1434
+ add_yes(record_delete)
1435
+ records.set_defaults(handler=handle_records)
1436
+
1437
+ roles = groups.add_parser("roles", help="Manage organization roles (production Host only).")
1438
+ role_actions = roles.add_subparsers(dest="roles_action", required=True)
1439
+ role_list = role_actions.add_parser("list")
1440
+ role_list.add_argument("--q", default="", help="Filter by role name.")
1441
+ role_list.add_argument("--page", type=int, default=1)
1442
+ role_actions.add_parser("options")
1443
+ role_get = role_actions.add_parser("get")
1444
+ role_get.add_argument("role_id")
1445
+ role_create = role_actions.add_parser("create")
1446
+ add_json_args(role_create, endpoint_key="roles.create")
1447
+ role_update = role_actions.add_parser("update")
1448
+ role_update.add_argument("role_id")
1449
+ add_json_args(role_update, endpoint_key="roles.update")
1450
+ role_delete = role_actions.add_parser("delete")
1451
+ role_delete.add_argument("role_id")
1452
+ add_yes(role_delete)
1453
+ roles.set_defaults(handler=handle_roles)
1454
+ return parser
1455
+
1456
+
1457
+ GLOBAL_FLAGS_WITH_VALUE = ("--host", "--api-key")
1458
+ GLOBAL_FLAGS_BOOL = ("--pretty", "--no-pretty", "--raw")
1459
+
1460
+
1461
+ def hoist_global_flags(argv: list[str]) -> list[str]:
1462
+ """Allow global flags after the subcommand, which is how AI-generated commands are usually written."""
1463
+ globals_out: list[str] = []
1464
+ rest: list[str] = []
1465
+ index = 0
1466
+ while index < len(argv):
1467
+ arg = argv[index]
1468
+ name = arg.split("=", 1)[0]
1469
+ if name in GLOBAL_FLAGS_WITH_VALUE:
1470
+ if "=" in arg:
1471
+ globals_out.append(arg)
1472
+ else:
1473
+ globals_out.append(arg)
1474
+ if index + 1 < len(argv) and not argv[index + 1].startswith("-"):
1475
+ index += 1
1476
+ globals_out.append(argv[index])
1477
+ elif name in GLOBAL_FLAGS_BOOL:
1478
+ globals_out.append(arg)
1479
+ else:
1480
+ rest.append(arg)
1481
+ index += 1
1482
+ return globals_out + rest
1483
+
1484
+
1485
+ def main(argv: list[str] | None = None) -> int:
1486
+ raw = list(argv) if argv is not None else sys.argv[1:]
1487
+ args = build_parser().parse_args(hoist_global_flags(raw))
1488
+ try:
1489
+ return args.handler(args)
1490
+ except CliError as exc:
1491
+ print(f"flowbase: error: {exc}", file=sys.stderr)
1492
+ return 1
1493
+
1494
+
1495
+ def entrypoint() -> None:
1496
+ raise SystemExit(main())
1497
+
1498
+
1499
+ if __name__ == "__main__":
1500
+ entrypoint()