connectonion 0.5.8__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 (113) hide show
  1. connectonion/__init__.py +78 -0
  2. connectonion/address.py +320 -0
  3. connectonion/agent.py +450 -0
  4. connectonion/announce.py +84 -0
  5. connectonion/asgi.py +287 -0
  6. connectonion/auto_debug_exception.py +181 -0
  7. connectonion/cli/__init__.py +3 -0
  8. connectonion/cli/browser_agent/__init__.py +5 -0
  9. connectonion/cli/browser_agent/browser.py +243 -0
  10. connectonion/cli/browser_agent/prompt.md +107 -0
  11. connectonion/cli/commands/__init__.py +1 -0
  12. connectonion/cli/commands/auth_commands.py +527 -0
  13. connectonion/cli/commands/browser_commands.py +27 -0
  14. connectonion/cli/commands/create.py +511 -0
  15. connectonion/cli/commands/deploy_commands.py +220 -0
  16. connectonion/cli/commands/doctor_commands.py +173 -0
  17. connectonion/cli/commands/init.py +469 -0
  18. connectonion/cli/commands/project_cmd_lib.py +828 -0
  19. connectonion/cli/commands/reset_commands.py +149 -0
  20. connectonion/cli/commands/status_commands.py +168 -0
  21. connectonion/cli/docs/co-vibecoding-principles-docs-contexts-all-in-one.md +2010 -0
  22. connectonion/cli/docs/connectonion.md +1256 -0
  23. connectonion/cli/docs.md +123 -0
  24. connectonion/cli/main.py +148 -0
  25. connectonion/cli/templates/meta-agent/README.md +287 -0
  26. connectonion/cli/templates/meta-agent/agent.py +196 -0
  27. connectonion/cli/templates/meta-agent/prompts/answer_prompt.md +9 -0
  28. connectonion/cli/templates/meta-agent/prompts/docs_retrieve_prompt.md +15 -0
  29. connectonion/cli/templates/meta-agent/prompts/metagent.md +71 -0
  30. connectonion/cli/templates/meta-agent/prompts/think_prompt.md +18 -0
  31. connectonion/cli/templates/minimal/README.md +56 -0
  32. connectonion/cli/templates/minimal/agent.py +40 -0
  33. connectonion/cli/templates/playwright/README.md +118 -0
  34. connectonion/cli/templates/playwright/agent.py +336 -0
  35. connectonion/cli/templates/playwright/prompt.md +102 -0
  36. connectonion/cli/templates/playwright/requirements.txt +3 -0
  37. connectonion/cli/templates/web-research/agent.py +122 -0
  38. connectonion/connect.py +128 -0
  39. connectonion/console.py +539 -0
  40. connectonion/debug_agent/__init__.py +13 -0
  41. connectonion/debug_agent/agent.py +45 -0
  42. connectonion/debug_agent/prompts/debug_assistant.md +72 -0
  43. connectonion/debug_agent/runtime_inspector.py +406 -0
  44. connectonion/debug_explainer/__init__.py +10 -0
  45. connectonion/debug_explainer/explain_agent.py +114 -0
  46. connectonion/debug_explainer/explain_context.py +263 -0
  47. connectonion/debug_explainer/explainer_prompt.md +29 -0
  48. connectonion/debug_explainer/root_cause_analysis_prompt.md +43 -0
  49. connectonion/debugger_ui.py +1039 -0
  50. connectonion/decorators.py +208 -0
  51. connectonion/events.py +248 -0
  52. connectonion/execution_analyzer/__init__.py +9 -0
  53. connectonion/execution_analyzer/execution_analysis.py +93 -0
  54. connectonion/execution_analyzer/execution_analysis_prompt.md +47 -0
  55. connectonion/host.py +579 -0
  56. connectonion/interactive_debugger.py +342 -0
  57. connectonion/llm.py +801 -0
  58. connectonion/llm_do.py +307 -0
  59. connectonion/logger.py +300 -0
  60. connectonion/prompt_files/__init__.py +1 -0
  61. connectonion/prompt_files/analyze_contact.md +62 -0
  62. connectonion/prompt_files/eval_expected.md +12 -0
  63. connectonion/prompt_files/react_evaluate.md +11 -0
  64. connectonion/prompt_files/react_plan.md +16 -0
  65. connectonion/prompt_files/reflect.md +22 -0
  66. connectonion/prompts.py +144 -0
  67. connectonion/relay.py +200 -0
  68. connectonion/static/docs.html +688 -0
  69. connectonion/tool_executor.py +279 -0
  70. connectonion/tool_factory.py +186 -0
  71. connectonion/tool_registry.py +105 -0
  72. connectonion/trust.py +166 -0
  73. connectonion/trust_agents.py +71 -0
  74. connectonion/trust_functions.py +88 -0
  75. connectonion/tui/__init__.py +57 -0
  76. connectonion/tui/divider.py +39 -0
  77. connectonion/tui/dropdown.py +251 -0
  78. connectonion/tui/footer.py +31 -0
  79. connectonion/tui/fuzzy.py +56 -0
  80. connectonion/tui/input.py +278 -0
  81. connectonion/tui/keys.py +35 -0
  82. connectonion/tui/pick.py +130 -0
  83. connectonion/tui/providers.py +155 -0
  84. connectonion/tui/status_bar.py +163 -0
  85. connectonion/usage.py +161 -0
  86. connectonion/useful_events_handlers/__init__.py +16 -0
  87. connectonion/useful_events_handlers/reflect.py +116 -0
  88. connectonion/useful_plugins/__init__.py +20 -0
  89. connectonion/useful_plugins/calendar_plugin.py +163 -0
  90. connectonion/useful_plugins/eval.py +139 -0
  91. connectonion/useful_plugins/gmail_plugin.py +162 -0
  92. connectonion/useful_plugins/image_result_formatter.py +127 -0
  93. connectonion/useful_plugins/re_act.py +78 -0
  94. connectonion/useful_plugins/shell_approval.py +159 -0
  95. connectonion/useful_tools/__init__.py +44 -0
  96. connectonion/useful_tools/diff_writer.py +192 -0
  97. connectonion/useful_tools/get_emails.py +183 -0
  98. connectonion/useful_tools/gmail.py +1596 -0
  99. connectonion/useful_tools/google_calendar.py +613 -0
  100. connectonion/useful_tools/memory.py +380 -0
  101. connectonion/useful_tools/microsoft_calendar.py +604 -0
  102. connectonion/useful_tools/outlook.py +488 -0
  103. connectonion/useful_tools/send_email.py +205 -0
  104. connectonion/useful_tools/shell.py +97 -0
  105. connectonion/useful_tools/slash_command.py +201 -0
  106. connectonion/useful_tools/terminal.py +285 -0
  107. connectonion/useful_tools/todo_list.py +241 -0
  108. connectonion/useful_tools/web_fetch.py +216 -0
  109. connectonion/xray.py +467 -0
  110. connectonion-0.5.8.dist-info/METADATA +741 -0
  111. connectonion-0.5.8.dist-info/RECORD +113 -0
  112. connectonion-0.5.8.dist-info/WHEEL +4 -0
  113. connectonion-0.5.8.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,527 @@
