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,518 @@
|
|
|
1
|
+
"""Model configuration utilities for TUI (adapted from Soothe).
|
|
2
|
+
|
|
3
|
+
This module provides TUI-specific configuration utilities that bridge between
|
|
4
|
+
SootheConfig and TUI preferences. Note: This is a minimal stub to enable TUI
|
|
5
|
+
functionality - full migration needed in future.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from soothe_sdk import SOOTHE_HOME
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Default config path for Soothe
|
|
21
|
+
DEFAULT_CONFIG_PATH = Path(SOOTHE_HOME) / "config" / "config.yml"
|
|
22
|
+
|
|
23
|
+
# Environment variable prefix (Soothe uses SOOTHE_ instead of DEEPAGENTS_)
|
|
24
|
+
_ENV_PREFIX = "SOOTHE_"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Model configuration error (stub for now)
|
|
28
|
+
class ModelConfigError(Exception):
|
|
29
|
+
"""Error in model configuration."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ModelSpec:
|
|
36
|
+
"""Parsed ``provider:model`` specification for TUI helpers."""
|
|
37
|
+
|
|
38
|
+
provider: str
|
|
39
|
+
model: str
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def try_parse(cls, model_spec: str) -> ModelSpec | None:
|
|
43
|
+
"""Parse explicit ``provider:model`` when both parts are non-empty."""
|
|
44
|
+
if not model_spec or ":" not in model_spec:
|
|
45
|
+
return None
|
|
46
|
+
prov, _, rest = model_spec.partition(":")
|
|
47
|
+
prov = prov.strip()
|
|
48
|
+
mod = rest.strip()
|
|
49
|
+
if not prov or not mod:
|
|
50
|
+
return None
|
|
51
|
+
return cls(provider=prov, model=mod)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _fetch_provider_config(provider_name: str) -> dict[str, Any] | None:
|
|
55
|
+
"""Fetch provider config from daemon via WebSocket RPC.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
provider_name: Provider name to fetch.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Provider config dict or None if not found.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
from soothe_sdk.client import WebSocketClient, fetch_config_section
|
|
65
|
+
|
|
66
|
+
from soothe_cli.config.cli_config import CLIConfig
|
|
67
|
+
|
|
68
|
+
cli_cfg = CLIConfig.from_config_file()
|
|
69
|
+
ws_url = cli_cfg.websocket_url()
|
|
70
|
+
|
|
71
|
+
client = WebSocketClient(url=ws_url)
|
|
72
|
+
await client.connect()
|
|
73
|
+
try:
|
|
74
|
+
providers_data = await fetch_config_section(client, "providers", timeout=5.0)
|
|
75
|
+
return providers_data.get(provider_name)
|
|
76
|
+
finally:
|
|
77
|
+
await client.close()
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.debug("Could not fetch provider config from daemon", exc_info=True)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ModelConfig:
|
|
84
|
+
"""TUI-facing view over daemon config providers and router.
|
|
85
|
+
|
|
86
|
+
Per IG-174/IG-175 architectural separation, this class is transitioning to
|
|
87
|
+
fetch config from daemon via WebSocket RPC instead of local SootheConfig.
|
|
88
|
+
Currently in transition period - gracefully degrades when daemon not reachable.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def load(cls) -> ModelConfig:
|
|
93
|
+
"""Load config from daemon (TODO: IG-175 Phase 2).
|
|
94
|
+
|
|
95
|
+
During transition, returns empty instance when daemon not reachable.
|
|
96
|
+
Full implementation will fetch defaults/providers from daemon RPC.
|
|
97
|
+
"""
|
|
98
|
+
# TODO(IG-175): Replace with async daemon RPC fetch
|
|
99
|
+
# Currently returns empty instance for graceful degradation
|
|
100
|
+
logger.debug("ModelConfig.load() returning empty instance during IG-175 transition")
|
|
101
|
+
return cls(_cfg=None)
|
|
102
|
+
|
|
103
|
+
def __init__(self, *, _cfg: Any = None) -> None:
|
|
104
|
+
"""Initialize from an optional pre-loaded config.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
_cfg: Config instance (None during IG-175 transition).
|
|
108
|
+
"""
|
|
109
|
+
self._cfg = _cfg
|
|
110
|
+
self.default_model: str | None = None
|
|
111
|
+
self.recent_model: str | None = None
|
|
112
|
+
# TODO(IG-175): Fetch default model from daemon RPC
|
|
113
|
+
|
|
114
|
+
def get_kwargs(self, provider: str, model_name: str | None = None) -> dict[str, Any]:
|
|
115
|
+
"""Return kwargs for ``init_chat_model`` for this provider.
|
|
116
|
+
|
|
117
|
+
TODO(IG-175): Fetch from daemon RPC.
|
|
118
|
+
Currently returns empty dict during transition.
|
|
119
|
+
"""
|
|
120
|
+
if not provider:
|
|
121
|
+
return {}
|
|
122
|
+
# TODO(IG-175): Implement daemon RPC fetch
|
|
123
|
+
logger.debug("get_kwargs returning empty dict during IG-175 transition")
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
def get_base_url(self, provider: str) -> str | None:
|
|
127
|
+
"""Resolved ``api_base_url`` for the named provider.
|
|
128
|
+
|
|
129
|
+
TODO(IG-175): Fetch from daemon RPC.
|
|
130
|
+
Currently returns None during transition.
|
|
131
|
+
"""
|
|
132
|
+
if not provider:
|
|
133
|
+
return None
|
|
134
|
+
# TODO(IG-175): Implement daemon RPC fetch
|
|
135
|
+
logger.debug("get_base_url returning None during IG-175 transition")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def get_api_key_env(self, provider: str) -> str | None:
|
|
139
|
+
"""Infer env var name from provider ``api_key``.
|
|
140
|
+
|
|
141
|
+
TODO(IG-175): Fetch from daemon RPC.
|
|
142
|
+
Currently falls back to static map during transition.
|
|
143
|
+
"""
|
|
144
|
+
if not provider:
|
|
145
|
+
return None
|
|
146
|
+
# TODO(IG-175): Implement daemon RPC fetch
|
|
147
|
+
# Fallback to static map during transition
|
|
148
|
+
return PROVIDER_API_KEY_ENV.get(provider)
|
|
149
|
+
|
|
150
|
+
def get_class_path(self, provider: str) -> str | None:
|
|
151
|
+
"""Optional custom ``BaseChatModel`` import path (not used in Soothe YAML today)."""
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def get_profile_overrides(self, provider: str, model_name: str | None = None) -> dict[str, Any]:
|
|
155
|
+
"""Profile overrides from config for the given provider/model."""
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_env_var(var_name: str) -> str:
|
|
160
|
+
"""Resolve environment variable with SOOTHE_ prefix support.
|
|
161
|
+
|
|
162
|
+
This function handles two scenarios:
|
|
163
|
+
1. Direct env var lookup: resolve_env_var("LANGSMITH_API_KEY")
|
|
164
|
+
- First checks SOOTHE_LANGSMITH_API_KEY
|
|
165
|
+
- Falls back to LANGSMITH_API_KEY
|
|
166
|
+
2. Pattern resolution: resolve_env_var("${LANGSMITH_API_KEY}")
|
|
167
|
+
- Resolves ${VAR} patterns within strings
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
var_name: Environment variable name (e.g., "LANGSMITH_API_KEY")
|
|
171
|
+
or pattern string (e.g., "${LANGSMITH_API_KEY}")
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Resolved value from environment, or empty string if not found.
|
|
175
|
+
"""
|
|
176
|
+
import os
|
|
177
|
+
import re
|
|
178
|
+
|
|
179
|
+
# Case 1: Pattern resolution (${VAR} syntax)
|
|
180
|
+
pattern = r"\$\{([^}]+)\}"
|
|
181
|
+
if re.search(pattern, var_name):
|
|
182
|
+
|
|
183
|
+
def replace_env_var(match):
|
|
184
|
+
env_var = match.group(1)
|
|
185
|
+
# Try SOOTHE_ prefix first, then canonical
|
|
186
|
+
prefixed = f"{_ENV_PREFIX}{env_var}"
|
|
187
|
+
if prefixed in os.environ:
|
|
188
|
+
return os.environ[prefixed]
|
|
189
|
+
if env_var in os.environ:
|
|
190
|
+
return os.environ[env_var]
|
|
191
|
+
# Keep original pattern if not found
|
|
192
|
+
return match.group(0)
|
|
193
|
+
|
|
194
|
+
return re.sub(pattern, replace_env_var, var_name)
|
|
195
|
+
|
|
196
|
+
# Case 2: Direct env var lookup (no ${...} pattern)
|
|
197
|
+
# Try SOOTHE_ prefix first, then canonical name
|
|
198
|
+
prefixed = f"{_ENV_PREFIX}{var_name}"
|
|
199
|
+
if prefixed in os.environ:
|
|
200
|
+
return os.environ[prefixed]
|
|
201
|
+
return os.getenv(var_name, "")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Provider API key environment variables mapping
|
|
205
|
+
PROVIDER_API_KEY_ENV = {
|
|
206
|
+
"openai": "OPENAI_API_KEY",
|
|
207
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
208
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
209
|
+
"google": "GOOGLE_API_KEY",
|
|
210
|
+
"google_genai": "GOOGLE_API_KEY",
|
|
211
|
+
"nvidia": "NVIDIA_API_KEY",
|
|
212
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
213
|
+
"fireworks": "FIREWORKS_API_KEY",
|
|
214
|
+
"groq": "GROQ_API_KEY",
|
|
215
|
+
"mistralai": "MISTRAL_API_KEY",
|
|
216
|
+
"together": "TOGETHER_API_KEY",
|
|
217
|
+
"xai": "XAI_API_KEY",
|
|
218
|
+
"cohere": "COHERE_API_KEY",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Providers where a single API-key env var is not a reliable auth signal
|
|
222
|
+
# (ADC, local runtime, etc.) — matches early-credential skip in ``create_model``.
|
|
223
|
+
IMPLICIT_AUTH_PROVIDERS: frozenset[str] = frozenset(
|
|
224
|
+
{
|
|
225
|
+
"google_vertexai",
|
|
226
|
+
"ollama",
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_credential_env_var(provider: str) -> str | None:
|
|
232
|
+
"""Return the primary API-key env var name for ``provider``, if known.
|
|
233
|
+
|
|
234
|
+
Per IG-174, fetches provider config from daemon via RPC.
|
|
235
|
+
Falls back to hardcoded env var mapping if daemon not reachable.
|
|
236
|
+
"""
|
|
237
|
+
if provider:
|
|
238
|
+
# Try to fetch from daemon
|
|
239
|
+
try:
|
|
240
|
+
import asyncio
|
|
241
|
+
|
|
242
|
+
provider_data = asyncio.run(_fetch_provider_config(provider))
|
|
243
|
+
if provider_data and provider_data.get("api_key"):
|
|
244
|
+
api_key = provider_data["api_key"]
|
|
245
|
+
# Extract env var from ${ENV_VAR} syntax
|
|
246
|
+
m = re.match(r"^\$\{([^}]+)\}\s*$", str(api_key).strip())
|
|
247
|
+
if m:
|
|
248
|
+
return m.group(1)
|
|
249
|
+
except Exception:
|
|
250
|
+
logger.debug("Could not fetch provider config from daemon", exc_info=True)
|
|
251
|
+
|
|
252
|
+
# Fallback to hardcoded mapping
|
|
253
|
+
return PROVIDER_API_KEY_ENV.get(provider)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Stub functions for thread config (TUI preferences)
|
|
257
|
+
# These should be migrated to use SootheConfig properly
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_thread_config(thread_id: str | None = None) -> dict:
|
|
261
|
+
"""Load thread-specific TUI preferences.
|
|
262
|
+
|
|
263
|
+
Stub implementation - returns empty dict.
|
|
264
|
+
Full implementation should use SootheConfig's persistence.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
thread_id: Thread identifier. Can be None for default config.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Thread configuration dictionary.
|
|
271
|
+
"""
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def save_thread_relative_time(thread_id: str, relative_time: str) -> None:
|
|
276
|
+
"""Save thread's relative time display preference.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
thread_id: Thread identifier.
|
|
280
|
+
relative_time: Relative time format.
|
|
281
|
+
"""
|
|
282
|
+
# Stub - implement with SootheConfig persistence
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def save_thread_columns(thread_id: str, columns: list) -> None:
|
|
287
|
+
"""Save thread's column display preferences.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
thread_id: Thread identifier.
|
|
291
|
+
columns: Column configuration list.
|
|
292
|
+
"""
|
|
293
|
+
# Stub - implement with SootheConfig persistence
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def save_thread_sort_order(sort_order: str) -> None:
|
|
298
|
+
"""Save thread list sort order preference.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
sort_order: Sort order specification.
|
|
302
|
+
"""
|
|
303
|
+
# Stub - implement with SootheConfig persistence
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def load_thread_sort_order() -> str:
|
|
308
|
+
"""Return persisted thread list sort key (stub: most recently updated first)."""
|
|
309
|
+
return "updated_at"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def load_thread_relative_time() -> bool:
|
|
313
|
+
"""Return whether thread list uses relative timestamps (stub: on)."""
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def suppress_warning(warning_type: str) -> bool:
|
|
318
|
+
"""Persist suppressed notification preference (stub: no-op success)."""
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def unsuppress_warning(warning_type: str) -> bool:
|
|
323
|
+
"""Clear suppressed notification preference (stub: no-op success)."""
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def save_default_model(model_spec: ModelSpec) -> None:
|
|
328
|
+
"""Save default model preference.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
model_spec: Model specification to save as default.
|
|
332
|
+
"""
|
|
333
|
+
# Stub - should integrate with SootheConfig providers mapping
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def save_recent_model(model_spec: ModelSpec) -> None:
|
|
338
|
+
"""Save model to recent models list.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
model_spec: Model specification to add to recent models.
|
|
342
|
+
"""
|
|
343
|
+
# Stub - should maintain recent models list in SootheConfig
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def clear_default_model() -> None:
|
|
348
|
+
"""Clear saved default model preference."""
|
|
349
|
+
# Stub
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def clear_caches() -> None:
|
|
354
|
+
"""Clear cached ``SootheConfig`` so the next ``ModelConfig.load()`` re-reads disk."""
|
|
355
|
+
try:
|
|
356
|
+
import soothe_cli.shared.config_loader as _cl
|
|
357
|
+
|
|
358
|
+
_cl._config_cache.clear()
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.debug("Could not clear config_loader cache", exc_info=True)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def is_warning_suppressed(warning_type: str) -> bool:
|
|
364
|
+
"""Check if a warning type is suppressed in user preferences.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
warning_type: Warning type identifier.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
True if warning should be suppressed.
|
|
371
|
+
"""
|
|
372
|
+
# Stub - should check SootheConfig user preferences
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Additional stub classes and functions for model_selector compatibility
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ModelProfileEntry:
|
|
380
|
+
"""Stub for model profile entry - used in model selector.
|
|
381
|
+
|
|
382
|
+
Full implementation should integrate with SootheConfig's provider profiles.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
provider: str,
|
|
388
|
+
model: str,
|
|
389
|
+
display_name: str | None = None,
|
|
390
|
+
description: str | None = None,
|
|
391
|
+
context_limit: int | None = None,
|
|
392
|
+
**kwargs: Any,
|
|
393
|
+
) -> None:
|
|
394
|
+
self.provider = provider
|
|
395
|
+
self.model = model
|
|
396
|
+
self.display_name = display_name or f"{provider}:{model}"
|
|
397
|
+
self.description = description or ""
|
|
398
|
+
self.context_limit = context_limit
|
|
399
|
+
self.kwargs = kwargs
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_available_models() -> list[ModelProfileEntry]:
|
|
403
|
+
"""List models declared on daemon config (for ``/model`` UI).
|
|
404
|
+
|
|
405
|
+
Per IG-174 architectural separation, CLI fetches providers from daemon via RPC.
|
|
406
|
+
Returns empty list if daemon not reachable.
|
|
407
|
+
"""
|
|
408
|
+
try:
|
|
409
|
+
import asyncio
|
|
410
|
+
|
|
411
|
+
from soothe_sdk.client import WebSocketClient, fetch_config_section
|
|
412
|
+
|
|
413
|
+
# Use CLIConfig to get WebSocket URL
|
|
414
|
+
from soothe_cli.config.cli_config import CLIConfig
|
|
415
|
+
|
|
416
|
+
cli_cfg = CLIConfig.from_config_file()
|
|
417
|
+
ws_url = cli_cfg.websocket_url()
|
|
418
|
+
|
|
419
|
+
# Fetch providers section from daemon
|
|
420
|
+
client = WebSocketClient(url=ws_url)
|
|
421
|
+
|
|
422
|
+
async def _fetch_providers() -> dict:
|
|
423
|
+
await client.connect()
|
|
424
|
+
try:
|
|
425
|
+
return await fetch_config_section(client, "providers", timeout=5.0)
|
|
426
|
+
finally:
|
|
427
|
+
await client.close()
|
|
428
|
+
|
|
429
|
+
providers_data = asyncio.run(_fetch_providers())
|
|
430
|
+
|
|
431
|
+
if not providers_data:
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
out: list[ModelProfileEntry] = []
|
|
435
|
+
for p_name, p_data in providers_data.items():
|
|
436
|
+
models = p_data.get("models", [])
|
|
437
|
+
if models:
|
|
438
|
+
for m in models:
|
|
439
|
+
out.append(ModelProfileEntry(p_name, m))
|
|
440
|
+
else:
|
|
441
|
+
provider_type = p_data.get("provider_type", "unknown")
|
|
442
|
+
out.append(
|
|
443
|
+
ModelProfileEntry(
|
|
444
|
+
p_name,
|
|
445
|
+
"",
|
|
446
|
+
display_name=f"{p_name} ({provider_type})",
|
|
447
|
+
description="Configure models: list under this provider in config.yml",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
return out
|
|
451
|
+
except Exception:
|
|
452
|
+
logger.debug("Could not fetch providers from daemon", exc_info=True)
|
|
453
|
+
return []
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def get_model_profiles(cli_override: dict[str, Any] | None = None) -> dict[str, dict[str, Any]]:
|
|
457
|
+
"""Map ``provider:model`` keys to footer-shaped profile rows (minimal until YAML profiles exist).
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
cli_override: Reserved for CLI profile merge (unused in minimal catalog).
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Mapping of spec string to ``{"profile": {...}, "overridden_keys": set()}``.
|
|
464
|
+
"""
|
|
465
|
+
del cli_override # reserved for future profile merge from CLI flags
|
|
466
|
+
profiles: dict[str, dict[str, Any]] = {}
|
|
467
|
+
for entry in get_available_models():
|
|
468
|
+
if not entry.model:
|
|
469
|
+
continue
|
|
470
|
+
key = f"{entry.provider}:{entry.model}"
|
|
471
|
+
profiles[key] = {"profile": {}, "overridden_keys": set()}
|
|
472
|
+
return profiles
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def has_provider_credentials(provider: str) -> bool | None:
|
|
476
|
+
"""Check credentials using daemon config when available.
|
|
477
|
+
|
|
478
|
+
Per IG-174, fetches provider config from daemon via RPC.
|
|
479
|
+
Falls back to environment variables if daemon not reachable.
|
|
480
|
+
"""
|
|
481
|
+
if not provider:
|
|
482
|
+
return None
|
|
483
|
+
if provider in IMPLICIT_AUTH_PROVIDERS:
|
|
484
|
+
if provider == "google_vertexai":
|
|
485
|
+
proj = resolve_env_var("GOOGLE_CLOUD_PROJECT")
|
|
486
|
+
return bool(proj and proj.strip())
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
# Try to fetch from daemon
|
|
490
|
+
try:
|
|
491
|
+
import asyncio
|
|
492
|
+
|
|
493
|
+
provider_data = asyncio.run(_fetch_provider_config(provider))
|
|
494
|
+
if provider_data is not None:
|
|
495
|
+
provider_type = provider_data.get("provider_type", "")
|
|
496
|
+
if provider_type in IMPLICIT_AUTH_PROVIDERS or provider_type == "ollama":
|
|
497
|
+
return None
|
|
498
|
+
if provider_data.get("api_key"):
|
|
499
|
+
try:
|
|
500
|
+
from soothe_sdk.utils import resolve_provider_env
|
|
501
|
+
|
|
502
|
+
v = resolve_provider_env(
|
|
503
|
+
provider_data["api_key"], provider_name=provider, field_name="api_key"
|
|
504
|
+
)
|
|
505
|
+
except Exception:
|
|
506
|
+
logger.debug("resolve api_key failed for provider %r", provider, exc_info=True)
|
|
507
|
+
return None
|
|
508
|
+
return bool(v and str(v).strip())
|
|
509
|
+
return False
|
|
510
|
+
except Exception:
|
|
511
|
+
logger.debug("Could not fetch provider config from daemon", exc_info=True)
|
|
512
|
+
|
|
513
|
+
# Fallback to environment variable check
|
|
514
|
+
env_name = PROVIDER_API_KEY_ENV.get(provider)
|
|
515
|
+
if not env_name:
|
|
516
|
+
return None
|
|
517
|
+
val = resolve_env_var(env_name)
|
|
518
|
+
return bool(val and val.strip())
|
soothe_cli/tui/output.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Machine-readable JSON output helpers for CLI subcommands.
|
|
2
|
+
|
|
3
|
+
This module deliberately stays stdlib-only so it can be imported from CLI
|
|
4
|
+
startup paths without pulling in unnecessary dependency trees.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
OutputFormat = Literal["text", "json"]
|
|
15
|
+
"""Accepted internal output modes for CLI subcommands."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_json_output_arg(
|
|
19
|
+
parser: argparse.ArgumentParser, *, default: OutputFormat | None = None
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Add a `--json` flag to an argparse parser.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
parser: Parser to update.
|
|
25
|
+
default: Default output format for this parser.
|
|
26
|
+
|
|
27
|
+
Pass `None` for subparsers so parent parser values are preserved.
|
|
28
|
+
"""
|
|
29
|
+
if default is None:
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--json",
|
|
32
|
+
dest="output_format",
|
|
33
|
+
action="store_const",
|
|
34
|
+
const="json",
|
|
35
|
+
default=argparse.SUPPRESS,
|
|
36
|
+
help="Emit machine-readable JSON for this command",
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--json",
|
|
41
|
+
dest="output_format",
|
|
42
|
+
action="store_const",
|
|
43
|
+
const="json",
|
|
44
|
+
default=default,
|
|
45
|
+
help="Emit machine-readable JSON for this command",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_json(command: str, data: list | dict) -> None:
|
|
50
|
+
"""Write a JSON envelope to stdout and flush.
|
|
51
|
+
|
|
52
|
+
The envelope is a single-line JSON object with a stable schema:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"schema_version": 1, "command": "...", "data": ...}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
command: Self-documenting command name (e.g. `'list'`,
|
|
60
|
+
`'threads list'`).
|
|
61
|
+
data: Payload — typically a list for listing commands or a dict
|
|
62
|
+
for action/info commands.
|
|
63
|
+
|
|
64
|
+
`default=str` is used so that `Path` and `datetime` objects
|
|
65
|
+
serialize without error.
|
|
66
|
+
"""
|
|
67
|
+
envelope = {"schema_version": 1, "command": command, "data": data}
|
|
68
|
+
sys.stdout.write(json.dumps(envelope, default=str) + "\n")
|
|
69
|
+
sys.stdout.flush()
|