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,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())
@@ -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()