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.
- guzli/__init__.py +3 -0
- guzli/__main__.py +4 -0
- guzli/auth.py +182 -0
- guzli/cli.py +84 -0
- guzli/commands/__init__.py +1 -0
- guzli/commands/admin.py +421 -0
- guzli/commands/auth.py +502 -0
- guzli/commands/campaign_launch.py +188 -0
- guzli/commands/campaigns.py +571 -0
- guzli/commands/common.py +42 -0
- guzli/commands/conversations.py +404 -0
- guzli/commands/integrations.py +48 -0
- guzli/commands/telephony.py +274 -0
- guzli/commands/voice_calls.py +270 -0
- guzli/config.py +137 -0
- guzli/config_store.py +218 -0
- guzli/errors.py +47 -0
- guzli/http_client.py +213 -0
- guzli/mcp/__init__.py +1 -0
- guzli/mcp/server.py +320 -0
- guzli/models.py +173 -0
- guzli/oauth_callback.py +126 -0
- guzli/output.py +79 -0
- guzli/runtime.py +159 -0
- guzli/services/__init__.py +29 -0
- guzli/services/api_keys.py +105 -0
- guzli/services/calls.py +48 -0
- guzli/services/campaigns.py +193 -0
- guzli/services/cli_auth.py +103 -0
- guzli/services/conversations.py +112 -0
- guzli/services/integrations.py +18 -0
- guzli/services/replies.py +21 -0
- guzli/services/telephony.py +80 -0
- guzli/services/voice_profiles.py +56 -0
- guzli/services/webhooks.py +169 -0
- guzli/types.py +15 -0
- guzli/validators.py +83 -0
- guzli/workflows/__init__.py +13 -0
- guzli/workflows/campaign_launch.py +501 -0
- guzli_cli-0.2.0.data/data/share/guzli/CHANGELOG.md +16 -0
- guzli_cli-0.2.0.data/data/share/guzli/PROVENANCE.md +23 -0
- guzli_cli-0.2.0.data/data/share/guzli/SECURITY.md +18 -0
- guzli_cli-0.2.0.data/data/share/guzli/SECURITY_AUDIT.md +47 -0
- guzli_cli-0.2.0.data/data/share/guzli/SKILL.md +101 -0
- guzli_cli-0.2.0.data/data/share/guzli/examples/compliance-advisory-us.json +15 -0
- guzli_cli-0.2.0.data/data/share/guzli/examples/pizza-order-extraction.json +65 -0
- guzli_cli-0.2.0.data/data/share/guzli/references/api.md +245 -0
- guzli_cli-0.2.0.dist-info/METADATA +341 -0
- guzli_cli-0.2.0.dist-info/RECORD +53 -0
- guzli_cli-0.2.0.dist-info/WHEEL +5 -0
- guzli_cli-0.2.0.dist-info/entry_points.txt +3 -0
- guzli_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
- guzli_cli-0.2.0.dist-info/top_level.txt +1 -0
guzli/__init__.py
ADDED
guzli/__main__.py
ADDED
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."""
|