guzli-cli 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 (53) hide show
  1. guzli/__init__.py +3 -0
  2. guzli/__main__.py +4 -0
  3. guzli/auth.py +182 -0
  4. guzli/cli.py +84 -0
  5. guzli/commands/__init__.py +1 -0
  6. guzli/commands/admin.py +421 -0
  7. guzli/commands/auth.py +502 -0
  8. guzli/commands/campaign_launch.py +188 -0
  9. guzli/commands/campaigns.py +571 -0
  10. guzli/commands/common.py +42 -0
  11. guzli/commands/conversations.py +404 -0
  12. guzli/commands/integrations.py +48 -0
  13. guzli/commands/telephony.py +274 -0
  14. guzli/commands/voice_calls.py +270 -0
  15. guzli/config.py +137 -0
  16. guzli/config_store.py +218 -0
  17. guzli/errors.py +47 -0
  18. guzli/http_client.py +213 -0
  19. guzli/mcp/__init__.py +1 -0
  20. guzli/mcp/server.py +320 -0
  21. guzli/models.py +173 -0
  22. guzli/oauth_callback.py +126 -0
  23. guzli/output.py +79 -0
  24. guzli/runtime.py +159 -0
  25. guzli/services/__init__.py +29 -0
  26. guzli/services/api_keys.py +105 -0
  27. guzli/services/calls.py +48 -0
  28. guzli/services/campaigns.py +193 -0
  29. guzli/services/cli_auth.py +103 -0
  30. guzli/services/conversations.py +112 -0
  31. guzli/services/integrations.py +18 -0
  32. guzli/services/replies.py +21 -0
  33. guzli/services/telephony.py +80 -0
  34. guzli/services/voice_profiles.py +56 -0
  35. guzli/services/webhooks.py +169 -0
  36. guzli/types.py +15 -0
  37. guzli/validators.py +83 -0
  38. guzli/workflows/__init__.py +13 -0
  39. guzli/workflows/campaign_launch.py +501 -0
  40. guzli_cli-0.2.0.data/data/share/guzli/CHANGELOG.md +16 -0
  41. guzli_cli-0.2.0.data/data/share/guzli/PROVENANCE.md +23 -0
  42. guzli_cli-0.2.0.data/data/share/guzli/SECURITY.md +18 -0
  43. guzli_cli-0.2.0.data/data/share/guzli/SECURITY_AUDIT.md +47 -0
  44. guzli_cli-0.2.0.data/data/share/guzli/SKILL.md +101 -0
  45. guzli_cli-0.2.0.data/data/share/guzli/examples/compliance-advisory-us.json +15 -0
  46. guzli_cli-0.2.0.data/data/share/guzli/examples/pizza-order-extraction.json +65 -0
  47. guzli_cli-0.2.0.data/data/share/guzli/references/api.md +245 -0
  48. guzli_cli-0.2.0.dist-info/METADATA +341 -0
  49. guzli_cli-0.2.0.dist-info/RECORD +53 -0
  50. guzli_cli-0.2.0.dist-info/WHEEL +5 -0
  51. guzli_cli-0.2.0.dist-info/entry_points.txt +3 -0
  52. guzli_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
  53. guzli_cli-0.2.0.dist-info/top_level.txt +1 -0
