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,635 @@
1
+ """CLI commands for managing background tasks.
2
+
3
+ IMPORTANT: These commands query the backend API.
4
+ All task execution happens SERVER-SIDE via ARQ workers.
5
+
6
+ The CLI is a THIN CLIENT - it only displays status and downloads reports.
7
+ """
8
+
9
+ import asyncio
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ import httpx
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
18
+ from rich.table import Table
19
+
20
+ from sudosu.commands.integrations import get_user_id
21
+ from sudosu.core import get_backend_url
22
+
23
+ console = Console()
24
+ app = typer.Typer(help="Manage background tasks")
25
+
26
+ # Status emoji mapping
27
+ STATUS_EMOJI = {
28
+ "pending": "⏳",
29
+ "running": "🔄",
30
+ "completed": "✅",
31
+ "failed": "❌",
32
+ "cancelled": "🚫",
33
+ }
34
+
35
+
36
+ def run_async(coro):
37
+ """Run an async coroutine, handling both sync and async contexts.
38
+
39
+ This helper detects if we're already in an event loop (like when called
40
+ from the interactive CLI session) and uses the appropriate method.
41
+ """
42
+ try:
43
+ loop = asyncio.get_running_loop()
44
+ # We're in an async context, create a task
45
+ import concurrent.futures
46
+ with concurrent.futures.ThreadPoolExecutor() as executor:
47
+ future = executor.submit(asyncio.run, coro)
48
+ return future.result()
49
+ except RuntimeError:
50
+ # No running loop, use asyncio.run()
51
+ return asyncio.run(coro)
52
+
53
+
54
+ def get_api_url() -> str:
55
+ """Get the backend API URL for tasks."""
56
+ backend_url = get_backend_url()
57
+ # Convert ws:// to http:// for REST API
58
+ if backend_url.startswith("ws://"):
59
+ return backend_url.replace("ws://", "http://").replace("/ws", "")
60
+ elif backend_url.startswith("wss://"):
61
+ return backend_url.replace("wss://", "https://").replace("/ws", "")
62
+ return backend_url.replace("/ws", "")
63
+
64
+
65
+ async def _list_tasks(status: Optional[str], limit: int) -> list[dict]:
66
+ """Fetch tasks from backend API."""
67
+ user_id = get_user_id()
68
+ if not user_id:
69
+ console.print("[red]Error: Not authenticated. Run 'sudosu init' first.[/red]")
70
+ return []
71
+
72
+ api_url = get_api_url()
73
+
74
+ async with httpx.AsyncClient() as client:
75
+ params = {"user_id": user_id, "limit": limit}
76
+ if status and status != "all":
77
+ params["status"] = status
78
+
79
+ try:
80
+ response = await client.get(
81
+ f"{api_url}/api/tasks",
82
+ params=params,
83
+ timeout=30.0,
84
+ )
85
+ response.raise_for_status()
86
+ data = response.json()
87
+ return data.get("tasks", [])
88
+ except httpx.HTTPError as e:
89
+ console.print(f"[red]Error fetching tasks: {e}[/red]")
90
+ return []
91
+
92
+
93
+ async def _get_task(task_id: str) -> Optional[dict]:
94
+ """Fetch a single task from backend API."""
95
+ user_id = get_user_id()
96
+ if not user_id:
97
+ console.print("[red]Error: Not authenticated. Run 'sudosu init' first.[/red]")
98
+ return None
99
+
100
+ api_url = get_api_url()
101
+
102
+ async with httpx.AsyncClient() as client:
103
+ try:
104
+ response = await client.get(
105
+ f"{api_url}/api/tasks/{task_id}",
106
+ params={"user_id": user_id},
107
+ timeout=30.0,
108
+ )
109
+ response.raise_for_status()
110
+ return response.json()
111
+ except httpx.HTTPStatusError as e:
112
+ if e.response.status_code == 404:
113
+ console.print(f"[red]Task not found: {task_id}[/red]")
114
+ else:
115
+ console.print(f"[red]Error fetching task: {e}[/red]")
116
+ return None
117
+ except httpx.HTTPError as e:
118
+ console.print(f"[red]Error fetching task: {e}[/red]")
119
+ return None
120
+
121
+
122
+ async def _get_task_logs(task_id: str, limit: int = 50) -> list[dict]:
123
+ """Fetch task logs from backend API."""
124
+ user_id = get_user_id()
125
+ if not user_id:
126
+ return []
127
+
128
+ api_url = get_api_url()
129
+
130
+ async with httpx.AsyncClient() as client:
131
+ try:
132
+ response = await client.get(
133
+ f"{api_url}/api/tasks/{task_id}/logs",
134
+ params={"user_id": user_id, "limit": limit},
135
+ timeout=30.0,
136
+ )
137
+ response.raise_for_status()
138
+ data = response.json()
139
+ return data.get("logs", [])
140
+ except httpx.HTTPError as e:
141
+ console.print(f"[red]Error fetching logs: {e}[/red]")
142
+ return []
143
+
144
+
145
+ async def _get_task_report(task_id: str) -> Optional[str]:
146
+ """Fetch task report from backend API."""
147
+ user_id = get_user_id()
148
+ if not user_id:
149
+ return None
150
+
151
+ api_url = get_api_url()
152
+
153
+ async with httpx.AsyncClient() as client:
154
+ try:
155
+ response = await client.get(
156
+ f"{api_url}/api/tasks/{task_id}/report",
157
+ params={"user_id": user_id},
158
+ timeout=30.0,
159
+ )
160
+ response.raise_for_status()
161
+ data = response.json()
162
+ return data.get("report")
163
+ except httpx.HTTPStatusError as e:
164
+ if e.response.status_code == 400:
165
+ console.print(f"[yellow]Report not available for this task.[/yellow]")
166
+ else:
167
+ console.print(f"[red]Error fetching report: {e}[/red]")
168
+ return None
169
+ except httpx.HTTPError as e:
170
+ console.print(f"[red]Error fetching report: {e}[/red]")
171
+ return None
172
+
173
+
174
+ async def _cancel_task(task_id: str) -> bool:
175
+ """Cancel a task via backend API."""
176
+ user_id = get_user_id()
177
+ if not user_id:
178
+ console.print("[red]Error: Not authenticated. Run 'sudosu init' first.[/red]")
179
+ return False
180
+
181
+ api_url = get_api_url()
182
+
183
+ async with httpx.AsyncClient() as client:
184
+ try:
185
+ response = await client.post(
186
+ f"{api_url}/api/tasks/{task_id}/cancel",
187
+ params={"user_id": user_id},
188
+ timeout=30.0,
189
+ )
190
+ response.raise_for_status()
191
+ return True
192
+ except httpx.HTTPStatusError as e:
193
+ if e.response.status_code == 404:
194
+ console.print(f"[red]Task not found: {task_id}[/red]")
195
+ elif e.response.status_code == 400:
196
+ console.print(f"[yellow]Task cannot be cancelled (already completed/failed).[/yellow]")
197
+ else:
198
+ console.print(f"[red]Error cancelling task: {e}[/red]")
199
+ return False
200
+ except httpx.HTTPError as e:
201
+ console.print(f"[red]Error cancelling task: {e}[/red]")
202
+ return False
203
+
204
+
205
+ def format_duration(seconds: int) -> str:
206
+ """Format duration in human-readable form."""
207
+ if seconds < 60:
208
+ return f"{seconds}s"
209
+ elif seconds < 3600:
210
+ return f"{seconds // 60}m {seconds % 60}s"
211
+ else:
212
+ hours = seconds // 3600
213
+ mins = (seconds % 3600) // 60
214
+ return f"{hours}h {mins}m"
215
+
216
+
217
+ def format_timestamp(iso_str: str) -> str:
218
+ """Format ISO timestamp for display."""
219
+ if not iso_str:
220
+ return "-"
221
+ try:
222
+ dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
223
+ return dt.strftime("%Y-%m-%d %H:%M")
224
+ except:
225
+ return iso_str[:16]
226
+
227
+
228
+ @app.command("list")
229
+ def list_tasks(
230
+ status: str = typer.Option(
231
+ "all",
232
+ "--status", "-s",
233
+ help="Filter by status: all, pending, running, completed, failed, cancelled"
234
+ ),
235
+ limit: int = typer.Option(10, "--limit", "-n", help="Number of tasks to show"),
236
+ full_id: bool = typer.Option(False, "--full-id", "-f", help="Show full task IDs"),
237
+ ):
238
+ """List your background tasks."""
239
+ tasks = run_async(_list_tasks(status if status != "all" else None, limit))
240
+
241
+ if not tasks:
242
+ console.print("\n[dim]No background tasks found.[/dim]\n")
243
+ return
244
+
245
+ table = Table(title="Background Tasks", show_header=True, header_style="bold cyan")
246
+
247
+ # Adjust ID column width based on full_id flag
248
+ id_width = 36 if full_id else 12
249
+ table.add_column("ID", style="dim", width=id_width)
250
+ table.add_column("Status", width=12)
251
+ table.add_column("Task Name", width=35)
252
+ table.add_column("Progress", width=12)
253
+ table.add_column("Created", width=16)
254
+
255
+ for task in tasks:
256
+ status_emoji = STATUS_EMOJI.get(task["status"], "❓")
257
+ status_text = f"{status_emoji} {task['status']}"
258
+
259
+ # Format progress
260
+ if task["status"] == "running":
261
+ progress = f"{task['progress_percent']:.0f}%"
262
+ elif task["status"] == "completed":
263
+ progress = f"✓ {task['completed_items']}/{task['total_items']}"
264
+ elif task["status"] == "failed":
265
+ progress = f"✗ {task['completed_items']}/{task['total_items']}"
266
+ else:
267
+ progress = "-"
268
+
269
+ # Truncate task name
270
+ task_name = task["task_name"]
271
+ if len(task_name) > 33:
272
+ task_name = task_name[:30] + "..."
273
+
274
+ # Show full or truncated ID based on flag
275
+ task_id_display = task["task_id"] if full_id else (task["task_id"][:12] + "...")
276
+
277
+ table.add_row(
278
+ task_id_display,
279
+ status_text,
280
+ task_name,
281
+ progress,
282
+ format_timestamp(task["created_at"]),
283
+ )
284
+
285
+ console.print()
286
+ console.print(table)
287
+ console.print()
288
+ if not full_id:
289
+ console.print("[dim]Use 'sudosu tasks status <task_id>' for details[/dim]")
290
+ console.print("[dim]Use --full-id to show complete task IDs for copy/paste[/dim]")
291
+ else:
292
+ console.print("[dim]Use 'sudosu tasks status <task_id>' for details[/dim]")
293
+
294
+
295
+ @app.command("status")
296
+ def task_status(
297
+ task_id: str = typer.Argument(..., help="Task ID (full or partial)"),
298
+ ):
299
+ """Get detailed status of a specific task."""
300
+ # Handle partial task IDs
301
+ if len(task_id) < 36:
302
+ # Need to list and find matching task
303
+ tasks = run_async(_list_tasks(None, 100))
304
+ matches = [t for t in tasks if t["task_id"].startswith(task_id)]
305
+ if len(matches) == 0:
306
+ console.print(f"[red]No task found starting with '{task_id}'[/red]")
307
+ return
308
+ elif len(matches) > 1:
309
+ console.print(f"[yellow]Multiple tasks match '{task_id}':[/yellow]")
310
+ for t in matches[:5]:
311
+ console.print(f" • {t['task_id'][:12]}... - {t['task_name'][:40]}")
312
+ return
313
+ task_id = matches[0]["task_id"]
314
+
315
+ task = run_async(_get_task(task_id))
316
+ if not task:
317
+ return
318
+
319
+ status_emoji = STATUS_EMOJI.get(task["status"], "❓")
320
+
321
+ # Build status panel
322
+ content = f"""
323
+ [bold]{task['task_name']}[/bold]
324
+
325
+ [cyan]Status:[/cyan] {status_emoji} {task['status'].upper()}
326
+ [cyan]Task ID:[/cyan] {task['task_id']}
327
+ [cyan]Type:[/cyan] {task['task_type']}
328
+
329
+ [cyan]Progress:[/cyan] {task['progress_percent']:.1f}%
330
+ [cyan]Items:[/cyan] {task['completed_items']} completed, {task['failed_items']} failed / {task['total_items']} total
331
+
332
+ [cyan]Created:[/cyan] {format_timestamp(task['created_at'])}
333
+ [cyan]Started:[/cyan] {format_timestamp(task.get('started_at', ''))}
334
+ [cyan]Completed:[/cyan] {format_timestamp(task.get('completed_at', ''))}
335
+ """
336
+
337
+ if task.get("error_message"):
338
+ content += f"\n[red]Error:[/red] {task['error_message']}"
339
+
340
+ console.print()
341
+ console.print(Panel(content, title="Task Status", border_style="cyan"))
342
+ console.print()
343
+
344
+ # Show original prompt
345
+ console.print("[bold]Original Request:[/bold]")
346
+ console.print(Panel(task["original_prompt"], border_style="dim"))
347
+
348
+ # Show result summary if completed
349
+ if task.get("result_summary"):
350
+ console.print("\n[bold]Result Summary:[/bold]")
351
+ console.print(Panel(task["result_summary"], border_style="green"))
352
+
353
+ console.print()
354
+ console.print("[dim]Use 'sudosu tasks logs <task_id>' for execution logs[/dim]")
355
+
356
+
357
+ @app.command("logs")
358
+ def task_logs(
359
+ task_id: str = typer.Argument(..., help="Task ID"),
360
+ limit: int = typer.Option(50, "--limit", "-n", help="Number of logs to show"),
361
+ ):
362
+ """View task execution logs."""
363
+ # Handle partial task IDs
364
+ if len(task_id) < 36:
365
+ tasks = run_async(_list_tasks(None, 100))
366
+ matches = [t for t in tasks if t["task_id"].startswith(task_id)]
367
+ if len(matches) == 1:
368
+ task_id = matches[0]["task_id"]
369
+ elif len(matches) == 0:
370
+ console.print(f"[red]No task found starting with '{task_id}'[/red]")
371
+ return
372
+ else:
373
+ console.print(f"[yellow]Multiple tasks match. Use more characters.[/yellow]")
374
+ return
375
+
376
+ logs = run_async(_get_task_logs(task_id, limit))
377
+
378
+ if not logs:
379
+ console.print("\n[dim]No logs found for this task.[/dim]\n")
380
+ return
381
+
382
+ console.print(f"\n[bold]Execution Logs[/bold] (Task: {task_id[:8]}...)\n")
383
+
384
+ table = Table(show_header=True, header_style="bold")
385
+ table.add_column("#", style="dim", width=4)
386
+ table.add_column("Status", width=8)
387
+ table.add_column("Tool", width=25)
388
+ table.add_column("Message", width=40)
389
+ table.add_column("Duration", width=10)
390
+
391
+ for log in logs:
392
+ status_icon = "✓" if log["status"] == "success" else "✗"
393
+ status_style = "green" if log["status"] == "success" else "red"
394
+
395
+ duration = f"{log['duration_ms']}ms" if log.get("duration_ms") else "-"
396
+ message = log.get("message", "-")
397
+ if len(message) > 38:
398
+ message = message[:35] + "..."
399
+
400
+ table.add_row(
401
+ str(log["iteration"]),
402
+ f"[{status_style}]{status_icon}[/{status_style}]",
403
+ log.get("tool_name", "-"),
404
+ message,
405
+ duration,
406
+ )
407
+
408
+ console.print(table)
409
+ console.print()
410
+
411
+
412
+ @app.command("report")
413
+ def task_report(
414
+ task_id: str = typer.Argument(..., help="Task ID"),
415
+ ):
416
+ """Download and display task report."""
417
+ # Handle partial task IDs
418
+ if len(task_id) < 36:
419
+ tasks = run_async(_list_tasks(None, 100))
420
+ matches = [t for t in tasks if t["task_id"].startswith(task_id)]
421
+ if len(matches) == 1:
422
+ task_id = matches[0]["task_id"]
423
+ elif len(matches) == 0:
424
+ console.print(f"[red]No task found starting with '{task_id}'[/red]")
425
+ return
426
+ else:
427
+ console.print(f"[yellow]Multiple tasks match. Use more characters.[/yellow]")
428
+ return
429
+
430
+ report = run_async(_get_task_report(task_id))
431
+
432
+ if not report:
433
+ return
434
+
435
+ console.print(f"\n[bold]Task Report[/bold] (ID: {task_id[:8]}...)\n")
436
+ console.print(Panel(report, border_style="green"))
437
+ console.print()
438
+
439
+
440
+ @app.command("cancel")
441
+ def cancel_task(
442
+ task_id: str = typer.Argument(..., help="Task ID to cancel (full or partial)"),
443
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
444
+ ):
445
+ """Cancel a pending or running task.
446
+
447
+ This will:
448
+ - Mark the task as cancelled in the database
449
+ - Stop execution at the next checkpoint (before the next tool call)
450
+ - Prevent the task from running if it hasn't started yet
451
+
452
+ Note: Tasks in the middle of a tool call will complete that call before stopping.
453
+ """
454
+ # Handle partial task IDs
455
+ if len(task_id) < 36:
456
+ tasks = run_async(_list_tasks(None, 100))
457
+ matches = [t for t in tasks if t["task_id"].startswith(task_id)]
458
+ if len(matches) == 1:
459
+ task_id = matches[0]["task_id"]
460
+ elif len(matches) == 0:
461
+ console.print(f"[red]No task found starting with '{task_id}'[/red]")
462
+ return
463
+ else:
464
+ console.print(f"[yellow]Multiple tasks match '{task_id}':[/yellow]")
465
+ for t in matches[:5]:
466
+ console.print(f" • {t['task_id']} - {t['task_name'][:40]}")
467
+ console.print(f"\n[dim]Use more characters to uniquely identify the task[/dim]")
468
+ return
469
+
470
+ # Get task details
471
+ task = run_async(_get_task(task_id))
472
+ if not task:
473
+ return
474
+
475
+ # Check if task can be cancelled
476
+ if task["status"] not in ("pending", "running"):
477
+ console.print(f"\n[yellow]This task cannot be cancelled (status: {task['status']})[/yellow]")
478
+ console.print("[dim]Only pending or running tasks can be cancelled.[/dim]")
479
+ return
480
+
481
+ # Show confirmation unless force flag is set
482
+ if not force:
483
+ console.print(f"\n[yellow]⚠ Cancel this task?[/yellow]")
484
+ console.print(f" [bold]Task:[/bold] {task['task_name']}")
485
+ console.print(f" [bold]Status:[/bold] {task['status']}")
486
+ console.print(f" [bold]Progress:[/bold] {task['completed_items']}/{task['total_items']} items")
487
+ console.print()
488
+
489
+ confirm = typer.confirm("Cancel this task?", default=False)
490
+ if not confirm:
491
+ console.print("[dim]Cancelled.[/dim]")
492
+ return
493
+
494
+ # Cancel the task
495
+ success = run_async(_cancel_task(task_id))
496
+
497
+ if success:
498
+ console.print(f"\n[green]✓ Task cancelled successfully[/green]")
499
+ console.print(f" [bold]Task ID:[/bold] {task_id}")
500
+ console.print()
501
+ console.print("[cyan]What happens now:[/cyan]")
502
+ if task["status"] == "pending":
503
+ console.print(" • Task was not yet started - it will be skipped")
504
+ else:
505
+ console.print(" • Task will stop before the next tool call")
506
+ console.print(" • Current operation will complete gracefully")
507
+ console.print()
508
+ console.print(f"[dim]Use 'sudosu tasks status {task_id[:12]}' to verify cancellation[/dim]")
509
+ else:
510
+ console.print(f"\n[red]✗ Failed to cancel task[/red]")
511
+ console.print("[dim]The task may have already completed or been cancelled.[/dim]")
512
+
513
+
514
+ @app.command("watch")
515
+ def watch_task(
516
+ task_id: str = typer.Argument(..., help="Task ID to watch"),
517
+ interval: int = typer.Option(2, "--interval", "-i", help="Refresh interval in seconds"),
518
+ ):
519
+ """Watch task progress in real-time."""
520
+ # Handle partial task IDs
521
+ if len(task_id) < 36:
522
+ tasks = run_async(_list_tasks(None, 100))
523
+ matches = [t for t in tasks if t["task_id"].startswith(task_id)]
524
+ if len(matches) == 1:
525
+ task_id = matches[0]["task_id"]
526
+ elif len(matches) == 0:
527
+ console.print(f"[red]No task found starting with '{task_id}'[/red]")
528
+ return
529
+ else:
530
+ console.print(f"[yellow]Multiple tasks match. Use more characters.[/yellow]")
531
+ return
532
+
533
+ console.print(f"\n[bold]Watching task {task_id[:8]}...[/bold]")
534
+ console.print("[dim]Press Ctrl+C to stop watching[/dim]\n")
535
+
536
+ try:
537
+ with Progress(
538
+ SpinnerColumn(),
539
+ TextColumn("[progress.description]{task.description}"),
540
+ BarColumn(),
541
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
542
+ console=console,
543
+ ) as progress:
544
+ progress_task = progress.add_task("Progress", total=100)
545
+
546
+ while True:
547
+ task = run_async(_get_task(task_id))
548
+ if not task:
549
+ break
550
+
551
+ status = task["status"]
552
+ percent = task["progress_percent"]
553
+
554
+ progress.update(
555
+ progress_task,
556
+ completed=percent,
557
+ description=f"{STATUS_EMOJI.get(status, '❓')} {task['task_name'][:40]}..."
558
+ )
559
+
560
+ # Stop watching if task is done
561
+ if status in ("completed", "failed", "cancelled"):
562
+ progress.update(progress_task, completed=100)
563
+ break
564
+
565
+ import time
566
+ time.sleep(interval)
567
+
568
+ # Show final status
569
+ task = run_async(_get_task(task_id))
570
+ if task:
571
+ console.print()
572
+ if task["status"] == "completed":
573
+ console.print(f"[green]✓ Task completed successfully![/green]")
574
+ elif task["status"] == "failed":
575
+ console.print(f"[red]✗ Task failed: {task.get('error_message', 'Unknown error')}[/red]")
576
+ elif task["status"] == "cancelled":
577
+ console.print(f"[yellow]⚠ Task was cancelled[/yellow]")
578
+
579
+ console.print(f"\n[dim]Use 'sudosu tasks status {task_id[:8]}' for details[/dim]")
580
+
581
+ except KeyboardInterrupt:
582
+ console.print("\n[dim]Stopped watching.[/dim]")
583
+
584
+
585
+ def handle_tasks_command(args: list[str]):
586
+ """Handle the tasks command from main CLI."""
587
+ if not args:
588
+ # Default to list with explicit default values
589
+ tasks = run_async(_list_tasks(None, 10))
590
+
591
+ if not tasks:
592
+ console.print("\n[dim]No background tasks found.[/dim]\n")
593
+ return
594
+
595
+ table = Table(title="Background Tasks", show_header=True, header_style="bold cyan")
596
+ table.add_column("ID", style="dim", width=10)
597
+ table.add_column("Status", width=12)
598
+ table.add_column("Task Name", width=35)
599
+ table.add_column("Progress", width=12)
600
+ table.add_column("Created", width=16)
601
+
602
+ for task in tasks:
603
+ status_emoji = STATUS_EMOJI.get(task["status"], "❓")
604
+ status_text = f"{status_emoji} {task['status']}"
605
+
606
+ # Format progress
607
+ if task["status"] == "running":
608
+ progress = f"{task['progress_percent']:.0f}%"
609
+ elif task["status"] == "completed":
610
+ progress = f"✓ {task['completed_items']}/{task['total_items']}"
611
+ elif task["status"] == "failed":
612
+ progress = f"✗ {task['completed_items']}/{task['total_items']}"
613
+ else:
614
+ progress = "-"
615
+
616
+ # Truncate task name
617
+ task_name = task["task_name"]
618
+ if len(task_name) > 33:
619
+ task_name = task_name[:30] + "..."
620
+
621
+ table.add_row(
622
+ task["task_id"][:8] + "...",
623
+ status_text,
624
+ task_name,
625
+ progress,
626
+ format_timestamp(task["created_at"]),
627
+ )
628
+
629
+ console.print()
630
+ console.print(table)
631
+ console.print()
632
+ console.print("[dim]Use '/tasks status <task_id>' for details[/dim]")
633
+ else:
634
+ # Pass args to typer app for subcommands
635
+ app(args)