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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- 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,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
|