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/commands/auth.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from guzli.auth import AuthSelector
|
|
8
|
+
from guzli.commands.common import add_json_flag as _add_json_flag
|
|
9
|
+
from guzli.commands.common import print_error as _print_error
|
|
10
|
+
from guzli.config import DEFAULT_BASE_URL, EnvironmentConfig
|
|
11
|
+
from guzli.config_store import ConfigStore, StoredProfile, mask_secret
|
|
12
|
+
from guzli.errors import ApiError, ConfigError, ValidationError
|
|
13
|
+
from guzli.http_client import HttpClient
|
|
14
|
+
from guzli.oauth_callback import OAuthCallbackConfig, OAuthLoopbackCallbackServer
|
|
15
|
+
from guzli.runtime import RuntimeContext
|
|
16
|
+
from guzli.services import CliAuthService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def handle_auth_login(args: argparse.Namespace) -> None:
|
|
20
|
+
mode = getattr(args, "login_mode", "api-key")
|
|
21
|
+
if mode == "oauth":
|
|
22
|
+
handle_auth_login_oauth(args)
|
|
23
|
+
return
|
|
24
|
+
handle_auth_login_api_key(args)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def handle_auth_login_api_key(args: argparse.Namespace) -> None:
|
|
28
|
+
import getpass
|
|
29
|
+
|
|
30
|
+
store = ConfigStore()
|
|
31
|
+
profile_name = (args.profile_name or args.profile or "default").strip() or "default"
|
|
32
|
+
existing = store.get_profile(profile_name)
|
|
33
|
+
base_url = args.login_base_url or (existing.base_url if existing else None) or DEFAULT_BASE_URL
|
|
34
|
+
api_key = args.login_api_key
|
|
35
|
+
agent_id = args.login_agent_id
|
|
36
|
+
organization_id = args.login_organization_id
|
|
37
|
+
|
|
38
|
+
interactive = sys.stdin.isatty() and not (api_key and agent_id)
|
|
39
|
+
if interactive:
|
|
40
|
+
print(f"Logging in to profile '{profile_name}'.")
|
|
41
|
+
prompt_base = f"Base URL [{base_url}]: "
|
|
42
|
+
typed_base = input(prompt_base).strip()
|
|
43
|
+
if typed_base:
|
|
44
|
+
base_url = typed_base
|
|
45
|
+
if not api_key:
|
|
46
|
+
api_key = getpass.getpass("API key (input hidden): ").strip()
|
|
47
|
+
if not agent_id:
|
|
48
|
+
default_agent = existing.agent_id if existing else ""
|
|
49
|
+
suffix = f" [{default_agent}]" if default_agent else ""
|
|
50
|
+
typed_agent = input(f"Agent ID{suffix}: ").strip()
|
|
51
|
+
agent_id = typed_agent or default_agent
|
|
52
|
+
if not organization_id:
|
|
53
|
+
default_org = existing.organization_id if existing else ""
|
|
54
|
+
suffix = f" [{default_org}]" if default_org else ""
|
|
55
|
+
typed_org = input(f"Organization ID{suffix}: ").strip()
|
|
56
|
+
organization_id = typed_org or default_org
|
|
57
|
+
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise ValidationError("--api-key is required (or run interactively)")
|
|
60
|
+
if not agent_id:
|
|
61
|
+
raise ValidationError("--agent-id is required (or run interactively)")
|
|
62
|
+
|
|
63
|
+
profile = StoredProfile(
|
|
64
|
+
name=profile_name,
|
|
65
|
+
base_url=base_url,
|
|
66
|
+
api_key=api_key,
|
|
67
|
+
bearer_token=existing.bearer_token if existing else None,
|
|
68
|
+
refresh_handle=existing.refresh_handle if existing else None,
|
|
69
|
+
access_token_expires_at=existing.access_token_expires_at if existing else None,
|
|
70
|
+
cli_session_id=existing.cli_session_id if existing else None,
|
|
71
|
+
agent_id=agent_id,
|
|
72
|
+
organization_id=(organization_id or (existing.organization_id if existing else None)),
|
|
73
|
+
default_number_pool_id=existing.default_number_pool_id if existing else None,
|
|
74
|
+
timeout_seconds=existing.timeout_seconds if existing else None,
|
|
75
|
+
)
|
|
76
|
+
store.save_profile(profile, set_active=not args.no_activate)
|
|
77
|
+
print(f"Saved profile '{profile_name}' to {store.path}")
|
|
78
|
+
if not args.no_activate:
|
|
79
|
+
print(f"Active profile: {profile_name}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def handle_auth_login_oauth(args: argparse.Namespace) -> None:
|
|
83
|
+
"""Browser-based OAuth login via the engine's /api/public/v2/auth/cli/oauth/* endpoints.
|
|
84
|
+
Opens a localhost callback listener, sends the user to the engine's authorization URL
|
|
85
|
+
(which delegates to Supabase OAuth under the hood), captures the returned state+code,
|
|
86
|
+
exchanges for an engine-issued access token + opaque refresh handle, and persists
|
|
87
|
+
everything to the named profile."""
|
|
88
|
+
import os
|
|
89
|
+
import platform
|
|
90
|
+
import socket
|
|
91
|
+
import webbrowser
|
|
92
|
+
|
|
93
|
+
store = ConfigStore()
|
|
94
|
+
profile_name = (args.profile_name or args.profile or "default").strip() or "default"
|
|
95
|
+
existing = store.get_profile(profile_name)
|
|
96
|
+
base_url = args.login_base_url or (existing.base_url if existing else None) or DEFAULT_BASE_URL
|
|
97
|
+
organization_id = (
|
|
98
|
+
args.login_organization_id
|
|
99
|
+
or os.getenv("GUZLI_ORGANIZATION_ID")
|
|
100
|
+
or (existing.organization_id if existing else None)
|
|
101
|
+
)
|
|
102
|
+
if not organization_id:
|
|
103
|
+
raise ValidationError(
|
|
104
|
+
"--organization-id is required for --mode oauth. "
|
|
105
|
+
"Pass --organization-id <uuid> or set GUZLI_ORGANIZATION_ID."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Build a bootstrap HttpClient with no auth — start/exchange are unauthenticated.
|
|
109
|
+
bootstrap_client = HttpClient(
|
|
110
|
+
base_url=base_url,
|
|
111
|
+
auth_selector=AuthSelector(api_key_auth=None, bearer_auth=None),
|
|
112
|
+
timeout_seconds=30,
|
|
113
|
+
agent_id=None,
|
|
114
|
+
organization_id=None,
|
|
115
|
+
)
|
|
116
|
+
cli_auth = CliAuthService(bootstrap_client)
|
|
117
|
+
|
|
118
|
+
callback = OAuthLoopbackCallbackServer(
|
|
119
|
+
OAuthCallbackConfig.from_inputs(port=args.login_callback_port),
|
|
120
|
+
_OAUTH_SUCCESS_HTML,
|
|
121
|
+
)
|
|
122
|
+
redirect_uri = callback.redirect_uri
|
|
123
|
+
callback.start()
|
|
124
|
+
try:
|
|
125
|
+
metadata = {
|
|
126
|
+
"hostname": socket.gethostname(),
|
|
127
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
128
|
+
"version": _cli_version(),
|
|
129
|
+
}
|
|
130
|
+
start_resp = cli_auth.start(
|
|
131
|
+
redirect_uri=redirect_uri,
|
|
132
|
+
client_display_name=args.login_client_name,
|
|
133
|
+
client_metadata=metadata,
|
|
134
|
+
)
|
|
135
|
+
if not isinstance(start_resp, dict) or "authorization_url" not in start_resp:
|
|
136
|
+
raise ApiError("OAuth start response missing authorization_url")
|
|
137
|
+
auth_url = str(start_resp["authorization_url"])
|
|
138
|
+
|
|
139
|
+
print()
|
|
140
|
+
print("Opening your browser to authenticate with Guzli...")
|
|
141
|
+
print("If it does not open automatically, paste this URL:")
|
|
142
|
+
print(f" {auth_url}")
|
|
143
|
+
print()
|
|
144
|
+
with contextlib.suppress(Exception):
|
|
145
|
+
webbrowser.open(auth_url)
|
|
146
|
+
|
|
147
|
+
timeout_seconds = callback.config.timeout_seconds
|
|
148
|
+
print(f"Waiting for callback on {redirect_uri} (timeout: {timeout_seconds}s)...")
|
|
149
|
+
captured = callback.wait_for_result()
|
|
150
|
+
finally:
|
|
151
|
+
callback.close()
|
|
152
|
+
|
|
153
|
+
if captured.error:
|
|
154
|
+
raise ConfigError(
|
|
155
|
+
f"OAuth login failed: {captured.error} "
|
|
156
|
+
f"({captured.error_description or 'no description'})"
|
|
157
|
+
)
|
|
158
|
+
if not captured.state or not captured.code:
|
|
159
|
+
raise ConfigError("OAuth callback was missing state or code parameters.")
|
|
160
|
+
|
|
161
|
+
print("Exchanging authorization code for engine access token...")
|
|
162
|
+
exchange_resp = cli_auth.exchange(
|
|
163
|
+
state=captured.state,
|
|
164
|
+
code=captured.code,
|
|
165
|
+
redirect_uri=redirect_uri,
|
|
166
|
+
organization_id=organization_id,
|
|
167
|
+
)
|
|
168
|
+
if not isinstance(exchange_resp, dict) or "access_token" not in exchange_resp:
|
|
169
|
+
raise ApiError("OAuth exchange response missing access_token")
|
|
170
|
+
|
|
171
|
+
session = (
|
|
172
|
+
exchange_resp.get("session", {}) if isinstance(exchange_resp.get("session"), dict) else {}
|
|
173
|
+
)
|
|
174
|
+
profile = StoredProfile(
|
|
175
|
+
name=profile_name,
|
|
176
|
+
base_url=base_url,
|
|
177
|
+
api_key=args.login_api_key or (existing.api_key if existing else None),
|
|
178
|
+
bearer_token=str(exchange_resp["access_token"]),
|
|
179
|
+
refresh_handle=str(exchange_resp["refresh_handle"]),
|
|
180
|
+
access_token_expires_at=str(exchange_resp.get("expires_at") or ""),
|
|
181
|
+
cli_session_id=str(session.get("id") or "") or None,
|
|
182
|
+
agent_id=args.login_agent_id or (existing.agent_id if existing else None),
|
|
183
|
+
organization_id=organization_id,
|
|
184
|
+
default_number_pool_id=existing.default_number_pool_id if existing else None,
|
|
185
|
+
timeout_seconds=existing.timeout_seconds if existing else None,
|
|
186
|
+
)
|
|
187
|
+
store.save_profile(profile, set_active=not args.no_activate)
|
|
188
|
+
print()
|
|
189
|
+
print(f"Logged in. Saved profile '{profile_name}' to {store.path}")
|
|
190
|
+
if not args.no_activate:
|
|
191
|
+
print(f"Active profile: {profile_name}")
|
|
192
|
+
print(f"Session id: {session.get('id') or '(unknown)'}")
|
|
193
|
+
print(f"User: {session.get('user_email') or session.get('user_id') or '(unknown)'}")
|
|
194
|
+
print(f"Expires at: {exchange_resp.get('expires_at') or '(unknown)'}")
|
|
195
|
+
print("Auto-refreshes for up to 30 days; sign out anytime with `guzli auth logout`.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _cli_version() -> str:
|
|
199
|
+
try:
|
|
200
|
+
from guzli import __version__
|
|
201
|
+
|
|
202
|
+
return str(__version__)
|
|
203
|
+
except Exception:
|
|
204
|
+
return "unknown"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_OAUTH_SUCCESS_HTML = """<!doctype html>
|
|
208
|
+
<html lang="en">
|
|
209
|
+
<head>
|
|
210
|
+
<meta charset="utf-8">
|
|
211
|
+
<title>Guzli CLI — logged in</title>
|
|
212
|
+
<style>
|
|
213
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
214
|
+
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
|
215
|
+
margin: 0; color: #1a1a1a; background: #fafafa; }
|
|
216
|
+
.card { max-width: 420px; padding: 32px; background: white; border-radius: 12px;
|
|
217
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08); text-align: center; }
|
|
218
|
+
h1 { margin: 0 0 12px; font-size: 20px; }
|
|
219
|
+
p { margin: 0; color: #555; font-size: 14px; line-height: 1.5; }
|
|
220
|
+
</style>
|
|
221
|
+
</head>
|
|
222
|
+
<body>
|
|
223
|
+
<div class="card">
|
|
224
|
+
<h1>You are logged in.</h1>
|
|
225
|
+
<p>Return to your terminal — the Guzli CLI has your session.<br>You can close this tab.</p>
|
|
226
|
+
</div>
|
|
227
|
+
</body>
|
|
228
|
+
</html>
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def handle_auth_logout(args: argparse.Namespace) -> None:
|
|
233
|
+
store = ConfigStore()
|
|
234
|
+
if args.all:
|
|
235
|
+
# Best-effort: revoke every OAuth session we have a refresh_handle for before nuking.
|
|
236
|
+
for name in store.list_profiles():
|
|
237
|
+
profile = store.get_profile(name)
|
|
238
|
+
if profile and profile.refresh_handle and profile.base_url:
|
|
239
|
+
_try_revoke_oauth_session(profile.base_url, profile.refresh_handle, profile.name)
|
|
240
|
+
removed = store.clear_all()
|
|
241
|
+
print("Removed config file" if removed else "No config file to remove")
|
|
242
|
+
return
|
|
243
|
+
profile_name = args.profile_name or args.profile or store.active_profile_name()
|
|
244
|
+
if not profile_name:
|
|
245
|
+
print("No active profile to log out of")
|
|
246
|
+
return
|
|
247
|
+
profile = store.get_profile(profile_name)
|
|
248
|
+
if profile and profile.refresh_handle and profile.base_url:
|
|
249
|
+
_try_revoke_oauth_session(profile.base_url, profile.refresh_handle, profile.name)
|
|
250
|
+
if args.delete_profile:
|
|
251
|
+
removed = store.delete_profile(profile_name)
|
|
252
|
+
if removed:
|
|
253
|
+
print(f"Removed profile '{profile_name}'")
|
|
254
|
+
else:
|
|
255
|
+
print(f"Profile '{profile_name}' was not present")
|
|
256
|
+
return
|
|
257
|
+
if profile:
|
|
258
|
+
store.save_profile(_profile_without_oauth_session(profile), set_active=False)
|
|
259
|
+
print(f"Logged out of OAuth for profile '{profile_name}'")
|
|
260
|
+
else:
|
|
261
|
+
print(f"Profile '{profile_name}' was not present")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _profile_without_oauth_session(profile: StoredProfile) -> StoredProfile:
|
|
265
|
+
return StoredProfile(
|
|
266
|
+
name=profile.name,
|
|
267
|
+
base_url=profile.base_url,
|
|
268
|
+
api_key=profile.api_key,
|
|
269
|
+
agent_id=profile.agent_id,
|
|
270
|
+
organization_id=profile.organization_id,
|
|
271
|
+
default_number_pool_id=profile.default_number_pool_id,
|
|
272
|
+
timeout_seconds=profile.timeout_seconds,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _try_revoke_oauth_session(base_url: str, refresh_handle: str, profile_name: str) -> None:
|
|
277
|
+
"""Best-effort POST to /auth/cli/oauth/logout. Failures are non-fatal — local
|
|
278
|
+
profile cleanup proceeds regardless so a broken refresh handle can't trap the user."""
|
|
279
|
+
bootstrap_client = HttpClient(
|
|
280
|
+
base_url=base_url,
|
|
281
|
+
auth_selector=AuthSelector(api_key_auth=None, bearer_auth=None),
|
|
282
|
+
timeout_seconds=10,
|
|
283
|
+
agent_id=None,
|
|
284
|
+
organization_id=None,
|
|
285
|
+
)
|
|
286
|
+
cli_auth = CliAuthService(bootstrap_client)
|
|
287
|
+
try:
|
|
288
|
+
cli_auth.logout(refresh_handle)
|
|
289
|
+
except ApiError as exc:
|
|
290
|
+
print(
|
|
291
|
+
f"Warning: could not revoke server-side session for '{profile_name}' ({exc}).",
|
|
292
|
+
file=sys.stderr,
|
|
293
|
+
)
|
|
294
|
+
print(
|
|
295
|
+
"Local OAuth credentials will be cleared anyway. The session may remain active on the engine until it expires.",
|
|
296
|
+
file=sys.stderr,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def handle_auth_status(args: argparse.Namespace) -> None:
|
|
301
|
+
store = ConfigStore()
|
|
302
|
+
profile_name = args.profile_name or args.profile or store.active_profile_name()
|
|
303
|
+
print(f"Config file: {store.path} ({'present' if store.exists() else 'missing'})")
|
|
304
|
+
profiles = store.list_profiles()
|
|
305
|
+
print(f"Profiles: {', '.join(profiles) if profiles else '(none)'}")
|
|
306
|
+
active = store.active_profile_name()
|
|
307
|
+
print(f"Active profile: {active or '(none)'}")
|
|
308
|
+
try:
|
|
309
|
+
resolved = EnvironmentConfig.resolve(profile_name)
|
|
310
|
+
except ConfigError as exc:
|
|
311
|
+
_print_error(str(exc))
|
|
312
|
+
sys.exit(1)
|
|
313
|
+
print("Resolved (env vars + CLI flags overlay the profile):")
|
|
314
|
+
print(f" base_url: {resolved.base_url}")
|
|
315
|
+
print(f" api_key: {mask_secret(resolved.api_key)}")
|
|
316
|
+
print(f" bearer_token: {mask_secret(resolved.bearer_token)}")
|
|
317
|
+
print(f" refresh_handle: {mask_secret(resolved.refresh_handle)}")
|
|
318
|
+
print(f" access_token_exp: {resolved.access_token_expires_at or '(unset)'}")
|
|
319
|
+
print(f" cli_session_id: {resolved.cli_session_id or '(unset)'}")
|
|
320
|
+
print(f" agent_id: {resolved.agent_id or '(unset)'}")
|
|
321
|
+
print(f" org_id: {resolved.organization_id or '(unset)'}")
|
|
322
|
+
print(f" number_pool_id: {resolved.default_number_pool_id or '(unset)'}")
|
|
323
|
+
print(f" timeout: {resolved.timeout_seconds}s")
|
|
324
|
+
print(f" source: {resolved.source_profile or '(env only)'}")
|
|
325
|
+
if resolved.bearer_token and resolved.refresh_handle and resolved.access_token_expires_at:
|
|
326
|
+
print(" auth mode: OAuth (auto-refresh enabled)")
|
|
327
|
+
elif resolved.bearer_token:
|
|
328
|
+
print(" auth mode: Static bearer (manual paste — no auto-refresh)")
|
|
329
|
+
elif resolved.api_key:
|
|
330
|
+
print(" auth mode: API key only (credential-mgmt commands will fail)")
|
|
331
|
+
else:
|
|
332
|
+
print(" auth mode: Not authenticated — run `guzli auth login --mode oauth`")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def handle_auth_use(args: argparse.Namespace) -> None:
|
|
336
|
+
store = ConfigStore()
|
|
337
|
+
store.set_active(args.profile_name)
|
|
338
|
+
print(f"Active profile set to '{args.profile_name}'")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def handle_auth_list(args: argparse.Namespace) -> None:
|
|
342
|
+
store = ConfigStore()
|
|
343
|
+
profiles = store.list_profiles()
|
|
344
|
+
if not profiles:
|
|
345
|
+
print("(no profiles configured)")
|
|
346
|
+
return
|
|
347
|
+
active = store.active_profile_name()
|
|
348
|
+
for name in profiles:
|
|
349
|
+
marker = "*" if name == active else " "
|
|
350
|
+
profile = store.get_profile(name)
|
|
351
|
+
base_url = profile.base_url if profile else ""
|
|
352
|
+
agent_id = profile.agent_id if profile else ""
|
|
353
|
+
print(f"{marker} {name:<20} {base_url} agent={agent_id or '(unset)'}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _add_auth_login(sub: argparse._SubParsersAction) -> None:
|
|
357
|
+
parser = sub.add_parser(
|
|
358
|
+
"login",
|
|
359
|
+
help="Log in via OAuth (recommended) or store a long-lived API key.",
|
|
360
|
+
description=(
|
|
361
|
+
"Two modes:\n"
|
|
362
|
+
" --mode oauth (recommended): opens your browser, you log in once, CLI gets an\n"
|
|
363
|
+
" engine-issued access token + opaque refresh handle. Auto-refreshes for 30 days.\n"
|
|
364
|
+
" Required: --organization-id (which org to scope the session to).\n"
|
|
365
|
+
" --mode api-key (legacy): prompts for a workspace API key. Use this only for\n"
|
|
366
|
+
" non-credential commands (conversations, campaigns, calls, etc.) — credential-\n"
|
|
367
|
+
" management routes (api-keys, webhooks, audit) require OAuth."
|
|
368
|
+
),
|
|
369
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
370
|
+
)
|
|
371
|
+
parser.add_argument(
|
|
372
|
+
"--mode",
|
|
373
|
+
choices=["api-key", "oauth"],
|
|
374
|
+
default="api-key",
|
|
375
|
+
dest="login_mode",
|
|
376
|
+
help="Auth mode (default: api-key for backward compat)",
|
|
377
|
+
)
|
|
378
|
+
parser.add_argument("profile_name", nargs="?", help='Profile name (default: "default")')
|
|
379
|
+
parser.add_argument(
|
|
380
|
+
"--api-key",
|
|
381
|
+
dest="login_api_key",
|
|
382
|
+
help="[api-key mode] Skip the prompt; [oauth mode] preserve a fallback API key",
|
|
383
|
+
)
|
|
384
|
+
parser.add_argument("--agent-id", dest="login_agent_id", help="Skip the prompt")
|
|
385
|
+
parser.add_argument(
|
|
386
|
+
"--organization-id",
|
|
387
|
+
dest="login_organization_id",
|
|
388
|
+
help="Org scope (required for --mode oauth)",
|
|
389
|
+
)
|
|
390
|
+
parser.add_argument("--base-url", dest="login_base_url", help="Skip the prompt")
|
|
391
|
+
parser.add_argument(
|
|
392
|
+
"--callback-port",
|
|
393
|
+
dest="login_callback_port",
|
|
394
|
+
type=int,
|
|
395
|
+
help="[oauth mode] Localhost callback port registered with the engine (env: GUZLI_OAUTH_CALLBACK_PORT; default: 8765)",
|
|
396
|
+
)
|
|
397
|
+
parser.add_argument(
|
|
398
|
+
"--client-name",
|
|
399
|
+
dest="login_client_name",
|
|
400
|
+
default="Guzli CLI",
|
|
401
|
+
help="[oauth mode] Display name for this CLI session (shown in connected clients list)",
|
|
402
|
+
)
|
|
403
|
+
parser.add_argument(
|
|
404
|
+
"--no-activate",
|
|
405
|
+
action="store_true",
|
|
406
|
+
help="Save the profile but do not switch to it",
|
|
407
|
+
)
|
|
408
|
+
parser.set_defaults(handler=handle_auth_login, auth_only=True)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _add_auth_logout(sub: argparse._SubParsersAction) -> None:
|
|
412
|
+
parser = sub.add_parser(
|
|
413
|
+
"logout", help="Revoke OAuth credentials for a profile while preserving profile settings."
|
|
414
|
+
)
|
|
415
|
+
parser.add_argument("profile_name", nargs="?")
|
|
416
|
+
parser.add_argument("--all", action="store_true", help="Delete the entire config file")
|
|
417
|
+
parser.add_argument(
|
|
418
|
+
"--delete-profile",
|
|
419
|
+
action="store_true",
|
|
420
|
+
help="Delete the selected profile after revoking OAuth",
|
|
421
|
+
)
|
|
422
|
+
parser.set_defaults(handler=handle_auth_logout, auth_only=True)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _add_auth_status(sub: argparse._SubParsersAction) -> None:
|
|
426
|
+
parser = sub.add_parser("status", help="Show resolved credentials (api key masked).")
|
|
427
|
+
parser.add_argument("profile_name", nargs="?", help="Inspect a specific profile")
|
|
428
|
+
parser.set_defaults(handler=handle_auth_status, auth_only=True)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _add_auth_use(sub: argparse._SubParsersAction) -> None:
|
|
432
|
+
parser = sub.add_parser("use", help="Switch the active profile.")
|
|
433
|
+
parser.add_argument("profile_name")
|
|
434
|
+
parser.set_defaults(handler=handle_auth_use, auth_only=True)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _add_auth_list(sub: argparse._SubParsersAction) -> None:
|
|
438
|
+
parser = sub.add_parser("list", help="List stored profiles (active marked with *).")
|
|
439
|
+
parser.set_defaults(handler=handle_auth_list, auth_only=True)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _add_auth_list_clients(sub: argparse._SubParsersAction) -> None:
|
|
443
|
+
parser = sub.add_parser(
|
|
444
|
+
"list-clients",
|
|
445
|
+
help="List connected CLI sessions for your account (or the whole org if you are admin)",
|
|
446
|
+
)
|
|
447
|
+
parser.add_argument(
|
|
448
|
+
"--scope",
|
|
449
|
+
choices=["mine", "organization"],
|
|
450
|
+
default="mine",
|
|
451
|
+
help="'mine' = your sessions only (default). 'organization' = all org members' (admin only).",
|
|
452
|
+
)
|
|
453
|
+
_add_json_flag(parser)
|
|
454
|
+
parser.set_defaults(handler=handle_auth_list_clients)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _add_auth_revoke_client(sub: argparse._SubParsersAction) -> None:
|
|
458
|
+
parser = sub.add_parser(
|
|
459
|
+
"revoke-client",
|
|
460
|
+
help="Revoke a connected CLI session by id (kills it on every machine immediately)",
|
|
461
|
+
)
|
|
462
|
+
parser.add_argument("session_id", help="Session id from `auth list-clients`")
|
|
463
|
+
parser.add_argument("--confirm", action="store_true", help="Required to proceed")
|
|
464
|
+
parser.set_defaults(handler=handle_auth_revoke_client)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def handle_auth_list_clients(context: RuntimeContext, args: argparse.Namespace) -> None:
|
|
468
|
+
payload = context.cli_auth.list_connected_clients(scope=args.scope)
|
|
469
|
+
if getattr(args, "json", False):
|
|
470
|
+
context.renderer.print_json(payload)
|
|
471
|
+
return
|
|
472
|
+
items = payload.get("items", []) if isinstance(payload, dict) else []
|
|
473
|
+
if not items:
|
|
474
|
+
print("No connected CLI sessions.")
|
|
475
|
+
return
|
|
476
|
+
for s in items:
|
|
477
|
+
revoked = " [REVOKED]" if s.get("revoked_at") else ""
|
|
478
|
+
last_used = s.get("last_used_at") or "never"
|
|
479
|
+
print(
|
|
480
|
+
f"{s.get('id')} {s.get('client_display_name'):24} "
|
|
481
|
+
f"user={s.get('user_email') or s.get('user_id')} "
|
|
482
|
+
f"last_used={last_used} expires={s.get('expires_at')}{revoked}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def handle_auth_revoke_client(context: RuntimeContext, args: argparse.Namespace) -> None:
|
|
487
|
+
if not args.confirm:
|
|
488
|
+
raise ValidationError("--confirm flag required to revoke a connected client")
|
|
489
|
+
context.cli_auth.revoke_connected_client(args.session_id)
|
|
490
|
+
print(f"Revoked CLI session {args.session_id}")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
494
|
+
auth_parser = subparsers.add_parser("auth", help="Manage credentials & profiles")
|
|
495
|
+
auth_sub = auth_parser.add_subparsers(dest="auth_command", required=True)
|
|
496
|
+
_add_auth_login(auth_sub)
|
|
497
|
+
_add_auth_logout(auth_sub)
|
|
498
|
+
_add_auth_status(auth_sub)
|
|
499
|
+
_add_auth_use(auth_sub)
|
|
500
|
+
_add_auth_list(auth_sub)
|
|
501
|
+
_add_auth_list_clients(auth_sub)
|
|
502
|
+
_add_auth_revoke_client(auth_sub)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
from guzli.errors import ConfigError, ValidationError
|
|
7
|
+
from guzli.runtime import RuntimeContext
|
|
8
|
+
from guzli.validators import normalize_list, parse_json_or_file
|
|
9
|
+
from guzli.workflows import CampaignLaunchInputs, CampaignLaunchWorkflow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def handle_campaign_launch(context: RuntimeContext, args: argparse.Namespace) -> None:
|
|
13
|
+
agent_id = (args.agent_id or context.agent_id or "").strip()
|
|
14
|
+
organization_id = (
|
|
15
|
+
getattr(args, "organization_id", None) or context.organization_id or ""
|
|
16
|
+
).strip()
|
|
17
|
+
if not organization_id:
|
|
18
|
+
raise ConfigError(
|
|
19
|
+
"organization_id is required. Set it via `guzli auth login --organization-id …`."
|
|
20
|
+
)
|
|
21
|
+
targets: list[str] = []
|
|
22
|
+
if args.target:
|
|
23
|
+
targets.extend(normalize_list(args.target))
|
|
24
|
+
if args.targets_file:
|
|
25
|
+
targets.extend(_read_targets_file(args.targets_file))
|
|
26
|
+
workflow = CampaignLaunchWorkflow(
|
|
27
|
+
telephony=context.telephony,
|
|
28
|
+
voice_profiles=context.voice_profiles,
|
|
29
|
+
campaigns=context.campaigns,
|
|
30
|
+
)
|
|
31
|
+
result = workflow.run(
|
|
32
|
+
CampaignLaunchInputs(
|
|
33
|
+
name=(args.name or "").strip(),
|
|
34
|
+
agent_id=agent_id,
|
|
35
|
+
organization_id=organization_id,
|
|
36
|
+
caller_phone_e164=(args.phone or "").strip() or None,
|
|
37
|
+
number_pool_id=(getattr(args, "number_pool_id", None) or "").strip() or None,
|
|
38
|
+
default_number_pool_id=context.default_number_pool_id,
|
|
39
|
+
voice_id=args.voice_id.strip() if args.voice_id else None,
|
|
40
|
+
voice_profile_id=args.voice_profile_id.strip() if args.voice_profile_id else None,
|
|
41
|
+
targets=targets,
|
|
42
|
+
telephony_account_id=(args.telephony_account_id or "").strip() or None,
|
|
43
|
+
call_instructions=(args.instructions or "").strip() or None,
|
|
44
|
+
runtime_objective=(args.objective or "").strip() or None,
|
|
45
|
+
initial_message=(args.message or "").strip() or None,
|
|
46
|
+
post_call_extraction=_resolve_extraction(args.extraction_json, args.extraction_file),
|
|
47
|
+
background_ambience=_resolve_background_ambience(
|
|
48
|
+
mode=args.background_ambience,
|
|
49
|
+
volume=args.ambience_volume,
|
|
50
|
+
transports=args.ambience_transports,
|
|
51
|
+
),
|
|
52
|
+
voice_profile_name=(args.voice_profile_name or "").strip() or None,
|
|
53
|
+
configure_compliance=not args.skip_compliance,
|
|
54
|
+
compliance_mode=args.compliance_mode or "advisory",
|
|
55
|
+
disclosures_enabled=(bool(args.disclosures) if args.disclosures is not None else False),
|
|
56
|
+
activate=not args.no_activate,
|
|
57
|
+
run=not args.no_run,
|
|
58
|
+
dry_run=args.dry_run,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
context.renderer.print_json(result.to_summary())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_campaign_launch_parser(
|
|
65
|
+
sub: argparse._SubParsersAction,
|
|
66
|
+
*,
|
|
67
|
+
command_name: str = "launch",
|
|
68
|
+
) -> None:
|
|
69
|
+
parser = sub.add_parser(
|
|
70
|
+
command_name,
|
|
71
|
+
help="Launch outbound calls using an existing number pool.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("--name", help="Campaign name; generated when omitted")
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--number-pool-id",
|
|
76
|
+
dest="number_pool_id",
|
|
77
|
+
help="Existing pool; falls back to env, profile default, or only eligible pool",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--from",
|
|
81
|
+
"--phone",
|
|
82
|
+
dest="phone",
|
|
83
|
+
help="Match an existing pool containing this caller E.164",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--to",
|
|
87
|
+
"--target",
|
|
88
|
+
dest="target",
|
|
89
|
+
action="append",
|
|
90
|
+
help="Recipient E.164; repeatable or comma-separated",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--recipients-file",
|
|
94
|
+
"--targets-file",
|
|
95
|
+
dest="targets_file",
|
|
96
|
+
help="File with one recipient E.164 per line",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument("--voice-id", dest="voice_id")
|
|
99
|
+
parser.add_argument("--voice-profile-id", dest="voice_profile_id")
|
|
100
|
+
parser.add_argument("--message", help="Literal opening line")
|
|
101
|
+
parser.add_argument("--instructions", help="Call behavior and task instructions")
|
|
102
|
+
parser.add_argument("--objective", help="Optional compact runtime objective")
|
|
103
|
+
parser.add_argument("--extraction-file", dest="extraction_file")
|
|
104
|
+
parser.add_argument("--extraction-json", dest="extraction_json")
|
|
105
|
+
parser.add_argument("--telephony-account-id", dest="telephony_account_id")
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--background-ambience",
|
|
108
|
+
dest="background_ambience",
|
|
109
|
+
choices=["none", "people_in_lounge", "food_court", "restaurant", "crowd_talking"],
|
|
110
|
+
default="people_in_lounge",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--ambience-volume", dest="ambience_volume", type=float, default=0.10)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--ambience-transport",
|
|
115
|
+
dest="ambience_transports",
|
|
116
|
+
action="append",
|
|
117
|
+
choices=["twilio", "webrtc"],
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument("--voice-profile-name", dest="voice_profile_name")
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--compliance-mode",
|
|
122
|
+
dest="compliance_mode",
|
|
123
|
+
choices=["advisory", "enforced"],
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--disclosures",
|
|
127
|
+
dest="disclosures",
|
|
128
|
+
type=_parse_bool_flag,
|
|
129
|
+
metavar="on|off",
|
|
130
|
+
help="Spoken compliance disclosures; default off",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument("--skip-compliance", dest="skip_compliance", action="store_true")
|
|
133
|
+
parser.add_argument("--no-activate", action="store_true")
|
|
134
|
+
parser.add_argument("--no-run", action="store_true")
|
|
135
|
+
parser.add_argument("--dry-run", dest="dry_run", action="store_true")
|
|
136
|
+
parser.set_defaults(
|
|
137
|
+
handler=handle_campaign_launch,
|
|
138
|
+
launch_command_name=command_name,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_extraction(inline: str | None, file_path: str | None) -> dict[str, object] | None:
|
|
143
|
+
parsed = parse_json_or_file(inline, file_path, "post-call extraction")
|
|
144
|
+
if parsed is None:
|
|
145
|
+
return None
|
|
146
|
+
if isinstance(parsed, list):
|
|
147
|
+
return {"is_enabled": True, "fields": parsed}
|
|
148
|
+
if isinstance(parsed, Mapping):
|
|
149
|
+
settings = dict(parsed)
|
|
150
|
+
settings.setdefault("is_enabled", True)
|
|
151
|
+
return settings
|
|
152
|
+
raise ValidationError("post-call extraction must be a JSON object or a list")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve_background_ambience(
|
|
156
|
+
*, mode: str, volume: float, transports: list[str] | None
|
|
157
|
+
) -> dict[str, object] | None:
|
|
158
|
+
normalized_mode = (mode or "none").strip().lower()
|
|
159
|
+
if normalized_mode == "none":
|
|
160
|
+
return None
|
|
161
|
+
if not 0.0 <= volume <= 1.0:
|
|
162
|
+
raise ValidationError("--ambience-volume must be between 0.0 and 1.0")
|
|
163
|
+
payload: dict[str, object] = {"mode": normalized_mode, "volume": float(volume)}
|
|
164
|
+
if transports:
|
|
165
|
+
payload["transports"] = list(dict.fromkeys(item.strip().lower() for item in transports))
|
|
166
|
+
return payload
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _read_targets_file(path: str) -> list[str]:
|
|
170
|
+
try:
|
|
171
|
+
with open(path.strip(), encoding="utf-8") as handle:
|
|
172
|
+
raw = handle.read()
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise ValidationError(f"could not read recipients file {path}") from exc
|
|
175
|
+
return [
|
|
176
|
+
line.strip()
|
|
177
|
+
for line in raw.splitlines()
|
|
178
|
+
if line.strip() and not line.strip().startswith("#")
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_bool_flag(value: str) -> bool:
|
|
183
|
+
normalized = value.strip().lower()
|
|
184
|
+
if normalized in {"true", "1", "yes", "on"}:
|
|
185
|
+
return True
|
|
186
|
+
if normalized in {"false", "0", "no", "off"}:
|
|
187
|
+
return False
|
|
188
|
+
raise argparse.ArgumentTypeError("expected on/off")
|