1
+ """
2
+ Purpose: Authenticate with OpenOnion backend using Ed25519 signature-based authentication to obtain JWT for managed keys
3
+ LLM-Note:
4
+ Dependencies: imports from [sys, time, toml, requests, pathlib, rich.console, rich.progress, rich.panel, address] | imported by [cli/main.py via handle_auth(), cli/commands/init.py, cli/commands/create.py] | calls backend at [https://api.openonion.ai/api/auth/login] | tested by [tests/cli/test_cli_auth.py]
5
+ Data flow: receives co_dir: Path from caller → address.load(co_dir) reads Ed25519 keypair from .co/keys/ → creates auth message with timestamp → address.sign() creates signature → POST to /api/auth/login with {public_key, message, signature, timestamp} → backend verifies signature → receives JWT token → saves to ~/.co/keys.env as OPENONION_API_KEY → optionally saves to project .env if save_to_project=True → displays balance and email status → returns success bool
6
+ State/Effects: modifies ~/.co/keys.env (adds/updates OPENONION_API_KEY and AGENT_EMAIL) | optionally modifies project .env if save_to_project=True | makes network POST request to api.openonion.ai | chmod 0o600 on .env files (Unix/Mac) | writes to stdout via rich.Console with progress spinner | updates ~/.co/config.toml with email_active status
7
+ Integration: exposes handle_auth() for CLI and authenticate(co_dir, save_to_project) for programmatic use | called by init.py and create.py during project setup | relies on address module for Ed25519 keypair operations | uses requests for HTTP calls | displays Rich progress spinner during network call | backend creates account on first auth (no separate registration)
8
+ Performance: network call to backend (2-5s) | signature generation is fast (<10ms) | file I/O for .env and config.toml | retries on network errors (up to 3 attempts with exponential backoff)
9
+ Errors: fails if ~/.co/keys/ missing (no keypair) | fails if backend unreachable (network error) | fails if signature invalid (backend 401) | fails if timestamp expired (5min window) | prints error messages to console and returns False | backend 500 errors bubble up with error details
10
+ """
11
+
12
+ import sys
13
+ import time
14
+ import toml
15
+ import requests
16
+ import json
17
+ import webbrowser
18
+ import os
19
+ from pathlib import Path
20
+ from rich.console import Console
21
+ from rich.progress import Progress, SpinnerColumn, TextColumn
22
+ from rich.panel import Panel
23
+ from dotenv import load_dotenv
24
+
25
+ from ... import address
26
+
27
+ console = Console()
28
+
29
+
30
+ def _save_api_key_to_env(co_dir: Path, api_key: str, agent_email: str = None, agent_address: str = None) -> None:
31
+ """Save OPENONION_API_KEY, AGENT_EMAIL, and AGENT_ADDRESS to .env file.
32
+
33
+ Args:
34
+ co_dir: Path to .co directory
35
+ api_key: The API key/token to save
36
+ agent_email: The agent email address to save (optional)
37
+ agent_address: The full agent address to save (optional)
38
+ """
39
+ env_file = co_dir.parent / ".env"
40
+ env_lines = []
41
+ key_found = False
42
+ email_found = False
43
+ address_found = False
44
+
45
+ # Read existing .env if it exists
46
+ if env_file.exists():
47
+ with open(env_file, "r", encoding='utf-8') as f:
48
+ for line in f:
49
+ if line.strip().startswith("OPENONION_API_KEY="):
50
+ env_lines.append(f"OPENONION_API_KEY={api_key}\n")
51
+ key_found = True
52
+ elif line.strip().startswith("AGENT_EMAIL=") and agent_email:
53
+ env_lines.append(f"AGENT_EMAIL={agent_email}\n")
54
+ email_found = True
55
+ elif line.strip().startswith("AGENT_ADDRESS=") and agent_address:
56
+ env_lines.append(f"AGENT_ADDRESS={agent_address}\n")
57
+ address_found = True
58
+ else:
59
+ env_lines.append(line)
60
+
61
+ # Add key if not found
62
+ if not key_found:
63
+ if env_lines and not env_lines[-1].endswith("\n"):
64
+ env_lines.append("\n")
65
+ env_lines.append(f"OPENONION_API_KEY={api_key}\n")
66
+
67
+ # Add email if not found and provided
68
+ if agent_email and not email_found:
69
+ env_lines.append(f"AGENT_EMAIL={agent_email}\n")
70
+
71
+ # Add address if not found and provided
72
+ if agent_address and not address_found:
73
+ env_lines.append(f"AGENT_ADDRESS={agent_address}\n")
74
+
75
+ # Write .env file
76
+ with open(env_file, "w", encoding='utf-8') as f:
77
+ f.writelines(env_lines)
78
+
79
+ # Make sure file permissions are restrictive (Unix/Mac only)
80
+ if sys.platform != 'win32':
81
+ env_file.chmod(0o600)
82
+
83
+
84
+ def authenticate(co_dir: Path, save_to_project: bool = True, quiet: bool = False) -> bool:
85
+ """Authenticate with OpenOnion API directly.
86
+
87
+ Args:
88
+ co_dir: Path to .co directory with keys
89
+ save_to_project: Whether to also save token to current directory's .env
90
+ quiet: If True, suppress verbose output (only show errors and minimal success)
91
+
92
+ Returns:
93
+ True if authentication successful, False otherwise
94
+ """
95
+ # Load agent keys - let it fail naturally if there's a problem
96
+ addr_data = address.load(co_dir)
97
+ if not addr_data:
98
+ console.print("āŒ No agent keys found!", style="red")
99
+ return False
100
+
101
+ public_key = addr_data["address"]
102
+
103
+ # Create signed authentication message
104
+ timestamp = int(time.time())
105
+ message = f"ConnectOnion-Auth-{public_key}-{timestamp}"
106
+ signature = address.sign(addr_data, message.encode()).hex()
107
+
108
+ # Call the new unified auth endpoint
109
+ auth_url = "https://oo.openonion.ai/api/v1/auth"
110
+
111
+ response = requests.post(auth_url, json={
112
+ "public_key": public_key,
113
+ "signature": signature,
114
+ "message": message
115
+ })
116
+
117
+ if response.status_code == 200:
118
+ data = response.json()
119
+ token = data.get("token")
120
+
121
+ # Extract agent email from server response FIRST (before saving to .env)
122
+ user = data.get("user", {})
123
+ email_info = user.get("email") if user else None
124
+
125
+ # Get the agent email from the server response
126
+ if email_info:
127
+ agent_email = email_info.get("address", f"{public_key[:10]}@mail.openonion.ai")
128
+ else:
129
+ agent_email = f"{public_key[:10]}@mail.openonion.ai"
130
+
131
+ # Save token to appropriate .env file(s)
132
+ is_global = co_dir.resolve() == (Path.home() / ".co").resolve()
133
+
134
+ if is_global:
135
+ # Save to global keys.env
136
+ global_keys_env = co_dir / "keys.env"
137
+ env_lines = []
138
+ key_found = False
139
+ email_found = False
140
+ address_found = False
141
+
142
+ # Read existing keys.env if it exists (preserve AGENT_ADDRESS)
143
+ config_path_found = False
144
+ if global_keys_env.exists():
145
+ with open(global_keys_env, "r", encoding='utf-8') as f:
146
+ for line in f:
147
+ if line.strip().startswith("OPENONION_API_KEY="):
148
+ env_lines.append(f"OPENONION_API_KEY={token}\n")
149
+ key_found = True
150
+ elif line.strip().startswith("AGENT_EMAIL="):
151
+ env_lines.append(f"AGENT_EMAIL={agent_email}\n")
152
+ email_found = True
153
+ elif line.strip().startswith("AGENT_ADDRESS="):
154
+ address_found = True
155
+ env_lines.append(line) # Preserve existing address
156
+ elif line.strip().startswith("AGENT_CONFIG_PATH="):
157
+ config_path_found = True
158
+ env_lines.append(line) # Preserve existing config path
159
+ else:
160
+ env_lines.append(line)
161
+
162
+ # Add config path if not found (at the beginning)
163
+ if not config_path_found:
164
+ env_lines.insert(0, f"AGENT_CONFIG_PATH={co_dir}\n")
165
+
166
+ # Add key if not found
167
+ if not key_found:
168
+ if env_lines and not env_lines[-1].endswith("\n"):
169
+ env_lines.append("\n")
170
+ env_lines.append(f"OPENONION_API_KEY={token}\n")
171
+
172
+ # Add email if not found
173
+ if not email_found:
174
+ env_lines.append(f"AGENT_EMAIL={agent_email}\n")
175
+
176
+ # Add address if not found (ensure AGENT_ADDRESS is always in global keys.env)
177
+ if not address_found:
178
+ env_lines.append(f"AGENT_ADDRESS={public_key}\n")
179
+
180
+ # Write global keys.env file
181
+ with open(global_keys_env, "w", encoding='utf-8') as f:
182
+ f.writelines(env_lines)
183
+ if sys.platform != 'win32':
184
+ global_keys_env.chmod(0o600)
185
+
186
+ console.print(f"āœ“ Saved to {global_keys_env}", style="green")
187
+
188
+ # Also save to current directory's .env (always create if using global keys and save_to_project=True)
189
+ if save_to_project:
190
+ local_env_path = Path(".co") if Path(".co").exists() else co_dir
191
+ _save_api_key_to_env(local_env_path, token, agent_email, public_key)
192
+ # Show relative path for local .env
193
+ local_env_file = Path.cwd() / ".env"
194
+ console.print(f"āœ“ Saved to {local_env_file}", style="green")
195
+ else:
196
+ # Save to local project .env
197
+ _save_api_key_to_env(co_dir, token, agent_email, public_key)
198
+
199
+ # Simple success message with balance
200
+ balance = user.get('balance_usd', 0.0) if user else 0.0
201
+ console.print(f"āœ“ Authenticated (Balance: ${balance:.2f})", style="green")
202
+
203
+ return True
204
+ else:
205
+ error_msg = response.json().get("detail", "Registration failed")
206
+ console.print(f"āŒ Registration failed: {error_msg}", style="red")
207
+ return False
208
+
209
+
210
+
211
+
212
+ def handle_auth():
213
+ """Authenticate with OpenOnion for managed keys (co/ models).
214
+
215
+ This command will:
216
+ 1. Load your agent's keys from .co/keys/ (or ~/.co/keys/ as fallback)
217
+ 2. Sign an authentication message
218
+ 3. Authenticate with the backend API
219
+ 4. Display comprehensive account information
220
+ 5. Save the token for future use
221
+ """
222
+ # Check if we have local keys first
223
+ co_dir = Path(".co")
224
+ use_global = False
225
+
226
+ # Check if local .co/keys/agent.key exists
227
+ if co_dir.exists() and (co_dir / "keys" / "agent.key").exists():
228
+ # Use local keys
229
+ console.print("šŸ“‚ Using local project keys (.co)", style="cyan")
230
+ else:
231
+ # No local keys, try global
232
+ co_dir = Path.home() / ".co"
233
+ use_global = True
234
+
235
+ if not co_dir.exists() or not (co_dir / "keys" / "agent.key").exists():
236
+ console.print("\nāŒ [bold red]No agent keys found[/bold red]")
237
+ console.print("\n[cyan]Initialize ConnectOnion first:[/cyan]")
238
+ console.print(" [bold]co init[/bold] Add to current directory")
239
+ console.print(" [bold]co create[/bold] Create new project folder")
240
+ console.print("\n[dim]Both set up ~/.co/ with your keys[/dim]\n")
241
+ return
242
+ else:
243
+ console.print("šŸ“‚ Using global ConnectOnion keys (~/.co)", style="cyan")
244
+
245
+ # Use the unified authenticate function
246
+ success = authenticate(co_dir)
247
+
248
+ if not success:
249
+ console.print("\n[yellow]Need help?[/yellow]")
250
+ console.print(" • Check your internet connection")
251
+ console.print(" • Try 'co init' to reinitialize your keys")
252
+ console.print(" • Visit https://discord.gg/4xfD9k8AUF for support")
253
+
254
+
255
+ def _load_api_key() -> str:
256
+ """Load OPENONION_API_KEY from environment.
257
+
258
+ Checks in order:
259
+ 1. Environment variable
260
+ 2. Local .env file
261
+ 3. Global ~/.co/keys.env file
262
+
263
+ Returns:
264
+ API key if found, None otherwise
265
+ """
266
+ # Check environment variable first
267
+ api_key = os.getenv("OPENONION_API_KEY")
268
+ if api_key:
269
+ return api_key
270
+
271
+ # Check local .env
272
+ local_env = Path(".env")
273
+ if local_env.exists():
274
+ load_dotenv(local_env)
275
+ api_key = os.getenv("OPENONION_API_KEY")
276
+ if api_key:
277
+ return api_key
278
+
279
+ # Check global ~/.co/keys.env
280
+ global_env = Path.home() / ".co" / "keys.env"
281
+ if global_env.exists():
282
+ load_dotenv(global_env)
283
+ api_key = os.getenv("OPENONION_API_KEY")
284
+ if api_key:
285
+ return api_key
286
+
287
+ return None
288
+
289
+
290
+ def _save_google_to_env(env_file: Path, credentials: dict) -> None:
291
+ """Save Google OAuth credentials to .env file.
292
+
293
+ Args:
294
+ env_file: Path to .env file
295
+ credentials: Dict with access_token, refresh_token, expires_at, google_email, scopes
296
+ """
297
+ env_lines = []
298
+
299
+ # Read existing .env
300
+ if env_file.exists():
301
+ with open(env_file, 'r', encoding='utf-8') as f:
302
+ for line in f:
303
+ # Skip existing Google credentials
304
+ if not line.strip().startswith('GOOGLE_'):
305
+ env_lines.append(line)
306
+
307
+ # Add Google credentials
308
+ if not env_lines or not env_lines[-1].endswith('\n'):
309
+ env_lines.append('\n')
310
+
311
+ env_lines.append('# Google OAuth Credentials\n')
312
+ env_lines.append(f"GOOGLE_ACCESS_TOKEN={credentials['access_token']}\n")
313
+ env_lines.append(f"GOOGLE_REFRESH_TOKEN={credentials['refresh_token']}\n")
314
+ env_lines.append(f"GOOGLE_TOKEN_EXPIRES_AT={credentials['expires_at']}\n")
315
+ env_lines.append(f"GOOGLE_SCOPES={credentials['scopes']}\n")
316
+ env_lines.append(f"GOOGLE_EMAIL={credentials['google_email']}\n")
317
+
318
+ # Write .env
319
+ with open(env_file, 'w', encoding='utf-8') as f:
320
+ f.writelines(env_lines)
321
+
322
+ # Set permissions (Unix/Mac only)
323
+ if sys.platform != 'win32':
324
+ env_file.chmod(0o600)
325
+
326
+
327
+ def handle_google_auth():
328
+ """Authenticate with Google OAuth for Gmail/Calendar access."""
329
+
330
+ # Check if user is authenticated with OpenOnion first
331
+ api_key = _load_api_key()
332
+ if not api_key:
333
+ console.print("\nāŒ [bold red]Not authenticated with OpenOnion[/bold red]")
334
+ console.print("\n[cyan]Authenticate first:[/cyan]")
335
+ console.print(" [bold]co auth[/bold] Get your OpenOnion API key\n")
336
+ return
337
+
338
+ api_url = "https://oo.openonion.ai/api/v1/oauth"
339
+ headers = {"Authorization": f"Bearer {api_key}"}
340
+
341
+ # Clear any existing connection first - this ensures we wait for NEW OAuth to complete
342
+ # (otherwise /google/status returns connected=true immediately from old credentials)
343
+ requests.delete(f"{api_url}/google/revoke", headers=headers)
344
+
345
+ # Get OAuth URL
346
+ console.print("šŸ”‘ Initializing Google OAuth...", style="cyan")
347
+
348
+ response = requests.get(f"{api_url}/google/init", headers=headers)
349
+ if response.status_code != 200:
350
+ console.print(f"\nāŒ Failed to initialize OAuth: {response.text}", style="red")
351
+ return
352
+
353
+ auth_url = response.json()['auth_url']
354
+
355
+ # Open browser
356
+ console.print(f"\n🌐 Opening browser for Google authentication...")
357
+ console.print(f" URL: {auth_url}\n", style="dim")
358
+
359
+ webbrowser.open(auth_url)
360
+
361
+ # Poll for completion
362
+ console.print("ā³ Waiting for authorization...", style="yellow")
363
+ console.print(" (Complete the authorization in your browser)\n", style="dim")
364
+
365
+ max_attempts = 60 # 5 minutes (5 second intervals)
366
+ for attempt in range(max_attempts):
367
+ time.sleep(5)
368
+
369
+ status_response = requests.get(f"{api_url}/google/status", headers=headers)
370
+ if status_response.status_code == 200:
371
+ status = status_response.json()
372
+ if status.get('connected'):
373
+ console.print("āœ“ Authorization successful!", style="green")
374
+ break
375
+ else:
376
+ console.print("\nāŒ Authorization timed out", style="red")
377
+ console.print("Please try again with: [bold]co auth google[/bold]\n")
378
+ return
379
+
380
+ # Get credentials
381
+ creds_response = requests.get(f"{api_url}/google/credentials", headers=headers)
382
+ if creds_response.status_code != 200:
383
+ console.print(f"\nāŒ Failed to get credentials: {creds_response.text}", style="red")
384
+ return
385
+
386
+ credentials = creds_response.json()
387
+
388
+ # Save credentials
389
+ console.print("\nšŸ’¾ Saving credentials...", style="cyan")
390
+
391
+ # Save to global ~/.co/keys.env
392
+ global_keys_env = Path.home() / ".co" / "keys.env"
393
+ if global_keys_env.exists():
394
+ _save_google_to_env(global_keys_env, credentials)
395
+ console.print(f" āœ“ Saved to {global_keys_env}", style="green")
396
+
397
+ # Save to local .env
398
+ local_env = Path(".env")
399
+ _save_google_to_env(local_env, credentials)
400
+ console.print(f" āœ“ Saved to {local_env.absolute()}", style="green")
401
+
402
+ # Success message
403
+ console.print(f"\nāœ… [bold green]Google account connected![/bold green]")
404
+ console.print(f" Email: {credentials['google_email']}", style="green")
405
+ console.print(f"\nšŸ“§ You can now use Google tools in your agents:")
406
+ console.print(f" [dim]from connectonion.tools import gmail_send[/dim]")
407
+ console.print(f" [dim]agent = Agent('assistant', tools=[gmail_send])[/dim]\n")
408
+
409
+
410
+ def _save_microsoft_to_env(env_file: Path, credentials: dict) -> None:
411
+ """Save Microsoft OAuth credentials to .env file.
412
+
413
+ Args:
414
+ env_file: Path to .env file
415
+ credentials: Dict with access_token, refresh_token, expires_at, microsoft_email, scopes
416
+ """
417
+ env_lines = []
418
+
419
+ # Read existing .env
420
+ if env_file.exists():
421
+ with open(env_file, 'r', encoding='utf-8') as f:
422
+ for line in f:
423
+ # Skip existing Microsoft credentials
424
+ if not line.strip().startswith('MICROSOFT_'):
425
+ env_lines.append(line)
426
+
427
+ # Add Microsoft credentials
428
+ if not env_lines or not env_lines[-1].endswith('\n'):
429
+ env_lines.append('\n')
430
+
431
+ env_lines.append('# Microsoft OAuth Credentials\n')
432
+ env_lines.append(f"MICROSOFT_ACCESS_TOKEN={credentials['access_token']}\n")
433
+ env_lines.append(f"MICROSOFT_REFRESH_TOKEN={credentials['refresh_token']}\n")
434
+ env_lines.append(f"MICROSOFT_TOKEN_EXPIRES_AT={credentials['expires_at']}\n")
435
+ env_lines.append(f"MICROSOFT_SCOPES={credentials['scopes']}\n")
436
+ env_lines.append(f"MICROSOFT_EMAIL={credentials['microsoft_email']}\n")
437
+
438
+ # Write .env
439
+ with open(env_file, 'w', encoding='utf-8') as f:
440
+ f.writelines(env_lines)
441
+
442
+ # Set permissions (Unix/Mac only)
443
+ if sys.platform != 'win32':
444
+ env_file.chmod(0o600)
445
+
446
+
447
+ def handle_microsoft_auth():
448
+ """Authenticate with Microsoft OAuth for Outlook/Calendar access."""
449
+
450
+ # Check if user is authenticated with OpenOnion first
451
+ api_key = _load_api_key()
452
+ if not api_key:
453
+ console.print("\nāŒ [bold red]Not authenticated with OpenOnion[/bold red]")
454
+ console.print("\n[cyan]Authenticate first:[/cyan]")
455
+ console.print(" [bold]co auth[/bold] Get your OpenOnion API key\n")
456
+ return
457
+
458
+ api_url = "https://oo.openonion.ai/api/v1/oauth"
459
+ headers = {"Authorization": f"Bearer {api_key}"}
460
+
461
+ # Clear any existing connection first
462
+ requests.delete(f"{api_url}/microsoft/revoke", headers=headers)
463
+
464
+ # Get OAuth URL
465
+ console.print("šŸ”‘ Initializing Microsoft OAuth...", style="cyan")
466
+
467
+ response = requests.get(f"{api_url}/microsoft/init", headers=headers)
468
+ if response.status_code != 200:
469
+ console.print(f"\nāŒ Failed to initialize OAuth: {response.text}", style="red")
470
+ return
471
+
472
+ auth_url = response.json()['auth_url']
473
+
474
+ # Open browser
475
+ console.print(f"\n🌐 Opening browser for Microsoft authentication...")
476
+ console.print(f" URL: {auth_url}\n", style="dim")
477
+
478
+ webbrowser.open(auth_url)
479
+
480
+ # Poll for completion
481
+ console.print("ā³ Waiting for authorization...", style="yellow")
482
+ console.print(" (Complete the authorization in your browser)\n", style="dim")
483
+
484
+ max_attempts = 60 # 5 minutes (5 second intervals)
485
+ for attempt in range(max_attempts):
486
+ time.sleep(5)
487
+
488
+ status_response = requests.get(f"{api_url}/microsoft/status", headers=headers)
489
+ if status_response.status_code == 200:
490
+ status = status_response.json()
491
+ if status.get('connected'):
492
+ console.print("āœ“ Authorization successful!", style="green")
493
+ break
494
+ else:
495
+ console.print("\nāŒ Authorization timed out", style="red")
496
+ console.print("Please try again with: [bold]co auth microsoft[/bold]\n")
497
+ return
498
+
499
+ # Get credentials
500
+ creds_response = requests.get(f"{api_url}/microsoft/credentials", headers=headers)
501
+ if creds_response.status_code != 200:
502
+ console.print(f"\nāŒ Failed to get credentials: {creds_response.text}", style="red")
503
+ return
504
+
505
+ credentials = creds_response.json()
506
+
507
+ # Save credentials
508
+ console.print("\nšŸ’¾ Saving credentials...", style="cyan")
509
+
510
+ # Save to global ~/.co/keys.env
511
+ global_keys_env = Path.home() / ".co" / "keys.env"
512
+ if global_keys_env.exists():
513
+ _save_microsoft_to_env(global_keys_env, credentials)
514
+ console.print(f" āœ“ Saved to {global_keys_env}", style="green")
515
+
516
+ # Save to local .env
517
+ local_env = Path(".env")
518
+ _save_microsoft_to_env(local_env, credentials)
519
+ console.print(f" āœ“ Saved to {local_env.absolute()}", style="green")
520
+
521
+ # Success message
522
+ console.print(f"\nāœ… [bold green]Microsoft account connected![/bold green]")
523
+ console.print(f" Email: {credentials['microsoft_email']}", style="green")
524
+ console.print(f"\nšŸ“§ You can now use Microsoft tools in your agents:")
525
+ console.print(f" [dim]from connectonion import Outlook, MicrosoftCalendar[/dim]")
526
+ console.print(f" [dim]agent = Agent('assistant', tools=[Outlook()])[/dim]\n")
527
+
@@ -0,0 +1,27 @@
1
+ """
2
+ Purpose: Execute browser automation commands using Playwright-based browser agent
3
+ LLM-Note:
4
+ Dependencies: imports from [rich.console, cli/browser_agent/browser.execute_browser_command] | imported by [cli/main.py via handle_browser()] | requires Playwright installation | tested by [tests/cli/test_cli_browser.py]
5
+ Data flow: receives command: str from CLI parser (e.g., "screenshot localhost:3000") → calls execute_browser_command(command) → browser agent parses command and executes via Playwright → returns result string → prints to console via rich.Console
6
+ State/Effects: no persistent state | launches headless browser via Playwright | may create screenshot files in current directory | writes to stdout via rich.Console | browser process lifecycle managed by browser_agent module
7
+ Integration: exposes handle_browser(command) | called from main.py via --browser flag or 'browser' subcommand | delegates to browser_agent/browser.execute_browser_command() | supports commands like "screenshot URL", "navigate URL", "click selector"
8
+ Performance: browser launch overhead (1-3s) | Playwright operations vary by command | screenshot generation is fast (<1s)
9
+ Errors: fails if Playwright not installed | fails if browser launch fails | fails if invalid command syntax | prints error to console but doesn't raise exception
10
+ """
11
+
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+
17
+ def handle_browser(command: str):
18
+ """Execute browser automation commands - guide browser to do something.
19
+
20
+ This is an alternative to the -b flag. Both 'co -b' and 'co browser' are supported.
21
+
22
+ Args:
23
+ command: The browser command to execute
24
+ """
25
+ from ..browser_agent.browser import execute_browser_command
26
+ result = execute_browser_command(command)
27
+ console.print(result)