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.
sudosu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sudosu - Your AI Coworker Platform"""
2
+
3
+ __version__ = "0.1.5"
sudosu/cli.py ADDED
@@ -0,0 +1,561 @@
1
+ """Sudosu CLI - Your AI Coworker Platform."""
2
+
3
+ import asyncio
4
+ import os
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from sudosu.commands.agent import get_agent_config, get_available_agents, handle_agent_command
13
+ from sudosu.commands.config import handle_config_command
14
+ from sudosu.commands.init import init_command, init_project_command
15
+ from sudosu.commands.integrations import (
16
+ get_user_id,
17
+ handle_connect_command,
18
+ handle_disconnect_command,
19
+ handle_integrations_command,
20
+ )
21
+ from sudosu.commands.memory import handle_memory_command
22
+ from sudosu.commands.onboarding import (
23
+ ensure_onboarding,
24
+ get_user_profile,
25
+ handle_profile_command,
26
+ )
27
+ from sudosu.commands.tasks import handle_tasks_command, app as tasks_app
28
+ from sudosu.core import ensure_config_structure, ensure_project_structure, get_backend_url, get_global_config_dir
29
+ from sudosu.core.connection import ConnectionManager
30
+ from sudosu.core.default_agent import get_default_agent_config, load_default_agent_from_file
31
+ from sudosu.core.safety import is_safe_directory, get_safety_warning
32
+ from sudosu.core.session import get_session_manager
33
+ from sudosu.tools import execute_tool
34
+ from sudosu.tools import ROUTING_MARKER
35
+ from sudosu.ui import (
36
+ clear_screen,
37
+ console,
38
+ get_user_input,
39
+ get_user_input_async,
40
+ LiveStreamPrinter,
41
+ print_agent_thinking,
42
+ print_consultation_route,
43
+ print_error,
44
+ print_help,
45
+ print_info,
46
+ print_routing_to_agent,
47
+ print_success,
48
+ print_tool_execution,
49
+ print_tool_result,
50
+ print_welcome,
51
+ StreamPrinter,
52
+ COLOR_PRIMARY,
53
+ COLOR_ACCENT,
54
+ )
55
+
56
+ app = typer.Typer(
57
+ name="sudosu",
58
+ help="Your AI Coworker Platform — AI teammates that actually get work done",
59
+ add_completion=False,
60
+ invoke_without_command=True,
61
+ no_args_is_help=False,
62
+ )
63
+
64
+ # Add tasks as a subcommand group
65
+ app.add_typer(tasks_app, name="tasks", help="Manage background tasks")
66
+
67
+
68
+ @app.callback(invoke_without_command=True)
69
+ def main_callback(
70
+ ctx: typer.Context,
71
+ init: bool = typer.Option(False, "--init", "-i", help="Initialize Sudosu configuration"),
72
+ version: bool = typer.Option(False, "--version", "-v", help="Show version"),
73
+ ):
74
+ """
75
+ Sudosu - Your AI Coworker Platform
76
+
77
+ Get AI coworkers that can read your files, write code, connect to your tools
78
+ (Gmail, Calendar, GitHub, Linear, Slack), and actually get work done.
79
+
80
+ Examples:
81
+ sudosu # Start interactive session
82
+ sudosu --init # Initialize configuration
83
+ sudosu tasks list # List background tasks
84
+ """
85
+ # If a subcommand was invoked (like 'tasks'), don't run main
86
+ if ctx.invoked_subcommand is not None:
87
+ return
88
+
89
+ if version:
90
+ from sudosu import __version__
91
+ console.print(f"sudosu version {__version__}")
92
+ return
93
+
94
+ if init:
95
+ asyncio.run(init_command(silent=False))
96
+ return
97
+
98
+ # Interactive mode (default)
99
+ _run_main_logic(None)
100
+
101
+
102
+ def parse_agent_prompt(text: str) -> tuple[str, str]:
103
+ """
104
+ Parse @agent_name from the prompt.
105
+
106
+ Returns:
107
+ Tuple of (agent_name, message)
108
+ """
109
+ match = re.match(r"@(\w+)\s*(.*)", text, re.DOTALL)
110
+ if match:
111
+ return match.group(1), match.group(2).strip()
112
+ return "", text
113
+
114
+
115
+ async def stream_agent_response(agent_config: dict, message: str, cwd: str, agent_name: str = "agent") -> dict | None:
116
+ """
117
+ Common function to stream agent response from backend with SHARED thread memory.
118
+
119
+ All agents use the same thread_id to share conversation context.
120
+
121
+ Returns:
122
+ Routing info dict if agent called route_to_agent or consultation triggered routing, None otherwise
123
+ """
124
+ backend_url = get_backend_url()
125
+ routing_info = None
126
+ consultation_routing = None
127
+
128
+ # Get session manager for SHARED thread memory
129
+ session_mgr = get_session_manager()
130
+
131
+ # Use shared thread_id, not per-agent thread
132
+ thread_id = session_mgr.get_thread_id()
133
+ session_id = session_mgr.session_id
134
+
135
+ # Get user_id for integration tools (Gmail, etc.)
136
+ user_id = get_user_id()
137
+
138
+ # Increment message count
139
+ session_mgr.increment_message_count()
140
+
141
+ try:
142
+ manager = ConnectionManager(backend_url)
143
+ await manager.connect()
144
+ except Exception as e:
145
+ print_error(f"Failed to connect to backend: {e}")
146
+ print_info("Make sure the backend is running")
147
+ print_info(f"Backend URL: {backend_url}")
148
+ return None
149
+
150
+ try:
151
+ stream_printer = LiveStreamPrinter()
152
+ stream_printer.start()
153
+
154
+ # Define callbacks
155
+ def on_text(content: str):
156
+ stream_printer.print_chunk(content)
157
+
158
+ async def on_tool_call(tool_name: str, args: dict):
159
+ nonlocal routing_info
160
+
161
+ # Special handling for route_to_agent
162
+ if tool_name == "route_to_agent":
163
+ result = await execute_tool(tool_name, args, cwd)
164
+ # Capture routing info for later
165
+ if result.get(ROUTING_MARKER):
166
+ routing_info = {
167
+ "agent_name": result["agent_name"],
168
+ "message": result["message"],
169
+ }
170
+ return result
171
+
172
+ # consult_orchestrator is handled by backend - shouldn't reach here
173
+ if tool_name == "consult_orchestrator":
174
+ # Backend handles this, but we need to return something
175
+ return {"output": "Consultation in progress..."}
176
+
177
+ # Normal tool execution
178
+ print_tool_execution(tool_name, args)
179
+ result = await execute_tool(tool_name, args, cwd)
180
+ print_tool_result(tool_name, result)
181
+ return result
182
+
183
+ def on_status(status: str):
184
+ console.print(f"[dim]{status}[/dim]")
185
+
186
+ async def on_special_message(msg: dict):
187
+ """Handle special messages from backend."""
188
+ nonlocal consultation_routing
189
+
190
+ if msg.get("type") == "get_available_agents":
191
+ # Backend is asking for available agents for consultation
192
+ agents = get_available_agents()
193
+ return {
194
+ "type": "available_agents",
195
+ "agents": agents,
196
+ }
197
+
198
+ elif msg.get("type") == "consultation_route":
199
+ # Sub-agent consulted sudosu and got a routing decision
200
+ consultation_routing = {
201
+ "from_agent": msg["from_agent"],
202
+ "to_agent": msg["to_agent"],
203
+ "reason": msg["reason"],
204
+ "user_request": msg["user_request"],
205
+ }
206
+
207
+ elif msg.get("type") == "background_queued":
208
+ # Task has been queued for background execution
209
+ task_id = msg.get("task_id", "unknown")
210
+ reason = msg.get("reason", "Complex task")
211
+ stream_printer.flush()
212
+ console.print()
213
+ console.print(f"[bold cyan]📋 Task Queued for Background Execution[/bold cyan]")
214
+ console.print(f"[dim]Task ID:[/dim] [yellow]{task_id}[/yellow]")
215
+ console.print(f"[dim]Reason:[/dim] {reason}")
216
+ console.print()
217
+ console.print("[dim]Use [bold]/tasks status {task_id}[/bold] to check progress[/dim]")
218
+ console.print("[dim]Use [bold]/tasks list[/bold] to see all your background tasks[/dim]")
219
+ console.print()
220
+
221
+ return None
222
+
223
+ # Stream response with SHARED thread context for memory
224
+ async for msg in manager.invoke_agent(
225
+ agent_config=agent_config,
226
+ message=message,
227
+ cwd=cwd,
228
+ session_id=session_id,
229
+ thread_id=thread_id,
230
+ user_id=user_id,
231
+ on_text=on_text,
232
+ on_tool_call=on_tool_call,
233
+ on_status=on_status,
234
+ on_special_message=on_special_message,
235
+ ):
236
+ if msg.get("type") == "error":
237
+ stream_printer.flush()
238
+ print_error(msg.get("message", "Unknown error"))
239
+ break
240
+ elif msg.get("type") == "done":
241
+ stream_printer.flush()
242
+ break
243
+
244
+ finally:
245
+ await manager.disconnect()
246
+
247
+ # Check for consultation-triggered routing first
248
+ if consultation_routing:
249
+ return {
250
+ "type": "consultation_route",
251
+ **consultation_routing,
252
+ }
253
+
254
+ return routing_info
255
+
256
+
257
+ async def invoke_agent(prompt: str, cwd: str):
258
+ """Send prompt to backend and stream response for @agent invocation."""
259
+ # Parse @agent_name from prompt
260
+ agent_name, message = parse_agent_prompt(prompt)
261
+
262
+ if not agent_name:
263
+ print_error("Please specify an agent: @agent_name <message>")
264
+ return
265
+
266
+ if not message:
267
+ print_error("Please provide a message")
268
+ return
269
+
270
+ # Load agent config
271
+ agent_config = get_agent_config(agent_name)
272
+
273
+ if not agent_config:
274
+ print_error(f"Agent '{agent_name}' not found")
275
+ print_info("Use /agent to list available agents, or /agent create <name> to create one")
276
+ return
277
+
278
+ # Explicit @agent switches the active agent
279
+ session_mgr = get_session_manager()
280
+ session_mgr.set_active_agent(agent_name, via_routing=False)
281
+
282
+ print_agent_thinking(agent_name)
283
+ await stream_agent_response(agent_config, message, cwd, agent_name)
284
+
285
+
286
+ async def invoke_active_agent(message: str, cwd: str):
287
+ """Continue conversation with the currently active agent."""
288
+ session_mgr = get_session_manager()
289
+ agent_name = session_mgr.get_active_agent()
290
+
291
+ agent_config = get_agent_config(agent_name)
292
+ if not agent_config:
293
+ # Fallback to orchestrator if agent not found
294
+ session_mgr.reset_to_orchestrator()
295
+ await invoke_default_agent(message, cwd)
296
+ return
297
+
298
+ print_agent_thinking(agent_name)
299
+ result = await stream_agent_response(agent_config, message, cwd, agent_name)
300
+
301
+ # Handle consultation-triggered routing
302
+ if result and result.get("type") == "consultation_route":
303
+ target_agent = result["to_agent"]
304
+ user_request = result["user_request"]
305
+ reason = result["reason"]
306
+ from_agent = result["from_agent"]
307
+
308
+ # Show the consultation decision
309
+ print_consultation_route(
310
+ from_agent=from_agent,
311
+ to_agent=target_agent,
312
+ reason=reason,
313
+ )
314
+
315
+ # Load target agent and continue
316
+ target_config = get_agent_config(target_agent)
317
+ if target_config:
318
+ session_mgr.set_active_agent(target_agent, via_routing=True)
319
+ print_agent_thinking(target_agent)
320
+ await stream_agent_response(target_config, user_request, cwd, target_agent)
321
+ else:
322
+ print_error(f"Agent '{target_agent}' not found")
323
+ print_info("Available agents:")
324
+ for agent in get_available_agents():
325
+ console.print(f" - @{agent['name']}")
326
+
327
+
328
+ async def invoke_default_agent(message: str, cwd: str):
329
+ """Invoke the default Sudosu assistant with intelligent routing.
330
+
331
+ First tries to load from .sudosu/AGENT.md (user-editable).
332
+ Falls back to built-in default if file doesn't exist.
333
+ """
334
+ # Get available agents to provide context
335
+ available_agents = get_available_agents()
336
+
337
+ # Get user profile for personalization
338
+ user_profile = get_user_profile()
339
+
340
+ # Try to load from .sudosu/AGENT.md first (user-customizable)
341
+ file_config = load_default_agent_from_file(cwd)
342
+
343
+ if file_config:
344
+ # User has customized their AGENT.md - use it
345
+ # But still inject dynamic context (available agents, user profile)
346
+ agent_config = file_config.copy()
347
+
348
+ # Inject available agents into system prompt if placeholder exists
349
+ # or append at the end
350
+ system_prompt = agent_config.get("system_prompt", "")
351
+
352
+ # Add available agents context
353
+ if available_agents:
354
+ agents_text = "\n## Available Agents in This Project\n\n"
355
+ for a in available_agents:
356
+ agents_text += f"- **@{a.get('name')}**: {a.get('description', 'No description')}\n"
357
+ system_prompt = system_prompt + "\n" + agents_text
358
+
359
+ # Add user context
360
+ if user_profile:
361
+ from sudosu.core.default_agent import format_user_context_for_prompt
362
+ user_context = format_user_context_for_prompt(user_profile)
363
+ if user_context:
364
+ system_prompt = user_context + "\n\n" + system_prompt
365
+
366
+ agent_config["system_prompt"] = system_prompt
367
+ else:
368
+ # Fall back to built-in default agent config
369
+ agent_config = get_default_agent_config(available_agents, cwd, user_profile)
370
+
371
+ print_agent_thinking("sudosu")
372
+ routing_info = await stream_agent_response(agent_config, message, cwd, "sudosu")
373
+
374
+ # Check if the default agent decided to route to another agent
375
+ if routing_info:
376
+ target_agent_name = routing_info["agent_name"]
377
+ routed_message = routing_info["message"]
378
+
379
+ print_routing_to_agent(target_agent_name)
380
+
381
+ # Load the target agent config
382
+ target_config = get_agent_config(target_agent_name)
383
+
384
+ if target_config:
385
+ # Mark this agent as active for follow-ups
386
+ session_mgr = get_session_manager()
387
+ session_mgr.set_active_agent(target_agent_name, via_routing=True)
388
+
389
+ print_agent_thinking(target_agent_name)
390
+ await stream_agent_response(target_config, routed_message, cwd, target_agent_name)
391
+ else:
392
+ print_error(f"Agent '{target_agent_name}' not found")
393
+ print_info("Available agents:")
394
+ for agent in available_agents:
395
+ console.print(f" - @{agent['name']}")
396
+ print_info("Create a new agent with /agent create <name>")
397
+
398
+
399
+ async def handle_command(command: str):
400
+ """Handle slash commands."""
401
+ parts = command.split()
402
+ cmd = parts[0].lower()
403
+ args = parts[1:]
404
+
405
+ if cmd == "/help":
406
+ print_help()
407
+
408
+ elif cmd == "/agent":
409
+ await handle_agent_command(args)
410
+
411
+ elif cmd == "/config":
412
+ await handle_config_command(args)
413
+
414
+ elif cmd == "/memory":
415
+ await handle_memory_command(args)
416
+
417
+ elif cmd == "/connect":
418
+ await handle_connect_command(" ".join(args))
419
+
420
+ elif cmd == "/disconnect":
421
+ await handle_disconnect_command(" ".join(args))
422
+
423
+ elif cmd == "/integrations":
424
+ await handle_integrations_command(" ".join(args))
425
+
426
+ elif cmd == "/profile":
427
+ await handle_profile_command(" ".join(args))
428
+
429
+ elif cmd == "/tasks":
430
+ handle_tasks_command(args)
431
+
432
+ elif cmd == "/init":
433
+ if args and args[0] == "project":
434
+ init_project_command()
435
+ else:
436
+ await init_command()
437
+
438
+ elif cmd == "/back":
439
+ # Return from sub-agent to sudosu orchestrator
440
+ session_mgr = get_session_manager()
441
+ old_agent = session_mgr.get_active_agent()
442
+ session_mgr.reset_to_orchestrator()
443
+ if old_agent != "sudosu":
444
+ print_info(f"Returned from @{old_agent} to sudosu")
445
+ else:
446
+ print_info("Already talking to sudosu")
447
+
448
+ elif cmd == "/clear":
449
+ clear_screen()
450
+
451
+ elif cmd == "/quit" or cmd == "/exit":
452
+ raise KeyboardInterrupt
453
+
454
+ else:
455
+ print_error(f"Unknown command: {cmd}")
456
+ print_info("Type /help for available commands")
457
+
458
+
459
+ async def interactive_session():
460
+ """Main interactive loop with active agent routing."""
461
+ # Ensure global config exists (just config.yaml, not project structure)
462
+ config_dir = get_global_config_dir()
463
+ if not (config_dir / "config.yaml").exists():
464
+ # Silent auto-init - just create config with defaults, no prompts
465
+ await init_command(silent=True)
466
+
467
+ # Run onboarding for first-time users (or sync profile from backend)
468
+ user_profile = await ensure_onboarding()
469
+
470
+ cwd = os.getcwd()
471
+
472
+ # Safety check - warn if running from unsafe directory
473
+ is_safe, reason = is_safe_directory(Path(cwd))
474
+
475
+ if not is_safe:
476
+ console.print(get_safety_warning(reason))
477
+ console.print("[dim]Press Enter to exit, or type 'continue' to proceed in read-only mode...[/dim]")
478
+ response = (await get_user_input_async("")).strip().lower()
479
+ if response != "continue":
480
+ console.print(f"[{COLOR_ACCENT}]Exiting. Navigate to a project folder and try again.[/{COLOR_ACCENT}]")
481
+ return
482
+ console.print(f"[{COLOR_ACCENT}]Running in restricted mode. Agent creation and file writes are disabled.[/{COLOR_ACCENT}]\n")
483
+ else:
484
+ # Safe directory - ensure project .sudosu/ structure exists with default AGENT.md
485
+ project_config = Path(cwd) / ".sudosu"
486
+ is_new_project = not project_config.exists()
487
+ ensure_project_structure(Path(cwd))
488
+
489
+ if is_new_project:
490
+ console.print(f"[{COLOR_PRIMARY}]✓[/{COLOR_PRIMARY}] Created .sudosu/ with default AGENT.md")
491
+ console.print("[dim] Edit .sudosu/AGENT.md to customize your AI assistant[/dim]")
492
+ console.print()
493
+
494
+ # Get username for welcome message (prefer profile name, fallback to system user)
495
+ if user_profile and user_profile.get("name"):
496
+ username = user_profile["name"]
497
+ else:
498
+ import getpass
499
+ username = getpass.getuser().capitalize()
500
+ print_welcome(username)
501
+
502
+ # Get session manager for active agent tracking
503
+ session_mgr = get_session_manager()
504
+
505
+ while True:
506
+ try:
507
+ # Show active agent in prompt
508
+ active = session_mgr.get_active_agent()
509
+ if active != "sudosu":
510
+ prompt = f"[@{active}] > "
511
+ else:
512
+ prompt = "> "
513
+
514
+ user_input = (await get_user_input_async(prompt)).strip()
515
+
516
+ if not user_input:
517
+ continue
518
+
519
+ if user_input.startswith("/"):
520
+ await handle_command(user_input)
521
+
522
+ elif user_input.startswith("@"):
523
+ # Check safety for @agent invocation
524
+ if not is_safe:
525
+ print_error("Agent invocation disabled in unsafe directory")
526
+ print_info("Navigate to a project folder to use agents")
527
+ continue
528
+ # Explicit agent switch - user is taking control
529
+ await invoke_agent(user_input, cwd)
530
+
531
+ else:
532
+ # Plain text - route to active agent
533
+ active_agent = session_mgr.get_active_agent()
534
+
535
+ if active_agent == "sudosu":
536
+ # Use orchestrator - it may route to another agent
537
+ await invoke_default_agent(user_input, cwd)
538
+ else:
539
+ # Continue with the active sub-agent
540
+ await invoke_active_agent(user_input, cwd)
541
+
542
+ except KeyboardInterrupt:
543
+ console.print(f"\n[{COLOR_PRIMARY}]Goodbye! 👋[/{COLOR_PRIMARY}]")
544
+ break
545
+ except EOFError:
546
+ break
547
+
548
+
549
+ def _run_main_logic(prompt: Optional[str]):
550
+ """Execute the main logic for sudosu CLI."""
551
+ if prompt:
552
+ # Direct invocation
553
+ cwd = os.getcwd()
554
+ asyncio.run(invoke_agent(prompt, cwd))
555
+ else:
556
+ # Interactive mode
557
+ asyncio.run(interactive_session())
558
+
559
+
560
+ if __name__ == "__main__":
561
+ app()
@@ -0,0 +1,15 @@
1
+ """Command handlers for Sudosu CLI."""
2
+
3
+ from sudosu.commands.integrations import (
4
+ get_user_id,
5
+ handle_connect_command,
6
+ handle_disconnect_command,
7
+ handle_integrations_command,
8
+ )
9
+
10
+ __all__ = [
11
+ "get_user_id",
12
+ "handle_connect_command",
13
+ "handle_disconnect_command",
14
+ "handle_integrations_command",
15
+ ]