deepflow-opendesk-cli 0.1.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.
- deepflow_opendesk_cli/__init__.py +3 -0
- deepflow_opendesk_cli/__main__.py +7 -0
- deepflow_opendesk_cli/batch.py +53 -0
- deepflow_opendesk_cli/cli.py +696 -0
- deepflow_opendesk_cli/client.py +323 -0
- deepflow_opendesk_cli/profiles.py +91 -0
- deepflow_opendesk_cli/schema.py +384 -0
- deepflow_opendesk_cli/workflows.py +37 -0
- deepflow_opendesk_cli-0.1.0.dist-info/METADATA +152 -0
- deepflow_opendesk_cli-0.1.0.dist-info/RECORD +13 -0
- deepflow_opendesk_cli-0.1.0.dist-info/WHEEL +5 -0
- deepflow_opendesk_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deepflow_opendesk_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""OpenAPI schema parsing and admin-configuration endpoint filtering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CONFIG_TAGS = {
|
|
17
|
+
"System",
|
|
18
|
+
"SystemSettings",
|
|
19
|
+
"OpenAgentSettings",
|
|
20
|
+
"Roles",
|
|
21
|
+
"Employees",
|
|
22
|
+
"EmployeeGroups",
|
|
23
|
+
"EmployeeSelect",
|
|
24
|
+
"ServiceHours",
|
|
25
|
+
"Channels",
|
|
26
|
+
"ConversationSettings",
|
|
27
|
+
"FieldDefinitions",
|
|
28
|
+
"FormLayouts",
|
|
29
|
+
"InteractionRules",
|
|
30
|
+
"UserViews",
|
|
31
|
+
"TicketViews",
|
|
32
|
+
"OrganizationViews",
|
|
33
|
+
"VoiceFlows",
|
|
34
|
+
"TicketWorkflows",
|
|
35
|
+
"InboundRoutingRules",
|
|
36
|
+
"SessionRoutingRules",
|
|
37
|
+
"Tenants",
|
|
38
|
+
"SIP Trunks",
|
|
39
|
+
"Phone Numbers",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
INCLUDE_PATHS = (
|
|
43
|
+
"/api/v1/auth/login",
|
|
44
|
+
"/api/v1/auth/me",
|
|
45
|
+
"/api/v1/session-summary/config*",
|
|
46
|
+
"/api/v1/call-summary/config*",
|
|
47
|
+
"/api/v1/queue/policies*",
|
|
48
|
+
"/api/v1/upload/avatar",
|
|
49
|
+
"/api/v1/upload/channel-logo",
|
|
50
|
+
"/api/v1/upload/channel-favicon",
|
|
51
|
+
"/api/v1/call-center/phone-numbers*",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
EXCLUDE_PATHS = (
|
|
55
|
+
"/api/v1/auth/reset-password",
|
|
56
|
+
"/api/v1/auth/send-verify-code",
|
|
57
|
+
"/api/v1/public/*",
|
|
58
|
+
"/api/v1/conversations*",
|
|
59
|
+
"/api/v1/session-records*",
|
|
60
|
+
"/api/v1/tickets*",
|
|
61
|
+
"/api/v1/users*",
|
|
62
|
+
"/api/v1/organizations*",
|
|
63
|
+
"/api/v1/reports*",
|
|
64
|
+
"/api/v1/workspace/*",
|
|
65
|
+
"/api/v1/agent/*",
|
|
66
|
+
"/api/v1/call-center/agents/*",
|
|
67
|
+
"/api/v1/queue/tasks*",
|
|
68
|
+
"/api/v1/queue/dispatch",
|
|
69
|
+
"/api/v1/queue/state",
|
|
70
|
+
"/api/v1/upload/custom-field-file",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
CURATED_ALIASES: dict[tuple[str, str], str] = {
|
|
74
|
+
("POST", "/api/v1/auth/login"): "auth.login",
|
|
75
|
+
("GET", "/api/v1/auth/me"): "auth.me",
|
|
76
|
+
("GET", "/api/v1/system/info"): "system.info",
|
|
77
|
+
("GET", "/api/v1/system-settings"): "system.settings",
|
|
78
|
+
("PUT", "/api/v1/system-settings"): "system.settings_update",
|
|
79
|
+
("PUT", "/api/v1/system-settings/organization"): "system.organization_update",
|
|
80
|
+
("GET", "/api/v1/roles"): "roles.list",
|
|
81
|
+
("POST", "/api/v1/roles"): "roles.create",
|
|
82
|
+
("GET", "/api/v1/roles/permission-tree"): "roles.permission_tree",
|
|
83
|
+
("GET", "/api/v1/employees"): "employees.list",
|
|
84
|
+
("POST", "/api/v1/employees"): "employees.create",
|
|
85
|
+
("GET", "/api/v1/employee-groups"): "employee_groups.list",
|
|
86
|
+
("GET", "/api/v1/field-definitions"): "fields.list",
|
|
87
|
+
("POST", "/api/v1/field-definitions"): "fields.create",
|
|
88
|
+
("GET", "/api/v1/field-definitions/unified"): "fields.unified",
|
|
89
|
+
("GET", "/api/v1/form-layouts/by-scene/{scene}"): "forms.get_by_scene",
|
|
90
|
+
("GET", "/api/v1/channels"): "channels.list",
|
|
91
|
+
("GET", "/api/v1/channels/{channel_id}"): "channels.get",
|
|
92
|
+
("GET", "/api/v1/session-routing-rules"): "routing.session_list",
|
|
93
|
+
("GET", "/api/v1/inbound-routing-rules"): "routing.inbound_list",
|
|
94
|
+
("GET", "/api/v1/queue/policies"): "queue.policies",
|
|
95
|
+
("GET", "/api/v1/voice-flows"): "voice_flows.list",
|
|
96
|
+
("GET", "/api/v1/ticket-workflows"): "ticket_workflows.list",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
|
|
100
|
+
NO_AUTH_PATHS = {"/api/v1/auth/login", "/api/v1/system/info", "/api/v1/health"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class ParamDoc:
|
|
105
|
+
"""Path or query parameter documentation."""
|
|
106
|
+
|
|
107
|
+
name: str
|
|
108
|
+
type: str
|
|
109
|
+
required: bool
|
|
110
|
+
description: str = ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class EndpointDoc:
|
|
115
|
+
"""Machine-readable endpoint documentation for humans and AI agents."""
|
|
116
|
+
|
|
117
|
+
key: str
|
|
118
|
+
operation_id: str
|
|
119
|
+
method: str
|
|
120
|
+
path: str
|
|
121
|
+
tag: str
|
|
122
|
+
summary: str
|
|
123
|
+
path_params: list[ParamDoc] = field(default_factory=list)
|
|
124
|
+
query_params: list[ParamDoc] = field(default_factory=list)
|
|
125
|
+
body_schema: str = ""
|
|
126
|
+
response_schema: str = ""
|
|
127
|
+
requires_auth: bool = True
|
|
128
|
+
examples: list[str] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
"""Return a JSON-serializable representation."""
|
|
132
|
+
return asdict(self)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_schema_file(path: str | Path) -> dict[str, Any]:
|
|
136
|
+
"""Read an OpenAPI schema object from disk."""
|
|
137
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
raise ValueError("OpenAPI schema must be a JSON object")
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def fetch_openapi_schema(host: str, token: str | None = None, timeout: int = 30) -> dict[str, Any]:
|
|
144
|
+
"""Fetch ``/openapi.json`` from an OpenDesk host."""
|
|
145
|
+
url = f"{host.rstrip('/')}/openapi.json"
|
|
146
|
+
headers = {"Accept": "application/json"}
|
|
147
|
+
if token:
|
|
148
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
149
|
+
request = Request(url, headers=headers, method="GET")
|
|
150
|
+
try:
|
|
151
|
+
with urlopen(request, timeout=timeout) as response: # noqa: S310 - CLI calls user-provided hosts.
|
|
152
|
+
payload = response.read().decode("utf-8")
|
|
153
|
+
except HTTPError as exc:
|
|
154
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
155
|
+
raise RuntimeError(f"HTTP {exc.code} while fetching OpenAPI schema: {body}") from exc
|
|
156
|
+
except URLError as exc:
|
|
157
|
+
raise RuntimeError(f"Failed to fetch OpenAPI schema from {url}: {exc.reason}") from exc
|
|
158
|
+
data = json.loads(payload)
|
|
159
|
+
if not isinstance(data, dict):
|
|
160
|
+
raise RuntimeError("OpenAPI schema response must be a JSON object")
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_catalog(schema: dict[str, Any]) -> list[EndpointDoc]:
|
|
165
|
+
"""Build the configuration endpoint catalog from an OpenAPI schema."""
|
|
166
|
+
endpoints: list[EndpointDoc] = []
|
|
167
|
+
paths = schema.get("paths", {})
|
|
168
|
+
if not isinstance(paths, dict):
|
|
169
|
+
return endpoints
|
|
170
|
+
|
|
171
|
+
for path, operations in paths.items():
|
|
172
|
+
if not isinstance(path, str) or not isinstance(operations, dict):
|
|
173
|
+
continue
|
|
174
|
+
for raw_method, operation in operations.items():
|
|
175
|
+
method = raw_method.upper()
|
|
176
|
+
if method not in HTTP_METHODS or not isinstance(operation, dict):
|
|
177
|
+
continue
|
|
178
|
+
tag = _first_tag(operation)
|
|
179
|
+
if not is_config_endpoint(method, path, tag):
|
|
180
|
+
continue
|
|
181
|
+
endpoints.append(_endpoint_from_operation(method, path, tag, operation))
|
|
182
|
+
|
|
183
|
+
return _dedupe_keys(endpoints)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def catalog_by_key(catalog: list[EndpointDoc]) -> dict[str, EndpointDoc]:
|
|
187
|
+
"""Index endpoints by key and OpenAPI operation id."""
|
|
188
|
+
indexed: dict[str, EndpointDoc] = {}
|
|
189
|
+
for endpoint in catalog:
|
|
190
|
+
indexed[endpoint.key] = endpoint
|
|
191
|
+
if endpoint.operation_id:
|
|
192
|
+
indexed[endpoint.operation_id] = endpoint
|
|
193
|
+
return indexed
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_endpoint(catalog: list[EndpointDoc], key: str) -> EndpointDoc:
|
|
197
|
+
"""Return an endpoint by key or operation id."""
|
|
198
|
+
indexed = catalog_by_key(catalog)
|
|
199
|
+
try:
|
|
200
|
+
return indexed[key]
|
|
201
|
+
except KeyError as exc:
|
|
202
|
+
choices = ", ".join(sorted(item.key for item in catalog))
|
|
203
|
+
raise KeyError(f"Unknown endpoint '{key}'. Available endpoints: {choices}") from exc
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def is_config_endpoint(method: str, path: str, tag: str) -> bool:
|
|
207
|
+
"""Return whether an endpoint belongs to the admin configuration surface."""
|
|
208
|
+
_ = method
|
|
209
|
+
if _matches_any(path, EXCLUDE_PATHS):
|
|
210
|
+
return False
|
|
211
|
+
if _matches_any(path, INCLUDE_PATHS):
|
|
212
|
+
return True
|
|
213
|
+
return tag in CONFIG_TAGS
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def find_matching_endpoint(catalog: list[EndpointDoc], method: str, path: str) -> EndpointDoc | None:
|
|
217
|
+
"""Find a catalog endpoint for a concrete path."""
|
|
218
|
+
method = method.upper()
|
|
219
|
+
for endpoint in catalog:
|
|
220
|
+
if endpoint.method != method:
|
|
221
|
+
continue
|
|
222
|
+
if endpoint.path == path or _path_template_matches(endpoint.path, path):
|
|
223
|
+
return endpoint
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def normalize_identifier(value: str) -> str:
|
|
228
|
+
"""Normalize a label into a stable snake_case identifier."""
|
|
229
|
+
value = re.sub(r"[^0-9A-Za-z]+", "_", value).strip("_").lower()
|
|
230
|
+
return re.sub(r"_+", "_", value) or "endpoint"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def schema_ref_name(schema_fragment: Any) -> str:
|
|
234
|
+
"""Summarize a schema fragment as a compact string."""
|
|
235
|
+
if not isinstance(schema_fragment, dict):
|
|
236
|
+
return ""
|
|
237
|
+
ref = schema_fragment.get("$ref")
|
|
238
|
+
if isinstance(ref, str):
|
|
239
|
+
return ref.rsplit("/", 1)[-1]
|
|
240
|
+
if "type" in schema_fragment:
|
|
241
|
+
return str(schema_fragment["type"])
|
|
242
|
+
if "allOf" in schema_fragment:
|
|
243
|
+
return "allOf"
|
|
244
|
+
if "anyOf" in schema_fragment:
|
|
245
|
+
return "anyOf"
|
|
246
|
+
if "oneOf" in schema_fragment:
|
|
247
|
+
return "oneOf"
|
|
248
|
+
return ""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _endpoint_from_operation(method: str, path: str, tag: str, operation: dict[str, Any]) -> EndpointDoc:
|
|
252
|
+
operation_id = str(operation.get("operationId") or "")
|
|
253
|
+
summary = str(operation.get("summary") or operation_id or f"{method} {path}")
|
|
254
|
+
key = CURATED_ALIASES.get((method, path)) or f"{normalize_identifier(tag)}.{_operation_action(operation_id, summary)}"
|
|
255
|
+
path_params, query_params = _params(operation)
|
|
256
|
+
body_schema = _request_body_schema(operation)
|
|
257
|
+
response_schema = _response_schema(operation)
|
|
258
|
+
return EndpointDoc(
|
|
259
|
+
key=key,
|
|
260
|
+
operation_id=operation_id,
|
|
261
|
+
method=method,
|
|
262
|
+
path=path,
|
|
263
|
+
tag=tag,
|
|
264
|
+
summary=summary,
|
|
265
|
+
path_params=path_params,
|
|
266
|
+
query_params=query_params,
|
|
267
|
+
body_schema=body_schema,
|
|
268
|
+
response_schema=response_schema,
|
|
269
|
+
requires_auth=path not in NO_AUTH_PATHS,
|
|
270
|
+
examples=[_example_for(key, method, path)],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _dedupe_keys(endpoints: list[EndpointDoc]) -> list[EndpointDoc]:
|
|
275
|
+
seen: dict[str, int] = {}
|
|
276
|
+
result: list[EndpointDoc] = []
|
|
277
|
+
for endpoint in endpoints:
|
|
278
|
+
count = seen.get(endpoint.key, 0)
|
|
279
|
+
seen[endpoint.key] = count + 1
|
|
280
|
+
if count == 0:
|
|
281
|
+
result.append(endpoint)
|
|
282
|
+
continue
|
|
283
|
+
suffix = hashlib.sha1(f"{endpoint.method} {endpoint.path}".encode("utf-8")).hexdigest()[:8]
|
|
284
|
+
result.append(
|
|
285
|
+
EndpointDoc(
|
|
286
|
+
key=f"{endpoint.key}_{suffix}",
|
|
287
|
+
operation_id=endpoint.operation_id,
|
|
288
|
+
method=endpoint.method,
|
|
289
|
+
path=endpoint.path,
|
|
290
|
+
tag=endpoint.tag,
|
|
291
|
+
summary=endpoint.summary,
|
|
292
|
+
path_params=endpoint.path_params,
|
|
293
|
+
query_params=endpoint.query_params,
|
|
294
|
+
body_schema=endpoint.body_schema,
|
|
295
|
+
response_schema=endpoint.response_schema,
|
|
296
|
+
requires_auth=endpoint.requires_auth,
|
|
297
|
+
examples=endpoint.examples,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _first_tag(operation: dict[str, Any]) -> str:
|
|
304
|
+
tags = operation.get("tags")
|
|
305
|
+
if isinstance(tags, list) and tags:
|
|
306
|
+
return str(tags[0])
|
|
307
|
+
return ""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _params(operation: dict[str, Any]) -> tuple[list[ParamDoc], list[ParamDoc]]:
|
|
311
|
+
path_params: list[ParamDoc] = []
|
|
312
|
+
query_params: list[ParamDoc] = []
|
|
313
|
+
raw_params = operation.get("parameters", [])
|
|
314
|
+
if not isinstance(raw_params, list):
|
|
315
|
+
return path_params, query_params
|
|
316
|
+
for raw in raw_params:
|
|
317
|
+
if not isinstance(raw, dict):
|
|
318
|
+
continue
|
|
319
|
+
schema = raw.get("schema") if isinstance(raw.get("schema"), dict) else {}
|
|
320
|
+
item = ParamDoc(
|
|
321
|
+
name=str(raw.get("name") or ""),
|
|
322
|
+
type=str(schema.get("type") or schema_ref_name(schema) or "string"),
|
|
323
|
+
required=bool(raw.get("required")),
|
|
324
|
+
description=str(raw.get("description") or ""),
|
|
325
|
+
)
|
|
326
|
+
location = raw.get("in")
|
|
327
|
+
if location == "path":
|
|
328
|
+
path_params.append(item)
|
|
329
|
+
elif location == "query":
|
|
330
|
+
query_params.append(item)
|
|
331
|
+
return path_params, query_params
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _request_body_schema(operation: dict[str, Any]) -> str:
|
|
335
|
+
body = operation.get("requestBody")
|
|
336
|
+
if not isinstance(body, dict):
|
|
337
|
+
return ""
|
|
338
|
+
content = body.get("content")
|
|
339
|
+
if not isinstance(content, dict):
|
|
340
|
+
return ""
|
|
341
|
+
for content_type in ("application/json", "multipart/form-data"):
|
|
342
|
+
media = content.get(content_type)
|
|
343
|
+
if isinstance(media, dict):
|
|
344
|
+
return schema_ref_name(media.get("schema"))
|
|
345
|
+
return ""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _response_schema(operation: dict[str, Any]) -> str:
|
|
349
|
+
responses = operation.get("responses")
|
|
350
|
+
if not isinstance(responses, dict):
|
|
351
|
+
return ""
|
|
352
|
+
response = responses.get("200") or responses.get("201") or responses.get("default")
|
|
353
|
+
if not isinstance(response, dict):
|
|
354
|
+
return ""
|
|
355
|
+
content = response.get("content")
|
|
356
|
+
if not isinstance(content, dict):
|
|
357
|
+
return ""
|
|
358
|
+
media = content.get("application/json")
|
|
359
|
+
if isinstance(media, dict):
|
|
360
|
+
return schema_ref_name(media.get("schema"))
|
|
361
|
+
return ""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _operation_action(operation_id: str, summary: str) -> str:
|
|
365
|
+
base = operation_id.split("_api_v1_", 1)[0] if operation_id else summary
|
|
366
|
+
return normalize_identifier(base)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _example_for(key: str, method: str, path: str) -> str:
|
|
370
|
+
if "{" in path:
|
|
371
|
+
return f"deepflow_opendesk_cli call {key} --path name=value"
|
|
372
|
+
if method in {"POST", "PUT", "PATCH"}:
|
|
373
|
+
return f"deepflow_opendesk_cli call {key} --json '{{}}'"
|
|
374
|
+
return f"deepflow_opendesk_cli call {key}"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _matches_any(path: str, patterns: tuple[str, ...]) -> bool:
|
|
378
|
+
return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _path_template_matches(template: str, path: str) -> bool:
|
|
382
|
+
pattern = re.escape(template)
|
|
383
|
+
pattern = re.sub(r"\\\{[^/]+\\\}", r"[^/]+", pattern)
|
|
384
|
+
return re.fullmatch(pattern, path) is not None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Simple workflow plan helpers for OpenDesk admin configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import CliError
|
|
10
|
+
|
|
11
|
+
WORKFLOWS: dict[str, str] = {
|
|
12
|
+
"setup-tenant": "Initialize tenant-level system settings and optional tenant metadata.",
|
|
13
|
+
"setup-web-channel": "Configure Web SDK channel, welcome rules, and routing prerequisites.",
|
|
14
|
+
"bootstrap-admin-config": "Apply fields, views, forms, service hours, and routing configuration.",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_workflows() -> list[dict[str, str]]:
|
|
19
|
+
"""Return available workflow names and descriptions."""
|
|
20
|
+
return [{"name": name, "description": description} for name, description in sorted(WORKFLOWS.items())]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_workflow_operations(name: str, json_file: str | None) -> list[dict[str, Any]]:
|
|
24
|
+
"""Load workflow operations from a JSON file."""
|
|
25
|
+
if name not in WORKFLOWS:
|
|
26
|
+
choices = ", ".join(sorted(WORKFLOWS))
|
|
27
|
+
raise CliError(f"Unknown workflow '{name}'. Available workflows: {choices}")
|
|
28
|
+
if not json_file:
|
|
29
|
+
raise CliError("workflow run requires --json-file with an operations plan")
|
|
30
|
+
payload = json.loads(Path(json_file).read_text(encoding="utf-8"))
|
|
31
|
+
if isinstance(payload, dict):
|
|
32
|
+
operations = payload.get("operations")
|
|
33
|
+
else:
|
|
34
|
+
operations = payload
|
|
35
|
+
if not isinstance(operations, list) or not all(isinstance(item, dict) for item in operations):
|
|
36
|
+
raise CliError("Workflow plan must be a JSON object with operations[] or a JSON array")
|
|
37
|
+
return list(operations)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deepflow-opendesk-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for OpenDesk admin configuration APIs.
|
|
5
|
+
Author: DeepFlow AI
|
|
6
|
+
Project-URL: Repository, https://github.com/DeepFlowAI/opendesk-dev
|
|
7
|
+
Keywords: deepflow,opendesk,cli,openapi,admin,configuration
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
|
|
23
|
+
# deepflow-opendesk-cli
|
|
24
|
+
|
|
25
|
+
Python command-line client for OpenDesk admin configuration APIs.
|
|
26
|
+
|
|
27
|
+
The CLI is designed for implementation engineers, operators, and AI agents that need to configure OpenDesk through the same APIs used by the admin console.
|
|
28
|
+
|
|
29
|
+
## Scope
|
|
30
|
+
|
|
31
|
+
This package intentionally exposes only admin configuration APIs. Runtime workspace data, public visitor APIs, reports, and call-center agent actions are excluded by a schema whitelist.
|
|
32
|
+
|
|
33
|
+
Included examples:
|
|
34
|
+
|
|
35
|
+
- System settings
|
|
36
|
+
- Roles, employees, employee groups
|
|
37
|
+
- Service hours
|
|
38
|
+
- Channels
|
|
39
|
+
- Field definitions, form layouts, interaction rules
|
|
40
|
+
- User, ticket, and organization view configuration
|
|
41
|
+
- Satisfaction and welcome-message settings
|
|
42
|
+
- Voice flows, ticket workflows, inbound/session routing rules
|
|
43
|
+
- Queue policies
|
|
44
|
+
- Optional tenant, SIP trunk, and phone-number configuration endpoints when extensions are loaded
|
|
45
|
+
|
|
46
|
+
## Install For Development
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd packages/deepflow-opendesk-cli
|
|
50
|
+
pip install -e ".[dev]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configure Host And Token
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export OPENDESK_HOST="http://localhost:5001"
|
|
57
|
+
export OPENDESK_TOKEN="..."
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or log in and save a local profile:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
deepflow_opendesk_cli auth login \
|
|
64
|
+
--tenant 12058577c8114513 \
|
|
65
|
+
--username test \
|
|
66
|
+
--save-token
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Profile files are stored under `~/.opendesk/cli/{profile}/` with owner-only permissions where supported.
|
|
70
|
+
|
|
71
|
+
## Common Commands
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
deepflow_opendesk_cli schema fetch
|
|
75
|
+
deepflow_opendesk_cli docs list --tag FieldDefinitions
|
|
76
|
+
deepflow_opendesk_cli docs show fields.create --json
|
|
77
|
+
|
|
78
|
+
deepflow_opendesk_cli request GET /api/v1/system/info
|
|
79
|
+
deepflow_opendesk_cli call fields.list --query domain=ticket
|
|
80
|
+
deepflow_opendesk_cli call fields.create --json-file field.json
|
|
81
|
+
|
|
82
|
+
deepflow_opendesk_cli system settings
|
|
83
|
+
deepflow_opendesk_cli roles permission-tree
|
|
84
|
+
deepflow_opendesk_cli employees list
|
|
85
|
+
deepflow_opendesk_cli channels list
|
|
86
|
+
deepflow_opendesk_cli routing session list
|
|
87
|
+
deepflow_opendesk_cli queue policies
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Equivalent module invocation:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python -m deepflow_opendesk_cli docs list
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## AI Agent Flow
|
|
97
|
+
|
|
98
|
+
Recommended execution loop:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
1. schema fetch or docs list
|
|
102
|
+
2. docs show <endpoint-key>
|
|
103
|
+
3. request/call --dry-run
|
|
104
|
+
4. present plan to a human
|
|
105
|
+
5. batch run / workflow run --yes
|
|
106
|
+
6. verify with config list/get commands
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Batch
|
|
110
|
+
|
|
111
|
+
`batch run` accepts JSONL. Each line is a config operation:
|
|
112
|
+
|
|
113
|
+
```jsonl
|
|
114
|
+
{"key":"auth.me"}
|
|
115
|
+
{"key":"fields.create","body":{"domain":"ticket","name":"Customer Tier","field_type":"select"}}
|
|
116
|
+
{"method":"PUT","path":"/api/v1/system-settings","body":{"default_language":"zh","default_timezone":"Asia/Shanghai"}}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
deepflow_opendesk_cli batch run ops.jsonl --dry-run
|
|
123
|
+
deepflow_opendesk_cli batch run ops.jsonl --continue-on-error --output results.jsonl
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
cd packages/deepflow-opendesk-cli
|
|
130
|
+
PYTHONPATH=src python -m pytest tests -q
|
|
131
|
+
python -m build
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Distribution
|
|
135
|
+
|
|
136
|
+
Releases are automated. Pushing a `v*` git tag (e.g. `v0.1.1`) triggers
|
|
137
|
+
`.github/workflows/release.yml`, which runs the tests, builds the sdist + wheel,
|
|
138
|
+
publishes `deepflow-opendesk-cli` to PyPI, and cuts a GitHub Release.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
pip install deepflow-opendesk-cli
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
To cut a patch release, run the helper script (bumps the version across
|
|
145
|
+
`pyproject.toml`, `setup.py`, and `__init__.py`, then tags and pushes):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bash .claude/skills/release-publish/scripts/release-tag.sh --dry-run
|
|
149
|
+
bash .claude/skills/release-publish/scripts/release-tag.sh
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
To build locally without publishing: `python -m build`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
deepflow_opendesk_cli/__init__.py,sha256=QQUA2RQSL5navxL2IBHLJuCcYDNGu6DlVSvyG0Bj-cA,67
|
|
2
|
+
deepflow_opendesk_cli/__main__.py,sha256=8T1O4_06PjtF3IgGo08A184Ih4Nv0Sokp4SNQAAebBA,146
|
|
3
|
+
deepflow_opendesk_cli/batch.py,sha256=6b2NggZO6PZkSU0zNRqiYfEPrtRKx0QI_ALnLJx2zr4,1837
|
|
4
|
+
deepflow_opendesk_cli/cli.py,sha256=m_5ONJKhHBSOgUeBVJnDLL4kaP_09vlPxUeO9kpz1Ig,27751
|
|
5
|
+
deepflow_opendesk_cli/client.py,sha256=xfaRE8E0lhSFf8oST4xHRbwd6ysXnYntEpwiTpFUlcQ,10793
|
|
6
|
+
deepflow_opendesk_cli/profiles.py,sha256=1qxnwZPtOMKZuHYR2KyQrNimZiKCoZJmmoHkZvzd6n8,2683
|
|
7
|
+
deepflow_opendesk_cli/schema.py,sha256=LCVFLelF1HYRIwcBSjFlBWnGskRZBLb-cNZWlq744L8,13336
|
|
8
|
+
deepflow_opendesk_cli/workflows.py,sha256=1SOJM_m4_S0MgyiJwGZ3OsabG6yB03E56qF7mgijrVQ,1561
|
|
9
|
+
deepflow_opendesk_cli-0.1.0.dist-info/METADATA,sha256=7vlFUyMEu_SippEoKeVBqwYcXle_Yc5wBxnLfFSgJVo,4332
|
|
10
|
+
deepflow_opendesk_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
deepflow_opendesk_cli-0.1.0.dist-info/entry_points.txt,sha256=5mTQ__pNqudneSyStqhp1LTYH0_zWuZEwGmV4iO-Qxk,73
|
|
12
|
+
deepflow_opendesk_cli-0.1.0.dist-info/top_level.txt,sha256=JrIzmrPOJORIaCHKzwnFXoV1N1TmHFY3jOv-7LAzx7E,22
|
|
13
|
+
deepflow_opendesk_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
deepflow_opendesk_cli
|