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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __version__ = "0.2.0"
2
+
3
+ __all__ = ["__version__", "cli"]
guzli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from guzli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
guzli/auth.py ADDED
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+ from enum import Enum
7
+ from typing import Protocol
8
+
9
+ from guzli.errors import ApiError, ConfigError
10
+
11
+
12
+ class AuthProvider(Protocol):
13
+ @property
14
+ def mode(self) -> str: ...
15
+
16
+ def headers(self) -> dict[str, str]: ...
17
+
18
+
19
+ class AuthCapability(str, Enum):
20
+ API_KEY_OR_BEARER = "api_key_or_bearer"
21
+ IDENTITY = "identity"
22
+ BEARER_ONLY = "bearer_only"
23
+ API_KEY_ONLY = "api_key_only"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ApiKeyAuth:
28
+ api_key: str
29
+ mode: str = "api_key"
30
+
31
+ def headers(self) -> dict[str, str]:
32
+ return {"X-API-Key": self.api_key}
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BearerAuth:
37
+ """Static bearer token. Used when the CLI was configured with a manually pasted
38
+ JWT (`--bearer-token` / `GUZLI_BEARER_TOKEN`) and we have no refresh handle.
39
+
40
+ For OAuth-managed sessions with auto-refresh, see RefreshableBearerAuth."""
41
+
42
+ token: str
43
+ mode: str = "bearer"
44
+
45
+ def headers(self) -> dict[str, str]:
46
+ return {"Authorization": f"Bearer {self.token}"}
47
+
48
+
49
+ # Refresh callback contract: takes the current refresh_handle string and returns
50
+ # (new_access_token, new_refresh_handle, new_expires_at_iso). The implementation
51
+ # typically POSTs to /api/public/v2/auth/cli/oauth/refresh and parses the response.
52
+ RefreshCallback = Callable[[str], tuple[str, str, str]]
53
+
54
+ # Persist callback contract: called with (access_token, refresh_handle, expires_at_iso)
55
+ # after a successful refresh. Implementation writes back to the StoredProfile so the
56
+ # new tokens survive across CLI invocations.
57
+ PersistCallback = Callable[[str, str, str], None]
58
+
59
+
60
+ class RefreshableBearerAuth:
61
+ """OAuth-managed bearer token with proactive refresh.
62
+
63
+ Holds a short-lived engine access token, an opaque refresh handle, and the
64
+ access token's expiry. On every `headers()` call, checks whether the access
65
+ token will expire within `refresh_skew_seconds` — if so, calls the engine's
66
+ /auth/cli/oauth/refresh endpoint via `refresh_callback`, rotates both the
67
+ access token AND refresh handle, persists the new values via `persist_callback`.
68
+
69
+ Stateful (not a frozen dataclass) because refresh mutates the token in-place
70
+ to amortize across many requests in the same CLI invocation."""
71
+
72
+ mode = "bearer"
73
+
74
+ def __init__(
75
+ self,
76
+ access_token: str,
77
+ refresh_handle: str,
78
+ expires_at: str | None,
79
+ refresh_callback: RefreshCallback,
80
+ persist_callback: PersistCallback,
81
+ refresh_skew_seconds: int = 60,
82
+ server_hint: str | None = None,
83
+ ) -> None:
84
+ self._access_token = access_token
85
+ self._refresh_handle = refresh_handle
86
+ self._expires_at = _parse_iso(expires_at) if expires_at else None
87
+ self._refresh_callback = refresh_callback
88
+ self._persist_callback = persist_callback
89
+ self._refresh_skew = timedelta(seconds=refresh_skew_seconds)
90
+ self._server_hint = server_hint
91
+
92
+ @property
93
+ def current_refresh_handle(self) -> str:
94
+ return self._refresh_handle
95
+
96
+ def headers(self) -> dict[str, str]:
97
+ self._refresh_if_needed()
98
+ return {"Authorization": f"Bearer {self._access_token}"}
99
+
100
+ def _refresh_if_needed(self) -> None:
101
+ if not self._expires_at:
102
+ return # No expiry known — treat as long-lived; never auto-refresh
103
+ now = datetime.now(timezone.utc)
104
+ if self._expires_at - now > self._refresh_skew:
105
+ return # Not near expiry
106
+ try:
107
+ new_access, new_handle, new_expires_iso = self._refresh_callback(self._refresh_handle)
108
+ except ApiError as exc:
109
+ # Bubble up with a clearer hint — refresh failures are almost always recoverable
110
+ # only via re-login, not retry. Surface that explicitly.
111
+ server = self._server_hint or "this server"
112
+ if exc.status_code == 404:
113
+ # Sessions live in each engine's own database; a profile created against
114
+ # one engine (e.g. local dev) cannot refresh against another (e.g. prod).
115
+ raise ConfigError(
116
+ f"CLI session not found on {server} — sessions are per-environment, "
117
+ "so a profile logged in against one engine cannot refresh against another. "
118
+ f"Log in to this server first: "
119
+ f"`guzli --profile <name> --base-url {server} auth login`."
120
+ ) from exc
121
+ raise ConfigError(
122
+ f"CLI session refresh failed ({exc}). "
123
+ "Run `guzli auth login --mode oauth --organization-id <uuid>` again."
124
+ ) from exc
125
+ self._access_token = new_access
126
+ self._refresh_handle = new_handle
127
+ self._expires_at = _parse_iso(new_expires_iso)
128
+ self._persist_callback(new_access, new_handle, new_expires_iso)
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class AuthSelector:
133
+ api_key_auth: ApiKeyAuth | None
134
+ bearer_auth: AuthProvider | None = None # Either BearerAuth (static) or RefreshableBearerAuth
135
+
136
+ def require_api_key(self) -> ApiKeyAuth:
137
+ if not self.api_key_auth:
138
+ raise ConfigError("GUZLI_API_KEY is required for this operation")
139
+ return self.api_key_auth
140
+
141
+ def require_bearer(self) -> AuthProvider:
142
+ if not self.bearer_auth:
143
+ raise ConfigError(
144
+ "No CLI session. Run `guzli auth login --mode oauth --organization-id <uuid>` "
145
+ "to start one, or paste a JWT via GUZLI_BEARER_TOKEN / --bearer-token. "
146
+ "Credential-sensitive routes (api-keys, webhooks, audit) reject "
147
+ "API-key auth by design."
148
+ )
149
+ return self.bearer_auth
150
+
151
+ def select(
152
+ self,
153
+ capability: AuthCapability = AuthCapability.API_KEY_OR_BEARER,
154
+ ) -> AuthProvider:
155
+ if capability == AuthCapability.BEARER_ONLY:
156
+ return self.require_bearer()
157
+ if capability == AuthCapability.API_KEY_ONLY:
158
+ return self.require_api_key()
159
+ if capability == AuthCapability.IDENTITY:
160
+ if self.bearer_auth:
161
+ return self.bearer_auth
162
+ if self.api_key_auth:
163
+ return self.api_key_auth
164
+ raise ConfigError(
165
+ "Identity operation requires OAuth bearer auth or an identity-signing API key."
166
+ )
167
+ if self.api_key_auth:
168
+ return self.api_key_auth
169
+ if self.bearer_auth:
170
+ return self.bearer_auth
171
+ raise ConfigError(
172
+ "No Guzli credentials configured. Run `guzli auth login` or set GUZLI_API_KEY."
173
+ )
174
+
175
+
176
+ def _parse_iso(value: str) -> datetime:
177
+ """Parse an ISO-8601 datetime. Accepts both `Z` and `+00:00` UTC suffixes."""
178
+ cleaned = value.replace("Z", "+00:00") if value.endswith("Z") else value
179
+ parsed = datetime.fromisoformat(cleaned)
180
+ if parsed.tzinfo is None:
181
+ parsed = parsed.replace(tzinfo=timezone.utc)
182
+ return parsed
guzli/cli.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from guzli.commands import (
8
+ admin,
9
+ auth,
10
+ campaigns,
11
+ conversations,
12
+ integrations,
13
+ telephony,
14
+ voice_calls,
15
+ )
16
+ from guzli.commands.common import print_error
17
+ from guzli.errors import ApiError, GuzliSkillError, ValidationError
18
+ from guzli.runtime import build_context
19
+
20
+
21
+ def main() -> None:
22
+ parser = build_parser()
23
+ args = parser.parse_args()
24
+ try:
25
+ handler = getattr(args, "handler", None)
26
+ if not handler:
27
+ raise ValidationError("No command selected")
28
+ if getattr(args, "auth_only", False):
29
+ handler(args)
30
+ return
31
+ context = build_context(args)
32
+ handler(context, args)
33
+ except GuzliSkillError as exc:
34
+ if getattr(args, "output", "text") == "json":
35
+ to_dict = getattr(exc, "to_dict", None)
36
+ error = (
37
+ to_dict()
38
+ if callable(to_dict)
39
+ else {
40
+ "code": exc.__class__.__name__.replace("Error", "").lower() or "error",
41
+ "message": str(exc),
42
+ "retryable": False,
43
+ }
44
+ )
45
+ print(json.dumps({"ok": False, "error": error}, sort_keys=True), file=sys.stderr)
46
+ raise SystemExit(1) from None
47
+ print_error(str(exc))
48
+ if isinstance(exc, ApiError) and exc.detail:
49
+ print_error(f"Detail: {exc.detail}")
50
+ raise SystemExit(1) from None
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(prog="guzli", description="Guzli CLI for humans and agents")
55
+ parser.add_argument("--profile", help="Named auth profile from ~/.guzli/config.json")
56
+ parser.add_argument("--base-url", dest="base_url")
57
+ parser.add_argument("--api-key", dest="api_key")
58
+ parser.add_argument(
59
+ "--bearer-token",
60
+ dest="bearer_token",
61
+ help=(
62
+ "JWT session token (env: GUZLI_BEARER_TOKEN or GUZLI_JWT). "
63
+ "Required for api-keys / webhooks / audit subcommands."
64
+ ),
65
+ )
66
+ parser.add_argument("--agent-id", dest="agent_id")
67
+ parser.add_argument("--organization-id", dest="organization_id")
68
+ parser.add_argument("--timeout-seconds", dest="timeout_seconds", type=int)
69
+ parser.add_argument(
70
+ "--output",
71
+ choices=["text", "json"],
72
+ default="text",
73
+ help="Global output mode; JSON mode also emits structured errors",
74
+ )
75
+
76
+ subparsers = parser.add_subparsers(dest="command", required=True)
77
+ conversations.register(subparsers)
78
+ auth.register(subparsers)
79
+ integrations.register(subparsers)
80
+ campaigns.register(subparsers)
81
+ telephony.register(subparsers)
82
+ voice_calls.register(subparsers)
83
+ admin.register(subparsers)
84
+ return parser
@@ -0,0 +1 @@
1
+ """CLI adapters. Business behavior belongs in workflows and services."""