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,155 @@
1
+ """CLI-specific configuration class (IG-174 Phase 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class CLIConfig:
12
+ """Minimal CLI config for daemon connection.
13
+
14
+ Full config available via daemon RPC when needed.
15
+ CLI package can be installed independently without full SootheConfig.
16
+ """
17
+
18
+ # WebSocket connection
19
+ daemon_host: str = "127.0.0.1"
20
+ daemon_port: int = 8765
21
+
22
+ # CLI behavior
23
+ verbosity: str = "normal"
24
+ output_format: str = "text"
25
+
26
+ # Paths
27
+ soothe_home: Path = field(default_factory=lambda: Path.home() / ".soothe")
28
+
29
+ # Daemon config cache (fetched via RPC)
30
+ _daemon_config_cache: dict[str, Any] = field(default_factory=dict)
31
+
32
+ def websocket_url(self) -> str:
33
+ """Construct WebSocket URL for daemon connection."""
34
+ return f"ws://{self.daemon_host}:{self.daemon_port}"
35
+
36
+ async def fetch_daemon_config(self, section: str = "all") -> dict[str, Any]:
37
+ """Fetch daemon config section via WebSocket RPC.
38
+
39
+ Args:
40
+ section: Config section name (e.g., "providers", "defaults", "all").
41
+
42
+ Returns:
43
+ Wire-safe config section dict.
44
+ """
45
+ from soothe_sdk.client import WebSocketClient, fetch_config_section
46
+
47
+ client = WebSocketClient(url=self.websocket_url())
48
+ await client.connect()
49
+
50
+ try:
51
+ config_section = await fetch_config_section(client, section, timeout=5.0)
52
+ self._daemon_config_cache[section] = config_section
53
+ return config_section
54
+ finally:
55
+ await client.close()
56
+
57
+ def get_cached_config(self, section: str) -> dict[str, Any]:
58
+ """Get cached daemon config section.
59
+
60
+ Args:
61
+ section: Config section name.
62
+
63
+ Returns:
64
+ Cached config section dict, or empty dict if not cached.
65
+ """
66
+ return self._daemon_config_cache.get(section, {})
67
+
68
+ @classmethod
69
+ def from_config_file(cls, config_path: Path | None = None) -> CLIConfig:
70
+ """Load CLI config from YAML file (minimal subset).
71
+
72
+ Reads minimal CLI-relevant settings from config file.
73
+ Full config available via daemon RPC.
74
+
75
+ Args:
76
+ config_path: Path to config file. Defaults to ~/.soothe/config.yml.
77
+
78
+ Returns:
79
+ CLIConfig instance with minimal settings.
80
+ """
81
+ import yaml
82
+
83
+ if config_path is None:
84
+ config_path = Path.home() / ".soothe" / "config.yml"
85
+
86
+ if not config_path.exists():
87
+ return cls() # Use defaults
88
+
89
+ with open(config_path) as f:
90
+ data = yaml.safe_load(f) or {}
91
+
92
+ # Extract minimal CLI-relevant config
93
+ daemon_section = data.get("daemon", {})
94
+ transports = daemon_section.get("transports", {})
95
+ websocket = transports.get("websocket", {})
96
+
97
+ return cls(
98
+ daemon_host=websocket.get("host", "127.0.0.1"),
99
+ daemon_port=websocket.get("port", 8765),
100
+ verbosity=data.get("logging", {}).get("verbosity", "normal"),
101
+ soothe_home=Path(data.get("home", str(Path.home() / ".soothe"))),
102
+ )
103
+
104
+ @classmethod
105
+ def from_soothe_config(cls, soothe_config: Any) -> CLIConfig:
106
+ """Create CLIConfig from full SootheConfig (compatibility helper).
107
+
108
+ Used during transition period where some code still has SootheConfig.
109
+
110
+ Args:
111
+ soothe_config: Full SootheConfig instance.
112
+
113
+ Returns:
114
+ CLIConfig with WebSocket settings extracted.
115
+ """
116
+ return cls(
117
+ daemon_host=soothe_config.daemon.transports.websocket.host,
118
+ daemon_port=soothe_config.daemon.transports.websocket.port,
119
+ verbosity=soothe_config.logging.verbosity,
120
+ soothe_home=Path(soothe_config.home),
121
+ )
122
+
123
+ # Compatibility properties for transition period
124
+ # These allow gradual migration without breaking existing code
125
+
126
+ @property
127
+ def daemon(self) -> Any:
128
+ """Compatibility property: return daemon config structure."""
129
+ return type(
130
+ "DaemonConfig",
131
+ (),
132
+ {
133
+ "transports": type(
134
+ "TransportsConfig",
135
+ (),
136
+ {
137
+ "websocket": type(
138
+ "WebSocketConfig",
139
+ (),
140
+ {"host": self.daemon_host, "port": self.daemon_port},
141
+ )
142
+ },
143
+ )()
144
+ },
145
+ )()
146
+
147
+ @property
148
+ def logging(self) -> Any:
149
+ """Compatibility property: return logging config structure."""
150
+ return type("LoggingConfig", (), {"verbosity": self.verbosity})()
151
+
152
+ @property
153
+ def home(self) -> str:
154
+ """Compatibility property: return home path string."""
155
+ return str(self.soothe_home)
@@ -0,0 +1,5 @@
1
+ """Plan rendering utilities for CLI/TUI."""
2
+
3
+ from soothe_cli.plan.rich_tree import render_plan_tree
4
+
5
+ __all__ = ["render_plan_tree"]
@@ -0,0 +1,54 @@
1
+ """Render planner ``Plan`` models as Rich trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from rich.text import Text
8
+ from rich.tree import Tree
9
+ from soothe_sdk.protocol_schemas import Plan
10
+
11
+ _TASK_NAME_RE = re.compile(r'"?name"?\s*:\s*"?(\w+)"?')
12
+ _STATUS_MARKERS: dict[str, tuple[str, str]] = {
13
+ "pending": ("[ ]", "dim"),
14
+ "in_progress": ("[>]", "bold yellow"),
15
+ "completed": ("[+]", "bold green"),
16
+ "failed": ("[x]", "bold red"),
17
+ }
18
+
19
+
20
+ def render_plan_tree(plan: Plan, title: str | None = None) -> Tree:
21
+ """Render a plan as a Rich Tree with status markers, dependencies, and activities."""
22
+ label = title or f"Plan: {plan.goal}"
23
+ tree = Tree(Text(label, style="bold cyan"))
24
+
25
+ if plan.reasoning:
26
+ reasoning_node = tree.add(Text("Reasoning", style="dim italic"))
27
+ reasoning_node.add(Text(plan.reasoning, style="dim"))
28
+
29
+ if plan.general_activity:
30
+ activity_node = tree.add(Text("General", style="dim italic"))
31
+ activity_node.add(Text(plan.general_activity, style="dim"))
32
+
33
+ for step in plan.steps:
34
+ marker, style = _STATUS_MARKERS.get(step.status, ("[ ]", "dim"))
35
+ step_style = {"in_progress": "yellow", "completed": "green"}.get(step.status, "dim")
36
+ parts: list[Text | str] = [
37
+ Text(marker, style=style),
38
+ " ",
39
+ Text(step.description, style=step_style),
40
+ ]
41
+ if step.depends_on:
42
+ dep_str = ", ".join(step.depends_on)
43
+ parts.append(Text(f" (< {dep_str})", style="dim italic"))
44
+
45
+ step_node = tree.add(Text.assemble(*parts))
46
+
47
+ if step.status == "in_progress" and step.current_activity:
48
+ activity_text = Text(step.current_activity, style="dim")
49
+ step_node.add(activity_text)
50
+
51
+ return tree
52
+
53
+
54
+ __all__ = ["_STATUS_MARKERS", "_TASK_NAME_RE", "render_plan_tree"]
@@ -0,0 +1,107 @@
1
+ """Shared UX presentation layer for CLI and TUI (not Typer/Textual).
2
+
3
+ This package provides:
4
+ - Configuration loading and logging setup
5
+ - Unified event processing (RFC-0019)
6
+ - Unified display policy for event/content filtering
7
+ - Abstract renderer protocol for CLI/TUI
8
+ - Shared message processing and utilities
9
+ - Slash command handlers and plan rendering (IG-176)
10
+ """
11
+
12
+ from soothe_sdk import setup_logging
13
+
14
+ from soothe_cli.shared.config_loader import load_config
15
+ from soothe_cli.shared.display_policy import (
16
+ INTERNAL_EVENT_TYPES,
17
+ INTERNAL_JSON_KEYS,
18
+ SKIP_EVENT_TYPES,
19
+ DisplayPolicy,
20
+ VerbosityLevel,
21
+ create_display_policy,
22
+ )
23
+ from soothe_cli.shared.essential_events import (
24
+ ESSENTIAL_PROGRESS_EVENT_TYPES,
25
+ GOAL_START_EVENT_TYPES,
26
+ LOOP_REASON_EVENT_TYPE,
27
+ STEP_COMPLETE_EVENT_TYPES,
28
+ STEP_START_EVENT_TYPES,
29
+ is_essential_progress_event_type,
30
+ is_goal_start_event_type,
31
+ is_step_complete_event_type,
32
+ is_step_start_event_type,
33
+ )
34
+ from soothe_cli.shared.event_processor import EventProcessor
35
+ from soothe_cli.shared.message_processing import (
36
+ accumulate_tool_call_chunks,
37
+ coerce_tool_call_args_to_dict,
38
+ extract_tool_brief,
39
+ finalize_pending_tool_call,
40
+ format_tool_call_args,
41
+ normalize_tool_calls_list,
42
+ strip_internal_tags,
43
+ tool_calls_have_any_arg_dict,
44
+ try_parse_pending_tool_call_args,
45
+ )
46
+ from soothe_cli.shared.processor_state import ProcessorState
47
+ from soothe_cli.shared.renderer_protocol import RendererProtocol
48
+ from soothe_cli.shared.rendering import update_name_map_from_tool_calls
49
+ from soothe_cli.shared.slash_commands import (
50
+ KEYBOARD_SHORTCUTS,
51
+ SLASH_COMMANDS,
52
+ parse_autonomous_command,
53
+ show_commands,
54
+ show_config,
55
+ show_history,
56
+ show_keymaps,
57
+ show_memory,
58
+ show_policy,
59
+ )
60
+
61
+ __all__ = [
62
+ "INTERNAL_EVENT_TYPES",
63
+ "INTERNAL_JSON_KEYS",
64
+ "SKIP_EVENT_TYPES",
65
+ "DisplayPolicy",
66
+ "ESSENTIAL_PROGRESS_EVENT_TYPES",
67
+ "GOAL_START_EVENT_TYPES",
68
+ "LOOP_REASON_EVENT_TYPE",
69
+ # Event processing
70
+ "EventProcessor",
71
+ "ProcessorState",
72
+ # Rendering
73
+ "RendererProtocol",
74
+ # Message processing
75
+ "VerbosityLevel",
76
+ "accumulate_tool_call_chunks",
77
+ "coerce_tool_call_args_to_dict",
78
+ # Display Policy (unified filtering module)
79
+ "create_display_policy",
80
+ "extract_tool_brief",
81
+ "finalize_pending_tool_call",
82
+ "format_tool_call_args",
83
+ "is_essential_progress_event_type",
84
+ "is_goal_start_event_type",
85
+ "is_step_complete_event_type",
86
+ "is_step_start_event_type",
87
+ # Config and logging
88
+ "load_config",
89
+ "normalize_tool_calls_list",
90
+ "setup_logging",
91
+ "STEP_COMPLETE_EVENT_TYPES",
92
+ "STEP_START_EVENT_TYPES",
93
+ "strip_internal_tags",
94
+ "tool_calls_have_any_arg_dict",
95
+ "try_parse_pending_tool_call_args",
96
+ "update_name_map_from_tool_calls",
97
+ # Slash commands (IG-176)
98
+ "KEYBOARD_SHORTCUTS",
99
+ "SLASH_COMMANDS",
100
+ "parse_autonomous_command",
101
+ "show_commands",
102
+ "show_config",
103
+ "show_history",
104
+ "show_keymaps",
105
+ "show_memory",
106
+ "show_policy",
107
+ ]
@@ -0,0 +1,246 @@
1
+ """Command routing logic for CLI/TUI (RFC-404).
2
+
3
+ Routes slash commands based on registry metadata:
4
+ - CLI-only commands: handled locally
5
+ - Daemon RPC commands: send command_request, handle command_response
6
+ - Daemon routing commands: send plain text input
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from rich.console import Console
17
+ from soothe_sdk.client import WebSocketClient
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def parse_slash_command(input_text: str) -> tuple[str, str | None]:
23
+ """Parse slash command and extract command + query.
24
+
25
+ Args:
26
+ input_text: Full user input (e.g., "/browser AI trends")
27
+
28
+ Returns:
29
+ Tuple of (command, query) where query may be None
30
+ """
31
+ stripped = input_text.strip()
32
+ if not stripped.startswith("/"):
33
+ return ("", None)
34
+
35
+ parts = stripped.split(maxsplit=1)
36
+ command = parts[0].lower()
37
+ query = parts[1] if len(parts) > 1 else None
38
+
39
+ return (command, query)
40
+
41
+
42
+ def validate_command(
43
+ entry: dict[str, Any], command: str, query: str | None, thread_id: str | None
44
+ ) -> tuple[bool, str | None]:
45
+ """Validate command before routing.
46
+
47
+ Args:
48
+ entry: Command registry entry
49
+ command: Command name
50
+ query: Query parameter (if present)
51
+ thread_id: Current thread ID
52
+
53
+ Returns:
54
+ Tuple of (is_valid, error_message)
55
+ """
56
+ # Check thread requirement
57
+ if entry.get("requires_thread") and not thread_id:
58
+ return (False, "No active thread")
59
+
60
+ # Check query requirement for routing commands
61
+ if entry.get("requires_query") and not query:
62
+ return (False, f"Command requires query: {command} <query>")
63
+
64
+ return (True, None)
65
+
66
+
67
+ def find_command_by_daemon_command(daemon_command: str) -> dict[str, Any] | None:
68
+ """Find command entry by daemon command name.
69
+
70
+ Args:
71
+ daemon_command: Daemon command name (e.g., "memory")
72
+
73
+ Returns:
74
+ Command entry dict or None if not found
75
+ """
76
+ from soothe_cli.shared.slash_commands import COMMANDS
77
+
78
+ for cmd_name, entry in COMMANDS.items():
79
+ if entry.get("daemon_command") == daemon_command:
80
+ return entry
81
+ return None
82
+
83
+
84
+ def parse_command_params(entry: dict[str, Any], query: str) -> dict[str, Any]:
85
+ """Parse query into params based on schema.
86
+
87
+ Args:
88
+ entry: Command registry entry with params_schema
89
+ query: Query string to parse
90
+
91
+ Returns:
92
+ Dict of params
93
+ """
94
+ schema = entry.get("params_schema", {})
95
+ if not schema:
96
+ return {}
97
+
98
+ parts = query.strip().split()
99
+ params = {}
100
+
101
+ # Map parts to schema keys
102
+ schema_keys = list(schema.keys())
103
+ for i, part in enumerate(parts):
104
+ if i < len(schema_keys):
105
+ key = schema_keys[i]
106
+ params[key] = part
107
+
108
+ return params
109
+
110
+
111
+ async def route_slash_command(cmd_input: str, console: Console, client: WebSocketClient) -> bool:
112
+ """Route slash command based on registry metadata (RFC-404).
113
+
114
+ Args:
115
+ cmd_input: Full command input (e.g., "/memory", "/browser AI trends")
116
+ console: Rich console for rendering
117
+ client: WebSocket client for daemon communication
118
+
119
+ Returns:
120
+ True if command was handled, False if unknown command
121
+ """
122
+ from soothe_cli.shared.slash_commands import COMMANDS
123
+
124
+ command, query = parse_slash_command(cmd_input)
125
+
126
+ # Not a slash command
127
+ if not command:
128
+ return False
129
+
130
+ # Lookup command in registry
131
+ entry = COMMANDS.get(command)
132
+ if not entry:
133
+ console.print(f"[red]Unknown command: {command}[/red]")
134
+ console.print("[dim]Type /help for available commands[/dim]")
135
+ return True # Handled (as error)
136
+
137
+ # Validate command
138
+ is_valid, error = validate_command(entry, command, query, client.thread_id)
139
+ if not is_valid:
140
+ console.print(f"[red]Error: {error}[/red]")
141
+ return True # Handled (as error)
142
+
143
+ # Route based on location and type
144
+ if entry["location"] == "cli":
145
+ # CLI-only: call handler directly
146
+ handler = entry.get("handler")
147
+ if handler:
148
+ handler(console)
149
+ return True
150
+
151
+ elif entry["location"] == "daemon" and entry.get("type") == "rpc":
152
+ # Daemon RPC: send command_request
153
+ await handle_rpc_command(entry, command, query, console, client)
154
+ return True
155
+
156
+ elif entry["location"] == "daemon" and entry.get("type") == "routing":
157
+ # Daemon routing: send as plain text input
158
+ await handle_routing_command(cmd_input, console, client)
159
+ return True
160
+
161
+ return False
162
+
163
+
164
+ async def handle_rpc_command(
165
+ entry: dict[str, Any],
166
+ command: str,
167
+ query: str | None,
168
+ console: Console,
169
+ client: WebSocketClient,
170
+ ) -> None:
171
+ """Handle daemon RPC command with structured request/response (RFC-404).
172
+
173
+ Args:
174
+ entry: Command registry entry
175
+ command: Command name
176
+ query: Query/params (if present)
177
+ console: Rich console
178
+ client: WebSocket client
179
+ """
180
+ daemon_command = entry["daemon_command"]
181
+
182
+ # Build request
183
+ request = {
184
+ "type": "command_request",
185
+ "command": daemon_command,
186
+ "thread_id": client.thread_id,
187
+ }
188
+
189
+ # Parse params if schema exists
190
+ if entry.get("params_schema") and query:
191
+ params = parse_command_params(entry, query)
192
+ request["params"] = params
193
+
194
+ # Send request and wait for response
195
+ try:
196
+ response = await client.request_response(
197
+ request, response_type="command_response", timeout=5.0
198
+ )
199
+
200
+ # Handle response
201
+ if response.get("error"):
202
+ console.print(f"[red]Error: {response['error']}[/red]")
203
+ elif response.get("data"):
204
+ handler = entry.get("handler")
205
+ if handler:
206
+ handler(console, response["data"])
207
+ else:
208
+ # Default: pretty print JSON
209
+ from rich.panel import Panel
210
+
211
+ console.print(
212
+ Panel(
213
+ json.dumps(response["data"], indent=2, default=str),
214
+ title=daemon_command,
215
+ border_style="cyan",
216
+ )
217
+ )
218
+
219
+ except TimeoutError:
220
+ console.print("[red]Error: Command request timed out[/red]")
221
+ except Exception as exc:
222
+ logger.exception("RPC command failed")
223
+ console.print(f"[red]Error: {exc}[/red]")
224
+
225
+
226
+ async def handle_routing_command(cmd_input: str, console: Console, client: WebSocketClient) -> None:
227
+ """Handle daemon routing command by sending plain text input (RFC-404).
228
+
229
+ Args:
230
+ cmd_input: Full command input (e.g., "/browser AI trends")
231
+ console: Rich console
232
+ client: WebSocket client
233
+ """
234
+ # Send as plain text - daemon input parser will route
235
+ await client.send_input(cmd_input)
236
+
237
+
238
+ __all__ = [
239
+ "parse_slash_command",
240
+ "route_slash_command",
241
+ "validate_command",
242
+ "find_command_by_daemon_command",
243
+ "parse_command_params",
244
+ "handle_rpc_command",
245
+ "handle_routing_command",
246
+ ]
@@ -0,0 +1,68 @@
1
+ """Configuration loading utilities (IG-174 Phase 3)."""
2
+
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+ from soothe_sdk import SOOTHE_HOME
9
+
10
+ from soothe_cli.config.cli_config import CLIConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _DEFAULT_CONFIG_PATH = Path(SOOTHE_HOME) / "config.yml"
15
+
16
+ # Config cache for performance
17
+ _config_cache: dict[str, CLIConfig] = {}
18
+
19
+
20
+ def load_config(config_path: str | None = None) -> CLIConfig:
21
+ """Load CLIConfig from a file path or defaults with caching.
22
+
23
+ CLIConfig is minimal and designed for independent CLI package installation.
24
+ Full daemon config available via WebSocket RPC when needed.
25
+
26
+ When no ``config_path`` is provided, automatically checks
27
+ ``~/.soothe/config.yml`` and loads it if present.
28
+
29
+ Uses an in-memory cache to avoid re-parsing config files.
30
+
31
+ Args:
32
+ config_path: Path to a YAML config file, or ``None`` for defaults.
33
+
34
+ Returns:
35
+ A ``CLIConfig`` instance.
36
+ """
37
+ # Load environment variables from .env file
38
+ # This ensures LangSmith and other env vars are available
39
+ load_dotenv()
40
+
41
+ # Determine the actual path to use
42
+ path_to_load: Path | None = None
43
+ if config_path:
44
+ path_to_load = Path(config_path)
45
+ elif _DEFAULT_CONFIG_PATH.is_file():
46
+ path_to_load = _DEFAULT_CONFIG_PATH
47
+
48
+ # Use "default" as cache key when no path is provided
49
+ cache_key = str(path_to_load) if path_to_load else "default"
50
+
51
+ # Check cache first
52
+ if cache_key in _config_cache:
53
+ logger.debug("Config loaded from cache: %s", cache_key)
54
+ return _config_cache[cache_key]
55
+
56
+ load_start = time.perf_counter()
57
+
58
+ # Load minimal CLI config
59
+ config = CLIConfig.from_config_file(path_to_load)
60
+ _config_cache[cache_key] = config
61
+
62
+ elapsed_ms = (time.perf_counter() - load_start) * 1000
63
+ if path_to_load:
64
+ logger.info("Loaded CLI config from '%s' in %.1fms", path_to_load, elapsed_ms)
65
+ else:
66
+ logger.debug("Created default CLI config in %.1fms", elapsed_ms)
67
+
68
+ return config