sudosu 0.1.5__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.
@@ -0,0 +1,563 @@
1
+ """Integration command handlers for external services.
2
+
3
+ Supports: Gmail, Slack, Notion, Linear, GitHub, Google Drive, Google Docs,
4
+ Google Sheets, and any other Composio-configured tool.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import time
10
+ import webbrowser
11
+
12
+ import httpx
13
+
14
+ from sudosu.core import get_config_value, set_config_value, get_backend_url
15
+ from sudosu.ui import (
16
+ console,
17
+ print_error,
18
+ print_info,
19
+ print_success,
20
+ print_warning,
21
+ COLOR_PRIMARY,
22
+ COLOR_SECONDARY,
23
+ COLOR_ACCENT,
24
+ COLOR_INTERACTIVE,
25
+ )
26
+
27
+
28
+ def get_http_backend_url() -> str:
29
+ """Get the HTTP backend URL derived from the WebSocket URL.
30
+
31
+ Converts wss://example.com/ws -> https://example.com
32
+ Converts ws://localhost:8000/ws -> http://localhost:8000
33
+ """
34
+ # Check for explicit override first
35
+ explicit_url = os.environ.get("SUDOSU_BACKEND_URL")
36
+ if explicit_url:
37
+ return explicit_url.rstrip("/")
38
+
39
+ # Get the WebSocket URL from config
40
+ ws_url = get_backend_url()
41
+
42
+ # Convert WebSocket URL to HTTP URL
43
+ # wss://example.com/ws -> https://example.com
44
+ # ws://localhost:8000/ws -> http://localhost:8000
45
+ http_url = ws_url
46
+
47
+ # Replace protocol
48
+ if http_url.startswith("wss://"):
49
+ http_url = http_url.replace("wss://", "https://", 1)
50
+ elif http_url.startswith("ws://"):
51
+ http_url = http_url.replace("ws://", "http://", 1)
52
+
53
+ # Remove /ws path suffix if present
54
+ if http_url.endswith("/ws"):
55
+ http_url = http_url[:-3]
56
+
57
+ return http_url.rstrip("/")
58
+
59
+
60
+ # Display names for toolkits
61
+ TOOLKIT_DISPLAY_NAMES = {
62
+ "gmail": "Gmail",
63
+ "slack": "Slack",
64
+ "notion": "Notion",
65
+ "linear": "Linear",
66
+ "github": "GitHub",
67
+ "googledrive": "Google Drive",
68
+ "googledocs": "Google Docs",
69
+ "googlesheets": "Google Sheets",
70
+ }
71
+
72
+
73
+ def get_display_name(toolkit: str) -> str:
74
+ """Get human-readable display name for a toolkit."""
75
+ return TOOLKIT_DISPLAY_NAMES.get(toolkit, toolkit.title())
76
+
77
+
78
+ def get_user_id() -> str:
79
+ """Get or create a unique user ID for this CLI installation.
80
+
81
+ The user_id is stored in ~/.sudosu/config.yaml and is used
82
+ to associate integrations (like Gmail) with this user.
83
+ """
84
+ user_id = get_config_value("user_id")
85
+ if not user_id:
86
+ import uuid
87
+ user_id = str(uuid.uuid4())
88
+ set_config_value("user_id", user_id)
89
+ return user_id
90
+
91
+
92
+ async def get_available_integrations() -> list[dict]:
93
+ """Get list of available integrations from backend.
94
+
95
+ Returns:
96
+ List of dicts with integration details
97
+ """
98
+ user_id = get_user_id()
99
+ backend_url = get_http_backend_url()
100
+
101
+ try:
102
+ async with httpx.AsyncClient(timeout=10.0) as client:
103
+ response = await client.get(
104
+ f"{backend_url}/api/integrations",
105
+ params={"user_id": user_id},
106
+ )
107
+
108
+ if response.status_code == 200:
109
+ return response.json()
110
+ else:
111
+ return {"available": [], "connected": [], "details": []}
112
+
113
+ except Exception as e:
114
+ return {"available": [], "connected": [], "error": str(e)}
115
+
116
+
117
+ async def check_integration_status(integration: str) -> dict:
118
+ """Check the status of any integration.
119
+
120
+ Args:
121
+ integration: Name of the integration (e.g., gmail, slack, notion)
122
+
123
+ Returns:
124
+ dict with 'connected' (bool) and other status info
125
+ """
126
+ user_id = get_user_id()
127
+ backend_url = get_http_backend_url()
128
+
129
+ try:
130
+ async with httpx.AsyncClient(timeout=10.0) as client:
131
+ response = await client.get(
132
+ f"{backend_url}/api/integrations/{integration}/status/{user_id}",
133
+ )
134
+
135
+ if response.status_code == 200:
136
+ return response.json()
137
+ else:
138
+ return {"connected": False, "error": f"HTTP {response.status_code}"}
139
+
140
+ except Exception as e:
141
+ return {"connected": False, "error": str(e)}
142
+
143
+
144
+ async def initiate_connection(integration: str) -> dict:
145
+ """Initiate OAuth connection for any integration.
146
+
147
+ Args:
148
+ integration: Name of the integration (e.g., gmail, slack, notion)
149
+
150
+ Returns:
151
+ dict with 'auth_url' if successful, or 'error' if failed
152
+ """
153
+ user_id = get_user_id()
154
+ backend_url = get_http_backend_url()
155
+
156
+ try:
157
+ async with httpx.AsyncClient(timeout=30.0) as client:
158
+ response = await client.post(
159
+ f"{backend_url}/api/integrations/{integration}/connect",
160
+ json={"user_id": user_id},
161
+ )
162
+
163
+ if response.status_code == 200:
164
+ return response.json()
165
+ else:
166
+ data = response.json()
167
+ return {"error": data.get("detail", f"HTTP {response.status_code}")}
168
+
169
+ except Exception as e:
170
+ return {"error": str(e)}
171
+
172
+
173
+ async def disconnect_integration(integration: str) -> dict:
174
+ """Disconnect any integration.
175
+
176
+ Args:
177
+ integration: Name of the integration (e.g., gmail, slack, notion)
178
+
179
+ Returns:
180
+ dict with 'success' (bool) and message
181
+ """
182
+ user_id = get_user_id()
183
+ backend_url = get_http_backend_url()
184
+
185
+ try:
186
+ async with httpx.AsyncClient(timeout=10.0) as client:
187
+ response = await client.post(
188
+ f"{backend_url}/api/integrations/{integration}/disconnect",
189
+ json={"user_id": user_id},
190
+ )
191
+
192
+ if response.status_code == 200:
193
+ return response.json()
194
+ else:
195
+ data = response.json()
196
+ return {"success": False, "error": data.get("detail", f"HTTP {response.status_code}")}
197
+
198
+ except Exception as e:
199
+ return {"success": False, "error": str(e)}
200
+
201
+
202
+ async def poll_for_connection(
203
+ integration: str = "gmail",
204
+ timeout: int = 120,
205
+ poll_interval: int = 2,
206
+ ) -> bool:
207
+ """Poll for connection completion after user authorizes in browser.
208
+
209
+ Args:
210
+ integration: Name of the integration
211
+ timeout: Maximum seconds to wait
212
+ poll_interval: Seconds between polls
213
+
214
+ Returns:
215
+ True if connected successfully, False otherwise
216
+ """
217
+ start_time = time.time()
218
+
219
+ while time.time() - start_time < timeout:
220
+ status = await check_integration_status(integration)
221
+
222
+ if status.get("connected"):
223
+ return True
224
+
225
+ await asyncio.sleep(poll_interval)
226
+
227
+ return False
228
+
229
+
230
+ async def handle_connect_command(args: str = ""):
231
+ """Handle /connect command - connect any integration.
232
+
233
+ Usage:
234
+ /connect gmail - Connect Gmail account
235
+ /connect slack - Connect Slack workspace
236
+ /connect notion - Connect Notion workspace
237
+ /connect linear - Connect Linear account
238
+ /connect github - Connect GitHub account
239
+ /connect - Show available integrations
240
+ """
241
+ # Get available integrations first
242
+ integrations_info = await get_available_integrations()
243
+ available = integrations_info.get("available", [])
244
+
245
+ if "error" in integrations_info:
246
+ print_error(f"Failed to get integrations: {integrations_info['error']}")
247
+ print_info("Make sure the backend is running: cd sudosu-backend && uvicorn app.main:app")
248
+ return
249
+
250
+ if not available:
251
+ print_warning("No integrations available. Configure them in your Composio account.")
252
+ print_info("Visit: https://app.composio.dev/auth-configs")
253
+ return
254
+
255
+ # Parse integration name from args
256
+ parts = args.strip().split()
257
+
258
+ # If no integration specified, show available options
259
+ if not parts:
260
+ console.print()
261
+ console.print(f"[bold {COLOR_SECONDARY}]━━━ Available Integrations ━━━[/bold {COLOR_SECONDARY}]")
262
+ console.print()
263
+ for toolkit in available:
264
+ display_name = get_display_name(toolkit)
265
+ console.print(f" • [{COLOR_INTERACTIVE}]{toolkit}[/{COLOR_INTERACTIVE}] - {display_name}")
266
+ console.print()
267
+ console.print("[dim]Usage: /connect <integration>[/dim]")
268
+ console.print("[dim]Example: /connect slack[/dim]")
269
+ return
270
+
271
+ integration = parts[0].lower()
272
+
273
+ if integration not in available:
274
+ print_error(f"Unknown integration: {integration}")
275
+ print_info(f"Available integrations: {', '.join(available)}")
276
+ return
277
+
278
+ display_name = get_display_name(integration)
279
+
280
+ # Check if already connected
281
+ print_info(f"Checking {display_name} connection status...")
282
+ status = await check_integration_status(integration)
283
+
284
+ if status.get("connected"):
285
+ print_success(f"✓ {display_name} is already connected!")
286
+ return
287
+
288
+ # Initiate connection
289
+ print_info(f"Initiating {display_name} connection...")
290
+ result = await initiate_connection(integration)
291
+
292
+ if "error" in result:
293
+ print_error(f"Failed to initiate connection: {result['error']}")
294
+ return
295
+
296
+ auth_url = result.get("auth_url")
297
+ if not auth_url:
298
+ print_error("No authorization URL received from backend")
299
+ return
300
+
301
+ # Open browser and wait for authorization
302
+ console.print()
303
+ console.print(f"[bold cyan]━━━ {display_name} Authorization ━━━[/bold cyan]")
304
+ console.print()
305
+ console.print(f"Opening your browser to authorize {display_name} access...")
306
+ console.print()
307
+ console.print("[dim]If the browser doesn't open, visit this URL:[/dim]")
308
+ console.print(f"[link={auth_url}]{auth_url}[/link]")
309
+ console.print()
310
+
311
+ # Try to open the browser
312
+ try:
313
+ webbrowser.open(auth_url)
314
+ except Exception:
315
+ pass # URL already displayed above
316
+
317
+ # Poll for completion
318
+ console.print(f"[{COLOR_PRIMARY}]Waiting for authorization...[/{COLOR_PRIMARY}]", end="")
319
+
320
+ connected = False
321
+ timeout = 120 # 2 minutes
322
+ poll_interval = 2
323
+ start_time = time.time()
324
+
325
+ while time.time() - start_time < timeout:
326
+ console.print(".", end="", style=COLOR_PRIMARY)
327
+
328
+ status = await check_integration_status(integration)
329
+ if status.get("connected"):
330
+ connected = True
331
+ break
332
+
333
+ await asyncio.sleep(poll_interval)
334
+
335
+ console.print() # New line after dots
336
+ console.print()
337
+
338
+ if connected:
339
+ print_success(f"✓ {display_name} connected successfully!")
340
+ console.print()
341
+ _show_integration_examples(integration)
342
+ else:
343
+ print_warning(f"Connection timed out. Please try again with /connect {integration}")
344
+
345
+
346
+ def _show_integration_examples(integration: str):
347
+ """Show example commands for a connected integration."""
348
+ examples = {
349
+ "gmail": [
350
+ "'Read my latest emails'",
351
+ "'Send an email to bob@example.com'",
352
+ "'Draft an email about the project update'",
353
+ ],
354
+ "slack": [
355
+ "'Send a message to #general channel'",
356
+ "'List my Slack channels'",
357
+ "'Send a DM to @john'",
358
+ ],
359
+ "notion": [
360
+ "'Create a new page in Notion'",
361
+ "'Search my Notion workspace for meeting notes'",
362
+ "'Update my project page'",
363
+ ],
364
+ "linear": [
365
+ "'Create a bug issue for the login problem'",
366
+ "'List my assigned issues'",
367
+ "'Update issue status to In Progress'",
368
+ ],
369
+ "github": [
370
+ "'Create an issue for the new feature'",
371
+ "'List open PRs in my repo'",
372
+ "'Star the langchain repository'",
373
+ ],
374
+ "googledrive": [
375
+ "'List files in my Drive'",
376
+ "'Upload a document'",
377
+ "'Create a new folder'",
378
+ ],
379
+ "googledocs": [
380
+ "'Create a new document'",
381
+ "'Get content from my meeting notes doc'",
382
+ ],
383
+ "googlesheets": [
384
+ "'Create a new spreadsheet'",
385
+ "'Update values in my budget sheet'",
386
+ ],
387
+ }
388
+
389
+ if integration in examples:
390
+ console.print("[dim]Your agent can now:[/dim]")
391
+ for example in examples[integration]:
392
+ console.print(f"[dim] {example}[/dim]")
393
+
394
+
395
+ async def handle_disconnect_command(args: str = ""):
396
+ """Handle /disconnect command - disconnect any integration.
397
+
398
+ Usage:
399
+ /disconnect gmail - Disconnect Gmail
400
+ /disconnect slack - Disconnect Slack
401
+ /disconnect - Show connected integrations
402
+ """
403
+ # Get available integrations first
404
+ integrations_info = await get_available_integrations()
405
+ available = integrations_info.get("available", [])
406
+ connected = integrations_info.get("connected", [])
407
+
408
+ # Parse integration name from args
409
+ parts = args.strip().split()
410
+
411
+ # If no integration specified, show connected ones
412
+ if not parts:
413
+ if not connected:
414
+ print_info("No integrations are currently connected.")
415
+ return
416
+
417
+ console.print()
418
+ console.print(f"[bold {COLOR_SECONDARY}]━━━ Connected Integrations ━━━[/bold {COLOR_SECONDARY}]")
419
+ console.print()
420
+ for toolkit in connected:
421
+ display_name = get_display_name(toolkit)
422
+ console.print(f" • [{COLOR_INTERACTIVE}]{toolkit}[/{COLOR_INTERACTIVE}] - {display_name}")
423
+ console.print()
424
+ console.print("[dim]Usage: /disconnect <integration>[/dim]")
425
+ console.print("[dim]Example: /disconnect slack[/dim]")
426
+ return
427
+
428
+ integration = parts[0].lower()
429
+
430
+ if integration not in available:
431
+ print_error(f"Unknown integration: {integration}")
432
+ print_info(f"Available integrations: {', '.join(available)}")
433
+ return
434
+
435
+ display_name = get_display_name(integration)
436
+
437
+ # Check if connected
438
+ status = await check_integration_status(integration)
439
+
440
+ if not status.get("connected"):
441
+ print_info(f"{display_name} is not connected.")
442
+ return
443
+
444
+ # Disconnect
445
+ print_info(f"Disconnecting {display_name}...")
446
+ result = await disconnect_integration(integration)
447
+
448
+ if result.get("success") or result.get("connected") is False:
449
+ print_success(f"✓ {display_name} disconnected successfully")
450
+ else:
451
+ print_error(f"Failed to disconnect: {result.get('error', 'Unknown error')}")
452
+
453
+
454
+ async def get_registry_info() -> dict:
455
+ """Get tool registry information from backend.
456
+
457
+ Returns:
458
+ Dict with registry data or error
459
+ """
460
+ user_id = get_user_id()
461
+ backend_url = get_http_backend_url()
462
+
463
+ try:
464
+ async with httpx.AsyncClient(timeout=10.0) as client:
465
+ response = await client.get(
466
+ f"{backend_url}/api/registry/{user_id}/summary",
467
+ )
468
+
469
+ if response.status_code == 200:
470
+ return response.json()
471
+ else:
472
+ return {"error": f"HTTP {response.status_code}"}
473
+
474
+ except Exception as e:
475
+ return {"error": str(e)}
476
+
477
+
478
+ async def handle_integrations_command(args: str = ""): # noqa: ARG001
479
+ """Handle /integrations command - show all integration status.
480
+
481
+ Usage:
482
+ /integrations - Show status of all integrations
483
+ """
484
+ console.print()
485
+ console.print("[bold cyan]━━━ Integrations ━━━[/bold cyan]")
486
+ console.print()
487
+
488
+ # Get all integrations with status
489
+ integrations_info = await get_available_integrations()
490
+
491
+ if "error" in integrations_info:
492
+ print_error(f"Failed to get integrations: {integrations_info['error']}")
493
+ print_info("Make sure the backend is running.")
494
+ return
495
+
496
+ details = integrations_info.get("details", [])
497
+ available = integrations_info.get("available", [])
498
+
499
+ if not details and not available:
500
+ print_warning("No integrations configured.")
501
+ print_info("Configure integrations at: https://app.composio.dev/auth-configs")
502
+ return
503
+
504
+ # Show status for each integration
505
+ if details:
506
+ for item in details:
507
+ slug = item.get("slug", "")
508
+ name = item.get("name", slug.title())
509
+ is_connected = item.get("connected", False)
510
+
511
+ if is_connected:
512
+ console.print(f" [{COLOR_INTERACTIVE}]●[/{COLOR_INTERACTIVE}] {name}: [{COLOR_INTERACTIVE}]Connected[/{COLOR_INTERACTIVE}]")
513
+ else:
514
+ console.print(f" [dim]○[/dim] {name}: [dim]Not connected[/dim]")
515
+ else:
516
+ # Fallback: check each available integration
517
+ for toolkit in available:
518
+ display_name = get_display_name(toolkit)
519
+ status = await check_integration_status(toolkit)
520
+
521
+ if status.get("connected"):
522
+ console.print(f" [{COLOR_INTERACTIVE}]●[/{COLOR_INTERACTIVE}] {display_name}: [{COLOR_INTERACTIVE}]Connected[/{COLOR_INTERACTIVE}]")
523
+ else:
524
+ console.print(f" [dim]○[/dim] {display_name}: [dim]Not connected[/dim]")
525
+
526
+ # Show tool registry info
527
+ console.print()
528
+ console.print(f"[bold {COLOR_SECONDARY}]━━━ Tool Registry ━━━[/bold {COLOR_SECONDARY}]")
529
+ console.print()
530
+
531
+ registry_info = await get_registry_info()
532
+
533
+ if "error" in registry_info:
534
+ console.print(f" [dim]Registry not available: {registry_info['error']}[/dim]")
535
+ else:
536
+ connected_count = registry_info.get("connected_count", 0)
537
+ connected = registry_info.get("connected", [])
538
+
539
+ if connected_count == 0:
540
+ console.print(" [dim]No tools registered (connect an integration first)[/dim]")
541
+ else:
542
+ console.print(f" [dim]Smart tool loading enabled[/dim]")
543
+ for item in connected:
544
+ slug = item.get("slug", "")
545
+ display_name = item.get("display_name", slug.title())
546
+ tool_count = item.get("tool_count", 0)
547
+ capabilities = item.get("capabilities", [])[:3]
548
+
549
+ console.print(f" • [{COLOR_INTERACTIVE}]{display_name}[/{COLOR_INTERACTIVE}]: {tool_count} tools")
550
+ if capabilities:
551
+ caps_str = ", ".join(capabilities[:3])
552
+ console.print(f" [dim]Capabilities: {caps_str}[/dim]")
553
+
554
+ console.print()
555
+ console.print("[dim]Commands:[/dim]")
556
+ console.print("[dim] /connect <integration> - Connect an integration[/dim]")
557
+ console.print("[dim] /disconnect <integration> - Disconnect an integration[/dim]")
558
+ console.print()
559
+ console.print("[dim]Examples:[/dim]")
560
+ console.print("[dim] /connect slack[/dim]")
561
+ console.print("[dim] /connect notion[/dim]")
562
+ console.print("[dim] /disconnect gmail[/dim]")
563
+ console.print()