soothe-cli 0.1.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.
Files changed (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,194 @@
1
+ """Daemon-based execution for headless mode.
2
+
3
+ Refactored to use RFC-0019 EventProcessor with CliRenderer.
4
+ Uses WebSocket transport (RFC-0013).
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import sys
11
+ from typing import Any
12
+
13
+ import typer
14
+ from soothe_sdk.client import (
15
+ bootstrap_thread_session,
16
+ connect_websocket_with_retries,
17
+ websocket_url_from_config,
18
+ )
19
+
20
+ from soothe_cli.cli.renderer import CliRenderer
21
+ from soothe_cli.shared import EventProcessor
22
+ from soothe_cli.shared.presentation_engine import PresentationEngine
23
+ from soothe_cli.shared.subagent_routing import parse_subagent_from_input
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _DAEMON_FALLBACK_EXIT_CODE = 42
28
+ _SESSION_BOOTSTRAP_TIMEOUT_S = 5.0
29
+ _QUERY_START_TIMEOUT_S = 20.0
30
+
31
+
32
+ async def run_headless_via_daemon(
33
+ cfg: Any,
34
+ prompt: str,
35
+ *,
36
+ thread_id: str | None = None,
37
+ output_format: str = "text",
38
+ autonomous: bool = False,
39
+ max_iterations: int | None = None,
40
+ ) -> int:
41
+ """Run a single prompt by connecting to a running daemon.
42
+
43
+ Uses WebSocket transport for all connections (RFC-0013).
44
+ Refactored to use RFC-0019 EventProcessor with CliRenderer.
45
+ """
46
+ from soothe_sdk.client import WebSocketClient
47
+
48
+ ws_url = websocket_url_from_config(cfg)
49
+ client = WebSocketClient(url=ws_url)
50
+ verbosity = cfg.logging.verbosity
51
+
52
+ try:
53
+ await connect_websocket_with_retries(client)
54
+ status_event = await bootstrap_thread_session(
55
+ client,
56
+ resume_thread_id=thread_id,
57
+ verbosity=verbosity,
58
+ thread_status_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
59
+ subscription_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
60
+ )
61
+ if status_event.get("type") == "error":
62
+ typer.echo(f"Daemon error: {status_event.get('message', 'unknown')}", err=True)
63
+ return 1
64
+
65
+ actual_thread_id = status_event.get("thread_id")
66
+ if not actual_thread_id:
67
+ typer.echo("Error: No thread_id in status message", err=True)
68
+ return 1
69
+
70
+ subagent_name, cleaned_prompt = parse_subagent_from_input(prompt)
71
+
72
+ # Send the input
73
+ await asyncio.wait_for(
74
+ client.send_input(
75
+ cleaned_prompt if subagent_name else prompt,
76
+ autonomous=autonomous,
77
+ max_iterations=max_iterations,
78
+ subagent=subagent_name,
79
+ ),
80
+ timeout=_SESSION_BOOTSTRAP_TIMEOUT_S,
81
+ )
82
+
83
+ # Initialize RFC-0019 unified event processor with one PresentationEngine
84
+ # for pipeline + message gating (RFC-502).
85
+ presentation = PresentationEngine()
86
+ renderer = CliRenderer(verbosity=verbosity, presentation_engine=presentation)
87
+ processor = EventProcessor(renderer, verbosity=verbosity, presentation_engine=presentation)
88
+
89
+ has_error = False
90
+ query_started = False # Track if we've seen the query start running
91
+
92
+ while True:
93
+ try:
94
+ if query_started:
95
+ event = await client.read_event()
96
+ else:
97
+ event = await asyncio.wait_for(
98
+ client.read_event(), timeout=_QUERY_START_TIMEOUT_S
99
+ )
100
+ except TimeoutError:
101
+ return _DAEMON_FALLBACK_EXIT_CODE
102
+ if not event:
103
+ break
104
+
105
+ event_type = event.get("type", "")
106
+
107
+ # IMMEDIATE error check - exit before any other processing
108
+ # This ensures errors before query starts return immediately (IG-181)
109
+ if event_type == "error":
110
+ typer.echo(f"Daemon error: {event.get('message', 'unknown')}", err=True)
111
+ return 1
112
+
113
+ # Check for soothe.error.* events before query starts
114
+ ev_data = event.get("data")
115
+ if (
116
+ not query_started
117
+ and isinstance(ev_data, dict)
118
+ and str(ev_data.get("type", "")).startswith("soothe.error")
119
+ ):
120
+ typer.echo(f"Daemon error: {ev_data.get('error', 'unknown')}", err=True)
121
+ return 1
122
+
123
+ # Handle status changes (need to track query_started for timeout)
124
+ if event_type == "status":
125
+ state = event.get("state", "")
126
+ if state == "running":
127
+ query_started = True
128
+ elif (state == "idle" and query_started) or state == "stopped":
129
+ # loop.completed (and stray message chunks) may arrive *after* idle on the
130
+ # WebSocket stream; draining avoids dropping completion + final stdout (test-case1).
131
+ loop_clock = asyncio.get_event_loop()
132
+ drain_deadline = loop_clock.time() + 2.5
133
+ while loop_clock.time() < drain_deadline:
134
+ try:
135
+ nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
136
+ except TimeoutError:
137
+ break
138
+ if not nxt:
139
+ break
140
+ if output_format == "jsonl":
141
+ namespace = nxt.get("namespace", [])
142
+ mode = nxt.get("mode", "")
143
+ data = nxt.get("data")
144
+ sys.stdout.write(
145
+ json.dumps(
146
+ {"namespace": list(namespace), "mode": mode, "data": data},
147
+ default=str,
148
+ )
149
+ + "\n"
150
+ )
151
+ sys.stdout.flush()
152
+ continue
153
+ processor.process_event(nxt)
154
+
155
+ processor.process_event(event) # Finalize (on_turn_end after drain)
156
+ break
157
+
158
+ # JSONL output bypass processor
159
+ if output_format == "jsonl":
160
+ namespace = event.get("namespace", [])
161
+ mode = event.get("mode", "")
162
+ data = event.get("data")
163
+ sys.stdout.write(
164
+ json.dumps(
165
+ {"namespace": list(namespace), "mode": mode, "data": data}, default=str
166
+ )
167
+ + "\n"
168
+ )
169
+ sys.stdout.flush()
170
+ continue
171
+
172
+ # Delegate to unified event processor
173
+ processor.process_event(event)
174
+
175
+ # Note: Final newline is handled by renderer.on_turn_end() called
176
+ # when status changes to idle/stopped in _handle_status().
177
+ # Daemon lifecycle remains silent in normal headless mode.
178
+
179
+ except (ConnectionError, OSError, TimeoutError) as e:
180
+ logger.exception("Daemon connection failed")
181
+ from soothe_sdk import format_cli_error
182
+
183
+ typer.echo(f"Error: {format_cli_error(e, context='daemon connection')}", err=True)
184
+ return _DAEMON_FALLBACK_EXIT_CODE
185
+ except Exception as e:
186
+ logger.exception("Failed to run via daemon")
187
+ from soothe_sdk import format_cli_error
188
+
189
+ typer.echo(f"Error: {format_cli_error(e)}", err=True)
190
+ return 1
191
+ else:
192
+ return 1 if has_error else 0
193
+ finally:
194
+ await client.close()
@@ -0,0 +1,99 @@
1
+ """Headless execution orchestration."""
2
+
3
+ import asyncio
4
+ import sys
5
+ import time
6
+
7
+ import typer
8
+ from soothe_sdk.client import (
9
+ WebSocketClient,
10
+ is_daemon_live,
11
+ request_daemon_shutdown,
12
+ websocket_url_from_config,
13
+ )
14
+
15
+ from soothe_cli.config import CLIConfig
16
+
17
+ _DAEMON_FALLBACK_EXIT_CODE = 42
18
+ _DAEMON_START_WAIT_TIMEOUT = 30.0 # Max time to wait for daemon to become ready
19
+
20
+
21
+ def run_headless(
22
+ cfg: CLIConfig,
23
+ prompt: str,
24
+ *,
25
+ thread_id: str | None = None,
26
+ output_format: str = "text",
27
+ autonomous: bool = False,
28
+ max_iterations: int | None = None,
29
+ ) -> None:
30
+ """Run a single prompt with streaming output and progress events.
31
+
32
+ Connects to running daemon via WebSocket if available to avoid RocksDB lock conflicts.
33
+ Auto-starts daemon if not running (RFC-0013 daemon lifecycle).
34
+
35
+ Note (RFC-0013): Daemon persists after request completion. Use 'soothe-daemon stop'
36
+ to explicitly shutdown the daemon.
37
+ """
38
+ from soothe_cli.cli.execution.daemon import run_headless_via_daemon
39
+
40
+ # Get WebSocket URL for daemon checks
41
+ ws_url = websocket_url_from_config(cfg)
42
+
43
+ # Auto-start daemon if not running (RFC-0013) - WebSocket RPC checks (IG-174 Phase 1)
44
+ async def _check_and_ensure_daemon() -> None:
45
+ """Check daemon status and auto-start if needed."""
46
+ daemon_live = await is_daemon_live(ws_url, timeout=5.0)
47
+
48
+ if not daemon_live:
49
+ # Attempt cleanup if stale daemon (connection exists but daemon not responsive)
50
+ try:
51
+ client = WebSocketClient(url=ws_url)
52
+ await client.connect()
53
+ await request_daemon_shutdown(client, timeout=10.0)
54
+ await client.close()
55
+ except Exception:
56
+ pass # No daemon running or already stopped
57
+
58
+ # Start daemon via subprocess (daemon manages its own lifecycle)
59
+ # Invoke daemon entry point without importing daemon modules
60
+ import subprocess
61
+
62
+ subprocess.Popen(
63
+ [sys.executable, "-m", "soothe.cli.daemon_main", "start"],
64
+ stdout=subprocess.DEVNULL,
65
+ stderr=subprocess.DEVNULL,
66
+ )
67
+
68
+ # Wait for daemon to become fully ready with timeout
69
+ start_time = time.time()
70
+ while time.time() - start_time < _DAEMON_START_WAIT_TIMEOUT:
71
+ daemon_live = await is_daemon_live(ws_url, timeout=2.0)
72
+ if daemon_live:
73
+ break
74
+ await asyncio.sleep(0.5)
75
+ # Note: We don't fail here - let the connection attempt handle errors
76
+ # This allows tests and edge cases to proceed with mocked daemons
77
+
78
+ asyncio.run(_check_and_ensure_daemon())
79
+
80
+ # Connect to daemon and execute
81
+ daemon_exit_code = asyncio.run(
82
+ run_headless_via_daemon(
83
+ cfg,
84
+ prompt,
85
+ thread_id=thread_id,
86
+ output_format=output_format,
87
+ autonomous=autonomous,
88
+ max_iterations=max_iterations,
89
+ )
90
+ )
91
+
92
+ # Handle daemon fallback (unresponsive daemon)
93
+ if daemon_exit_code == _DAEMON_FALLBACK_EXIT_CODE:
94
+ typer.echo(
95
+ "Error: Daemon is unresponsive. Please restart with 'soothe-daemon restart'", err=True
96
+ )
97
+ sys.exit(1)
98
+
99
+ sys.exit(daemon_exit_code)
@@ -0,0 +1,31 @@
1
+ """TUI execution mode."""
2
+
3
+ import sys
4
+
5
+ import typer
6
+
7
+ from soothe_cli.config import CLIConfig
8
+
9
+
10
+ def run_tui(
11
+ cfg: CLIConfig,
12
+ *,
13
+ thread_id: str | None = None,
14
+ config_path: str | None = None,
15
+ initial_prompt: str | None = None,
16
+ ) -> None:
17
+ """Launch the Textual TUI (with daemon auto-start)."""
18
+ try:
19
+ from soothe_cli.tui import run_textual_tui
20
+
21
+ run_textual_tui(
22
+ config=cfg,
23
+ thread_id=thread_id,
24
+ initial_prompt=initial_prompt,
25
+ )
26
+ except ImportError:
27
+ typer.echo(
28
+ "Error: Textual is required for the TUI. Install: pip install 'textual>=0.40.0'",
29
+ err=True,
30
+ )
31
+ sys.exit(1)