guzli/config_store.py ADDED
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from collections.abc import Mapping
7
+ from contextlib import suppress
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from guzli.errors import ConfigError
12
+
13
+ CONFIG_DIR_ENV = "GUZLI_CONFIG_DIR"
14
+ DEFAULT_CONFIG_DIR = Path.home() / ".guzli"
15
+ CONFIG_FILE_NAME = "config.json"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class StoredProfile:
20
+ name: str
21
+ base_url: str | None = None
22
+ api_key: str | None = None
23
+ # Engine-issued access token, or a manually pasted JWT for the legacy workflow.
24
+ bearer_token: str | None = None
25
+ # Opaque refresh handle returned by /auth/cli/oauth/exchange.
26
+ # before bearer_token expires. Rotates on every refresh — must save the new value back.
27
+ refresh_handle: str | None = None
28
+ # ISO-8601 datetime of bearer_token expiry. Used to decide when to call /refresh.
29
+ access_token_expires_at: str | None = None
30
+ # Connected CLI session id from CliOAuthSessionResponse.id. Surfaced for debugging
31
+ # ("which connected client am I?") and so `auth status` can show it.
32
+ cli_session_id: str | None = None
33
+ agent_id: str | None = None
34
+ organization_id: str | None = None
35
+ default_number_pool_id: str | None = None
36
+ timeout_seconds: int | None = None
37
+
38
+ def to_dict(self) -> dict[str, object]:
39
+ data: dict[str, object] = {}
40
+ if self.base_url:
41
+ data["base_url"] = self.base_url
42
+ if self.api_key:
43
+ data["api_key"] = self.api_key
44
+ if self.bearer_token:
45
+ data["bearer_token"] = self.bearer_token
46
+ if self.refresh_handle:
47
+ data["refresh_handle"] = self.refresh_handle
48
+ if self.access_token_expires_at:
49
+ data["access_token_expires_at"] = self.access_token_expires_at
50
+ if self.cli_session_id:
51
+ data["cli_session_id"] = self.cli_session_id
52
+ if self.agent_id:
53
+ data["agent_id"] = self.agent_id
54
+ if self.organization_id:
55
+ data["organization_id"] = self.organization_id
56
+ if self.default_number_pool_id:
57
+ data["default_number_pool_id"] = self.default_number_pool_id
58
+ if self.timeout_seconds is not None:
59
+ data["timeout_seconds"] = self.timeout_seconds
60
+ return data
61
+
62
+
63
+ def _config_dir() -> Path:
64
+ override = os.getenv(CONFIG_DIR_ENV)
65
+ return Path(override) if override else DEFAULT_CONFIG_DIR
66
+
67
+
68
+ def _config_path() -> Path:
69
+ return _config_dir() / CONFIG_FILE_NAME
70
+
71
+
72
+ class ConfigStore:
73
+ """Read/write profile credentials at ~/.guzli/config.json."""
74
+
75
+ def __init__(self, path: Path | None = None) -> None:
76
+ self._path = path or _config_path()
77
+
78
+ @property
79
+ def path(self) -> Path:
80
+ return self._path
81
+
82
+ def exists(self) -> bool:
83
+ return self._path.is_file()
84
+
85
+ def load_raw(self) -> dict[str, object]:
86
+ if not self._path.is_file():
87
+ return {"active_profile": None, "profiles": {}}
88
+ try:
89
+ with self._path.open("r", encoding="utf-8") as handle:
90
+ data = json.load(handle)
91
+ except (OSError, json.JSONDecodeError) as exc:
92
+ raise ConfigError(f"Could not read {self._path}: {exc}") from exc
93
+ if not isinstance(data, dict):
94
+ raise ConfigError(f"{self._path} must contain a JSON object")
95
+ profiles = data.get("profiles")
96
+ if not isinstance(profiles, dict):
97
+ data["profiles"] = {}
98
+ if data.get("active_profile") is not None and not isinstance(data["active_profile"], str):
99
+ data["active_profile"] = None
100
+ return data
101
+
102
+ def list_profiles(self) -> list[str]:
103
+ raw = self.load_raw()
104
+ profiles = raw.get("profiles")
105
+ return sorted(profiles.keys()) if isinstance(profiles, dict) else []
106
+
107
+ def active_profile_name(self) -> str | None:
108
+ raw = self.load_raw()
109
+ name = raw.get("active_profile")
110
+ return name if isinstance(name, str) else None
111
+
112
+ def get_profile(self, profile_name: str | None = None) -> StoredProfile | None:
113
+ raw = self.load_raw()
114
+ profiles = raw.get("profiles")
115
+ if not isinstance(profiles, dict):
116
+ return None
117
+ name = profile_name or raw.get("active_profile")
118
+ if not isinstance(name, str) or name not in profiles:
119
+ return None
120
+ entry = profiles[name]
121
+ if not isinstance(entry, Mapping):
122
+ return None
123
+ return StoredProfile(
124
+ name=name,
125
+ base_url=_string_or_none(entry.get("base_url")),
126
+ api_key=_string_or_none(entry.get("api_key")),
127
+ bearer_token=_string_or_none(entry.get("bearer_token")),
128
+ refresh_handle=_string_or_none(entry.get("refresh_handle")),
129
+ access_token_expires_at=_string_or_none(entry.get("access_token_expires_at")),
130
+ cli_session_id=_string_or_none(entry.get("cli_session_id")),
131
+ agent_id=_string_or_none(entry.get("agent_id")),
132
+ organization_id=_string_or_none(entry.get("organization_id")),
133
+ default_number_pool_id=_string_or_none(entry.get("default_number_pool_id")),
134
+ timeout_seconds=_int_or_none(entry.get("timeout_seconds")),
135
+ )
136
+
137
+ def save_profile(self, profile: StoredProfile, *, set_active: bool = True) -> None:
138
+ raw = self.load_raw()
139
+ profiles = raw.get("profiles")
140
+ if not isinstance(profiles, dict):
141
+ profiles = {}
142
+ profiles[profile.name] = profile.to_dict()
143
+ raw["profiles"] = profiles
144
+ if set_active or not raw.get("active_profile"):
145
+ raw["active_profile"] = profile.name
146
+ self._write(raw)
147
+
148
+ def set_active(self, profile_name: str) -> None:
149
+ raw = self.load_raw()
150
+ profiles = raw.get("profiles")
151
+ if not isinstance(profiles, dict):
152
+ raise ConfigError(f"Unknown profile: {profile_name}")
153
+ if profile_name not in profiles:
154
+ raise ConfigError(f"Unknown profile: {profile_name}")
155
+ raw["active_profile"] = profile_name
156
+ self._write(raw)
157
+
158
+ def delete_profile(self, profile_name: str) -> bool:
159
+ raw = self.load_raw()
160
+ profiles = raw.get("profiles")
161
+ if not isinstance(profiles, dict) or profile_name not in profiles:
162
+ return False
163
+ profiles.pop(profile_name)
164
+ if raw.get("active_profile") == profile_name:
165
+ raw["active_profile"] = next(iter(profiles), None)
166
+ self._write(raw)
167
+ return True
168
+
169
+ def clear_all(self) -> bool:
170
+ if not self._path.is_file():
171
+ return False
172
+ try:
173
+ self._path.unlink()
174
+ except OSError as exc:
175
+ raise ConfigError(f"Could not delete {self._path}: {exc}") from exc
176
+ return True
177
+
178
+ def _write(self, raw: Mapping[str, object]) -> None:
179
+ directory = self._path.parent
180
+ try:
181
+ directory.mkdir(parents=True, exist_ok=True)
182
+ with suppress(OSError):
183
+ os.chmod(directory, stat.S_IRWXU)
184
+ with self._path.open("w", encoding="utf-8") as handle:
185
+ json.dump(raw, handle, indent=2, sort_keys=True)
186
+ handle.write("\n")
187
+ with suppress(OSError):
188
+ os.chmod(self._path, stat.S_IRUSR | stat.S_IWUSR)
189
+ except OSError as exc:
190
+ raise ConfigError(f"Could not write {self._path}: {exc}") from exc
191
+
192
+
193
+ def mask_secret(value: str | None) -> str:
194
+ if not value:
195
+ return "(unset)"
196
+ if len(value) <= 8:
197
+ return "***"
198
+ return f"{value[:4]}…{value[-4:]}"
199
+
200
+
201
+ def _string_or_none(value: object) -> str | None:
202
+ if isinstance(value, str) and value.strip():
203
+ return value.strip()
204
+ return None
205
+
206
+
207
+ def _int_or_none(value: object) -> int | None:
208
+ if isinstance(value, bool):
209
+ return None
210
+ if isinstance(value, int) and value > 0:
211
+ return value
212
+ if isinstance(value, str):
213
+ try:
214
+ parsed = int(value)
215
+ except ValueError:
216
+ return None
217
+ return parsed if parsed > 0 else None
218
+ return None
guzli/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class GuzliSkillError(Exception):
5
+ """Base error for Guzli skill."""
6
+
7
+
8
+ class ConfigError(GuzliSkillError):
9
+ """Raised when required configuration is missing or invalid."""
10
+
11
+
12
+ class ValidationError(GuzliSkillError):
13
+ """Raised when input validation fails."""
14
+
15
+
16
+ class ApiError(GuzliSkillError):
17
+ """Raised when the API returns a non-success response."""
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ status_code: int | None = None,
23
+ detail: str | None = None,
24
+ *,
25
+ code: str | None = None,
26
+ request_id: str | None = None,
27
+ retry_after: str | None = None,
28
+ retryable: bool = False,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+ self.detail = detail
33
+ self.code = code
34
+ self.request_id = request_id
35
+ self.retry_after = retry_after
36
+ self.retryable = retryable
37
+
38
+ def to_dict(self) -> dict[str, object]:
39
+ return {
40
+ "code": self.code or "api_error",
41
+ "message": str(self),
42
+ "detail": self.detail,
43
+ "status_code": self.status_code,
44
+ "request_id": self.request_id,
45
+ "retry_after": self.retry_after,
46
+ "retryable": self.retryable,
47
+ }
guzli/http_client.py ADDED
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from guzli.auth import AuthCapability, AuthSelector
9
+ from guzli.errors import ApiError, ConfigError
10
+ from guzli.types import JsonValue
11
+ from guzli.validators import require_non_empty
12
+
13
+
14
+ @dataclass
15
+ class HttpClient:
16
+ base_url: str
17
+ auth_selector: AuthSelector
18
+ timeout_seconds: int
19
+ agent_id: str | None
20
+ # Scriptable credential routes require this as X-Organization-ID.
21
+ organization_id: str | None = None
22
+ _session: Any = field(init=False, repr=False)
23
+
24
+ def __post_init__(self) -> None:
25
+ requests = _load_requests()
26
+ from requests.adapters import HTTPAdapter
27
+ from urllib3.util import Retry
28
+
29
+ retry = Retry(
30
+ total=2,
31
+ connect=2,
32
+ read=2,
33
+ status=2,
34
+ backoff_factor=0.25,
35
+ status_forcelist=(429, 500, 502, 503, 504),
36
+ allowed_methods=frozenset({"GET", "HEAD", "OPTIONS"}),
37
+ respect_retry_after_header=True,
38
+ raise_on_status=False,
39
+ )
40
+ self._session = requests.Session()
41
+ adapter = HTTPAdapter(max_retries=retry)
42
+ self._session.mount("https://", adapter)
43
+ self._session.mount("http://", adapter)
44
+
45
+ def request_unauthenticated_json(
46
+ self,
47
+ method: str,
48
+ path: str,
49
+ *,
50
+ params: Mapping[str, object] | None = None,
51
+ payload: JsonValue | None = None,
52
+ ) -> JsonValue:
53
+ """For endpoints that take their credential in the request body (e.g. the OAuth
54
+ refresh / exchange / logout endpoints take `refresh_handle` in the body and reject
55
+ Authorization headers). Skips auth_selector entirely to avoid bootstrap issues
56
+ when no credential is configured yet (e.g. during `auth login --mode oauth`)."""
57
+ return self._send(method, path, params=params, payload=payload, extra_headers={})
58
+
59
+ def request_json(
60
+ self,
61
+ method: str,
62
+ path: str,
63
+ *,
64
+ params: Mapping[str, object] | None = None,
65
+ payload: JsonValue | None = None,
66
+ require_user: bool = False,
67
+ auth_capability: AuthCapability | None = None,
68
+ require_agent: bool = False,
69
+ require_organization: bool = False,
70
+ ) -> JsonValue:
71
+ capability = auth_capability
72
+ if capability is None:
73
+ capability = (
74
+ AuthCapability.BEARER_ONLY if require_user else AuthCapability.API_KEY_OR_BEARER
75
+ )
76
+ auth_headers = self.auth_selector.select(capability).headers()
77
+ extra: dict[str, str] = dict(auth_headers)
78
+ if require_agent:
79
+ agent_id = require_non_empty(self.agent_id, "GUZLI_AGENT_ID")
80
+ extra["x-agent-id"] = agent_id
81
+ elif self.agent_id:
82
+ extra["x-agent-id"] = self.agent_id
83
+ if require_organization:
84
+ org_id = require_non_empty(self.organization_id, "GUZLI_ORGANIZATION_ID")
85
+ extra["x-organization-id"] = org_id
86
+ elif self.organization_id:
87
+ extra["x-organization-id"] = self.organization_id
88
+ return self._send(method, path, params=params, payload=payload, extra_headers=extra)
89
+
90
+ def _send(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ *,
95
+ params: Mapping[str, object] | None,
96
+ payload: JsonValue | None,
97
+ extra_headers: dict[str, str],
98
+ ) -> JsonValue:
99
+ requests = _load_requests()
100
+ request_id = uuid4().hex
101
+ headers: dict[str, str] = {
102
+ "Accept": "application/json",
103
+ "Content-Type": "application/json",
104
+ "User-Agent": "guzli-cli/0.2",
105
+ "X-Request-ID": request_id,
106
+ }
107
+ headers.update(extra_headers)
108
+ url = _join_url(self.base_url, path)
109
+ try:
110
+ response = self._session.request(
111
+ method=method,
112
+ url=url,
113
+ headers=headers,
114
+ params=_normalize_params(params),
115
+ json=payload,
116
+ timeout=self.timeout_seconds,
117
+ )
118
+ except requests.RequestException as exc:
119
+ raise ApiError(
120
+ "Network error while calling Guzli API",
121
+ code="network_error",
122
+ request_id=request_id,
123
+ retryable=method.upper() in {"GET", "HEAD", "OPTIONS"},
124
+ ) from exc
125
+
126
+ if response.ok:
127
+ return _parse_json(response)
128
+
129
+ detail = _extract_error_detail(response)
130
+ response_request_id = (
131
+ response.headers.get("X-Request-ID")
132
+ or response.headers.get("X-Correlation-ID")
133
+ or request_id
134
+ )
135
+ retry_after = response.headers.get("Retry-After")
136
+ error_code = _extract_error_code(response)
137
+ raise ApiError(
138
+ f"Guzli API request failed ({response.status_code})",
139
+ status_code=response.status_code,
140
+ detail=detail,
141
+ code=error_code,
142
+ request_id=response_request_id,
143
+ retry_after=retry_after,
144
+ retryable=response.status_code in {408, 425, 429, 500, 502, 503, 504},
145
+ )
146
+
147
+
148
+ def _join_url(base_url: str, path: str) -> str:
149
+ if not base_url:
150
+ raise ConfigError("GUZLI_API_BASE_URL is required")
151
+ base = base_url.rstrip("/")
152
+ suffix = path if path.startswith("/") else f"/{path}"
153
+ return f"{base}{suffix}"
154
+
155
+
156
+ def _normalize_params(
157
+ params: Mapping[str, object] | None,
158
+ ) -> dict[str, Iterable[str] | str] | None:
159
+ if not params:
160
+ return None
161
+ normalized: dict[str, Iterable[str] | str] = {}
162
+ for key, value in params.items():
163
+ if value is None:
164
+ continue
165
+ if isinstance(value, list):
166
+ normalized[key] = [str(item) for item in value if item is not None]
167
+ else:
168
+ normalized[key] = str(value)
169
+ return normalized
170
+
171
+
172
+ def _parse_json(response: Any) -> JsonValue:
173
+ if not response.content:
174
+ return None
175
+ try:
176
+ return response.json()
177
+ except ValueError:
178
+ return response.text
179
+
180
+
181
+ def _extract_error_detail(response: Any) -> str | None:
182
+ try:
183
+ payload = response.json()
184
+ except ValueError:
185
+ payload = None
186
+ if isinstance(payload, dict):
187
+ detail = payload.get("detail") or payload.get("message")
188
+ if isinstance(detail, str):
189
+ return detail
190
+ return response.text
191
+ if isinstance(payload, list):
192
+ return response.text
193
+ return response.text
194
+
195
+
196
+ def _extract_error_code(response: Any) -> str:
197
+ try:
198
+ payload = response.json()
199
+ except ValueError:
200
+ payload = None
201
+ if isinstance(payload, dict):
202
+ code = payload.get("code") or payload.get("error_code")
203
+ if isinstance(code, str) and code.strip():
204
+ return code.strip()
205
+ return f"http_{response.status_code}"
206
+
207
+
208
+ def _load_requests():
209
+ try:
210
+ import requests
211
+ except ImportError as exc: # pragma: no cover - runtime dependency
212
+ raise ConfigError("requests library not found. Install with: pip install requests") from exc
213
+ return requests
guzli/mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Guzli Model Context Protocol server package."""