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
soothe_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Soothe CLI client - communicates with daemon via WebSocket."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = []
@@ -0,0 +1 @@
1
+ """Module initialization for UX components."""
@@ -0,0 +1 @@
1
+ """Module initialization for UX components."""
@@ -0,0 +1,410 @@
1
+ """Autopilot CLI subcommands for RFC-204.
2
+
3
+ CLI is a control surface — no streaming output. Users submit tasks
4
+ and check status; real-time monitoring is via TUI/daemon.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from soothe_sdk.protocol import preview_first
13
+
14
+ app = typer.Typer(help="Autopilot mode — long-running autonomous agent control.")
15
+
16
+
17
+ @app.command("run")
18
+ def run(
19
+ prompt: str = typer.Argument(..., help="Task for autonomous execution."),
20
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to configuration file."),
21
+ max_iterations: int | None = typer.Option(
22
+ None, "--max-iterations", help="Maximum autonomous iterations."
23
+ ),
24
+ output_format: str = typer.Option(
25
+ "text", "--format", "-f", help="Output format: text or jsonl."
26
+ ),
27
+ ) -> None:
28
+ """Run autonomous agent loop for complex tasks.
29
+
30
+ Autopilot mode executes tasks autonomously without requiring user interaction.
31
+ The agent operates in headless mode (no TUI) and outputs progress to stdout.
32
+ """
33
+ from soothe_cli.cli.commands.run_cmd import run_impl
34
+
35
+ run_impl(
36
+ prompt=prompt,
37
+ config=config,
38
+ thread_id=None,
39
+ no_tui=True,
40
+ autonomous=True,
41
+ max_iterations=max_iterations,
42
+ output_format=output_format,
43
+ verbosity=None,
44
+ )
45
+
46
+
47
+ @app.command("submit")
48
+ def submit(
49
+ task: str = typer.Argument(..., help="Task description."),
50
+ priority: int = typer.Option(50, "--priority", "-p", help="Goal priority (0-100)."),
51
+ ) -> None:
52
+ """Submit a new task to autopilot.
53
+
54
+ Writes a markdown task file to the autopilot inbox.
55
+ """
56
+ from datetime import UTC, datetime
57
+
58
+ from soothe_sdk import SOOTHE_HOME
59
+
60
+ inbox_dir = SOOTHE_HOME / "autopilot" / "inbox"
61
+ inbox_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ timestamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S")
64
+ filename = f"TASK-{timestamp}.md"
65
+ fpath = inbox_dir / filename
66
+
67
+ fpath.write_text(f"---\ntype: task_submit\npriority: {priority}\n---\n\n{task}\n")
68
+ typer.echo(f"Task submitted: {fpath}")
69
+ typer.echo(f" Priority: {priority}")
70
+
71
+
72
+ @app.command("status")
73
+ def status() -> None:
74
+ """Show overall autopilot state."""
75
+ from soothe_sdk import SOOTHE_HOME
76
+
77
+ autopilot_dir = SOOTHE_HOME / "autopilot"
78
+ state_file = autopilot_dir / "status.json"
79
+
80
+ if not autopilot_dir.exists():
81
+ typer.echo("Autopilot not configured. Run 'soothe autopilot submit' to start.")
82
+ return
83
+
84
+ if state_file.exists():
85
+ import json
86
+
87
+ data = json.loads(state_file.read_text())
88
+ state = data.get("state", "unknown")
89
+ typer.echo(f"Autopilot state: {state}")
90
+
91
+ if "active_goals" in data:
92
+ typer.echo(f"Active goals: {len(data['active_goals'])}")
93
+ else:
94
+ typer.echo("Autopilot: idle (no status file)")
95
+
96
+ # Check inbox for pending tasks
97
+ inbox_dir = autopilot_dir / "inbox"
98
+ if inbox_dir.exists():
99
+ pending = list(inbox_dir.glob("*.md"))
100
+ if pending:
101
+ typer.echo(f"Pending inbox tasks: {len(pending)}")
102
+
103
+
104
+ @app.command("list")
105
+ def list_goals(
106
+ status_filter: str = typer.Option("", "--status", "-s", help="Filter by status."),
107
+ ) -> None:
108
+ """List all goals."""
109
+ from soothe_sdk import SOOTHE_HOME
110
+
111
+ autopilot_dir = SOOTHE_HOME / "autopilot"
112
+ goals = _discover_goals(autopilot_dir)
113
+
114
+ if not goals:
115
+ typer.echo("No goals found.")
116
+ return
117
+
118
+ for g in goals:
119
+ if status_filter and g.get("status", "") != status_filter:
120
+ continue
121
+ sid = g.get("id", "?")[:8]
122
+ sdesc = preview_first(g.get("description", ""), 60)
123
+ sstat = g.get("status", "pending")
124
+ spri = g.get("priority", 50)
125
+ typer.echo(f" [{sid}] {sstat:10s} pri={spri:3d} {sdesc}")
126
+
127
+
128
+ @app.command("goal")
129
+ def show_goal(
130
+ goal_id: str = typer.Argument(..., help="Goal ID to show details for."),
131
+ ) -> None:
132
+ """Show details for a specific goal."""
133
+ from soothe_sdk import SOOTHE_HOME
134
+
135
+ autopilot_dir = SOOTHE_HOME / "autopilot"
136
+ goals = _discover_goals(autopilot_dir)
137
+
138
+ found = None
139
+ for g in goals:
140
+ if g.get("id", "").startswith(goal_id) or goal_id in g.get("id", ""):
141
+ found = g
142
+ break
143
+
144
+ if not found:
145
+ typer.echo(f"Goal '{goal_id}' not found.")
146
+ raise typer.Exit(1)
147
+
148
+ typer.echo(f"ID: {found.get('id')}")
149
+ typer.echo(f"Description: {found.get('description')}")
150
+ typer.echo(f"Status: {found.get('status', 'pending')}")
151
+ typer.echo(f"Priority: {found.get('priority', 50)}")
152
+ if found.get("depends_on"):
153
+ typer.echo(f"Depends On: {', '.join(found['depends_on'])}")
154
+ if found.get("source_file"):
155
+ typer.echo(f"Source File: {found['source_file']}")
156
+
157
+
158
+ @app.command("cancel")
159
+ def cancel_goal(
160
+ goal_id: str = typer.Argument(..., help="Goal ID to cancel."),
161
+ ) -> None:
162
+ """Cancel a goal (remove from inbox if pending)."""
163
+ from soothe_sdk import SOOTHE_HOME
164
+
165
+ inbox_dir = SOOTHE_HOME / "autopilot" / "inbox"
166
+ if not inbox_dir.exists():
167
+ typer.echo("No inbox to cancel from.")
168
+ return
169
+
170
+ # Remove matching inbox file
171
+ removed = 0
172
+ for f in inbox_dir.glob("*.md"):
173
+ if goal_id in f.stem:
174
+ f.unlink()
175
+ removed += 1
176
+ typer.echo(f"Removed: {f.name}")
177
+
178
+ if removed == 0:
179
+ typer.echo(f"No matching inbox tasks for '{goal_id}'.")
180
+ else:
181
+ typer.echo(f"Cancelled {removed} task(s).")
182
+
183
+
184
+ @app.command("approve")
185
+ def approve_goal(
186
+ goal_id: str = typer.Argument(
187
+ ..., help="Confirmation ID to approve (use 'inbox' to list pending)."
188
+ ),
189
+ ) -> None:
190
+ """Approve a MUST-confirmation goal."""
191
+ import json
192
+
193
+ from soothe_sdk import SOOTHE_HOME
194
+
195
+ confirmations_file = SOOTHE_HOME / "autopilot" / "pending_confirmations.json"
196
+ if not confirmations_file.exists():
197
+ typer.echo("No pending goal confirmations.")
198
+ return
199
+
200
+ try:
201
+ confirmations = json.loads(confirmations_file.read_text())
202
+ except (json.JSONDecodeError, OSError):
203
+ typer.echo("Failed to read pending confirmations.")
204
+ return
205
+
206
+ for i, c in enumerate(confirmations):
207
+ if c.get("id") == goal_id:
208
+ c["status"] = "approved"
209
+ confirmations[i] = c
210
+ confirmations_file.write_text(json.dumps(confirmations, indent=2))
211
+ typer.echo(
212
+ f"Confirmation {goal_id} approved. Goal will be created on next runner poll."
213
+ )
214
+ return
215
+
216
+ typer.echo(f"Confirmation {goal_id} not found. Run 'soothe autopilot inbox' to list pending.")
217
+
218
+
219
+ @app.command("reject")
220
+ def reject_goal(
221
+ goal_id: str = typer.Argument(..., help="Confirmation ID to reject."),
222
+ ) -> None:
223
+ """Reject a proposed goal."""
224
+ import json
225
+
226
+ from soothe_sdk import SOOTHE_HOME
227
+
228
+ confirmations_file = SOOTHE_HOME / "autopilot" / "pending_confirmations.json"
229
+ if not confirmations_file.exists():
230
+ typer.echo("No pending goal confirmations.")
231
+ return
232
+
233
+ try:
234
+ confirmations = json.loads(confirmations_file.read_text())
235
+ except (json.JSONDecodeError, OSError):
236
+ typer.echo("Failed to read pending confirmations.")
237
+ return
238
+
239
+ remaining = [c for c in confirmations if c.get("id") != goal_id]
240
+ if len(remaining) == len(confirmations):
241
+ typer.echo(f"Confirmation {goal_id} not found.")
242
+ return
243
+
244
+ confirmations_file.write_text(json.dumps(remaining, indent=2))
245
+ typer.echo(f"Confirmation {goal_id} rejected.")
246
+
247
+
248
+ @app.command("wake")
249
+ def wake() -> None:
250
+ """Exit dreaming mode — resume active execution."""
251
+ from soothe_sdk import SOOTHE_HOME
252
+
253
+ inbox_dir = SOOTHE_HOME / "autopilot" / "inbox"
254
+ inbox_dir.mkdir(parents=True, exist_ok=True)
255
+
256
+ signal = inbox_dir / "WAKE.md"
257
+ signal.write_text("---\ntype: signal_resume\n---\n\nWake signal.\n")
258
+ typer.echo("Wake signal sent. Autopilot will exit dreaming mode.")
259
+
260
+
261
+ @app.command("dream")
262
+ def dream() -> None:
263
+ """Force enter dreaming mode."""
264
+ from soothe_sdk import SOOTHE_HOME
265
+
266
+ inbox_dir = SOOTHE_HOME / "autopilot" / "inbox"
267
+ inbox_dir.mkdir(parents=True, exist_ok=True)
268
+
269
+ signal = inbox_dir / "DREAM.md"
270
+ signal.write_text("---\ntype: signal_interrupt\n---\n\nDream signal.\n")
271
+ typer.echo("Dream signal sent. Autopilot will enter dreaming mode.")
272
+
273
+
274
+ @app.command("inbox")
275
+ def view_inbox(
276
+ limit: int = typer.Option(10, "--limit", "-n", help="Max tasks to show."),
277
+ ) -> None:
278
+ """View pending inbox tasks."""
279
+ from soothe_sdk import SOOTHE_HOME
280
+
281
+ inbox_dir = SOOTHE_HOME / "autopilot" / "inbox"
282
+ if not inbox_dir.exists():
283
+ typer.echo("Inbox is empty.")
284
+ return
285
+
286
+ tasks = sorted(inbox_dir.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True)
287
+ if not tasks:
288
+ typer.echo("Inbox is empty.")
289
+ return
290
+
291
+ typer.echo(f"Pending tasks ({len(tasks)}):")
292
+ for f in tasks[:limit]:
293
+ content = f.read_text()
294
+ # Extract description from body
295
+ desc = ""
296
+ for line in content.splitlines():
297
+ stripped = line.strip()
298
+ if stripped.startswith("# "):
299
+ desc = stripped[2:]
300
+ break
301
+ if stripped.startswith(("--", "type:", "priority:")):
302
+ continue
303
+ if stripped:
304
+ desc = preview_first(stripped, 80)
305
+ break
306
+ if not desc:
307
+ desc = "(no description)"
308
+ typer.echo(f" {f.name:30s} {desc}")
309
+
310
+ if len(tasks) > limit:
311
+ typer.echo(f" ... and {len(tasks) - limit} more")
312
+
313
+
314
+ def _discover_goals(autopilot_dir: Path) -> list[dict]:
315
+ """Parse goals from GOAL.md/GOALS.md files for CLI display.
316
+
317
+ This is a simple parser for CLI use — not the full engine.
318
+ """
319
+ import re
320
+
321
+ goals = []
322
+
323
+ # Check GOAL.md
324
+ goal_file = autopilot_dir / "GOAL.md"
325
+ if goal_file.exists():
326
+ g = _parse_single_goal(goal_file.read_text(), str(goal_file))
327
+ if g:
328
+ return [g]
329
+
330
+ # Check GOALS.md
331
+ goals_file = autopilot_dir / "GOALS.md"
332
+ if goals_file.exists():
333
+ text = goals_file.read_text()
334
+ for section in re.split(r"## Goal:", text)[1:]:
335
+ g = _parse_goals_section(section.strip(), str(goals_file))
336
+ if g:
337
+ goals.append(g)
338
+
339
+ # Check goals/ subdirectories
340
+ goals_dir = autopilot_dir / "goals"
341
+ if goals_dir.exists():
342
+ for subdir in sorted(goals_dir.iterdir()):
343
+ gfile = subdir / "GOAL.md"
344
+ if gfile.exists():
345
+ g = _parse_single_goal(gfile.read_text(), str(gfile))
346
+ if g:
347
+ goals.append(g)
348
+
349
+ return goals
350
+
351
+
352
+ def _parse_single_goal(text: str, source: str) -> dict | None:
353
+ """Parse a single GOAL.md file."""
354
+ if not text.startswith("---"):
355
+ return None
356
+
357
+ parts = text.split("---", 2)
358
+ if len(parts) < 3: # noqa: PLR2004
359
+ return None
360
+
361
+ import yaml
362
+
363
+ fm = yaml.safe_load(parts[1]) or {}
364
+ body = parts[2].strip()
365
+
366
+ desc = ""
367
+ for line in body.splitlines():
368
+ s = line.strip()
369
+ if s.startswith("# "):
370
+ desc = s[2:]
371
+ break
372
+
373
+ return {
374
+ "id": fm.get("id", source.split("/")[-2]),
375
+ "description": desc or preview_first(body, 100),
376
+ "priority": int(fm.get("priority", 50)),
377
+ "status": fm.get("status", "pending"),
378
+ "depends_on": fm.get("depends_on", []),
379
+ "source_file": source,
380
+ }
381
+
382
+
383
+ def _parse_goals_section(text: str, source: str) -> dict | None:
384
+ """Parse a single goal section from GOALS.md."""
385
+ lines = text.splitlines()
386
+ name = lines[0].strip() if lines else ""
387
+ metadata: dict = {}
388
+
389
+ for line in lines[1:]:
390
+ s = line.strip()
391
+ if s.startswith("- id:"):
392
+ metadata["id"] = s.split(":", 1)[1].strip()
393
+ elif s.startswith("- priority:"):
394
+ metadata["priority"] = int(s.split(":", 1)[1].strip())
395
+ elif s.startswith("- depends_on:"):
396
+ raw = s.split(":", 1)[1].strip()
397
+ if raw.startswith("[") and raw.endswith("]"):
398
+ inner = raw[1:-1].strip()
399
+ metadata["depends_on"] = (
400
+ [x.strip() for x in inner.split(",") if x.strip()] if inner else []
401
+ )
402
+
403
+ return {
404
+ "id": metadata.get("id", name.lower().replace(" ", "-")),
405
+ "description": name,
406
+ "priority": metadata.get("priority", 50),
407
+ "status": "pending",
408
+ "depends_on": metadata.get("depends_on", []),
409
+ "source_file": source,
410
+ }
@@ -0,0 +1,277 @@
1
+ """Config command for Soothe CLI."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from soothe_cli.shared import load_config
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def config_show(
17
+ config: Annotated[
18
+ str | None,
19
+ typer.Option("--config", "-c", help="Path to configuration file."),
20
+ ] = None,
21
+ format_output: Annotated[
22
+ str,
23
+ typer.Option("--format", "-f", help="Output format: json or summary."),
24
+ ] = "summary",
25
+ show_sensitive: Annotated[ # noqa: ARG001, FBT002
26
+ bool,
27
+ typer.Option("--show-sensitive", "-s", help="Show sensitive values like API keys."),
28
+ ] = False,
29
+ ) -> None:
30
+ """Display current configuration.
31
+
32
+ Examples:
33
+ soothe config show
34
+ soothe config show --show-sensitive
35
+ soothe config show --format json
36
+ """
37
+ try:
38
+ cfg = load_config(config)
39
+
40
+ if format_output == "json":
41
+ # Output full config as JSON
42
+ config_dict = cfg.model_dump(mode="python", exclude_unset=True)
43
+ # Note: show_sensitive parameter reserved for future use
44
+ typer.echo(json.dumps(config_dict, indent=2, default=str))
45
+ else:
46
+ # Summary output
47
+ from rich.console import Console
48
+ from rich.panel import Panel
49
+ from rich.table import Table
50
+
51
+ # Providers summary
52
+ providers_table = Table(title="Model Providers")
53
+ providers_table.add_column("Name", style="cyan")
54
+ providers_table.add_column("Models", style="yellow")
55
+ providers_table.add_column("Default", justify="center")
56
+
57
+ for provider in cfg.providers:
58
+ model_count = len(provider.models)
59
+ providers_table.add_row(
60
+ provider.name,
61
+ f"{model_count} models",
62
+ "✓" if cfg.router.default.startswith(f"{provider.name}:") else "",
63
+ )
64
+
65
+ if not cfg.providers:
66
+ providers_table.add_row("None configured", "", "")
67
+
68
+ # Subagents summary
69
+ from soothe_cli.cli.commands.subagent_names import (
70
+ BUILTIN_SUBAGENT_NAMES,
71
+ SUBAGENT_DISPLAY_NAMES,
72
+ )
73
+
74
+ subagents_table = Table(title="Agents")
75
+ subagents_table.add_column("Name", style="cyan")
76
+ subagents_table.add_column("Status", justify="center")
77
+
78
+ for subagent_id in BUILTIN_SUBAGENT_NAMES:
79
+ display_name = SUBAGENT_DISPLAY_NAMES.get(
80
+ subagent_id, subagent_id.replace("_", " ").title()
81
+ )
82
+ enabled = True
83
+ if subagent_id in cfg.subagents:
84
+ enabled = cfg.subagents[subagent_id].enabled
85
+ status = "[green]Enabled[/green]" if enabled else "[red]Disabled[/red]"
86
+ subagents_table.add_row(display_name, status)
87
+
88
+ # General info
89
+ general_table = Table(title="General Configuration")
90
+ general_table.add_column("Setting", style="cyan")
91
+ general_table.add_column("Value", style="yellow")
92
+ general_table.add_row(
93
+ "Debug Mode", "[green]Yes[/green]" if cfg.debug else "[red]No[/red]"
94
+ )
95
+ general_table.add_row("Context Backend", cfg.protocols.context.backend.title())
96
+ # MemU is the memory backend type (no separate backend attribute)
97
+ general_table.add_row(
98
+ "Memory Backend", "MemU" if cfg.protocols.memory.enabled else "Disabled"
99
+ )
100
+ general_table.add_row("Policy Profile", cfg.protocols.policy.profile)
101
+ general_table.add_row("Progress Verbosity", cfg.logging.verbosity)
102
+ # Show vector store providers count
103
+ vs_count = len(cfg.vector_stores)
104
+ general_table.add_row("Vector Store Providers", f"{vs_count} configured")
105
+
106
+ console = Console()
107
+ console.print(Panel(providers_table, border_style="blue"))
108
+ console.print(Panel(subagents_table, border_style="blue"))
109
+ console.print(Panel(general_table, border_style="blue"))
110
+
111
+ except KeyboardInterrupt:
112
+ typer.echo("\nInterrupted.")
113
+ sys.exit(0)
114
+ except Exception as e:
115
+ logger.exception("Config command error")
116
+ from soothe_sdk import format_cli_error
117
+
118
+ typer.echo(f"Error: {format_cli_error(e)}", err=True)
119
+ sys.exit(1)
120
+
121
+
122
+ def config_init(
123
+ force: Annotated[ # noqa: FBT002
124
+ bool,
125
+ typer.Option(
126
+ "--force", "-f", help="Overwrite existing configuration without confirmation."
127
+ ),
128
+ ] = False,
129
+ ) -> None:
130
+ """Initialize ~/.soothe with a default configuration.
131
+
132
+ Examples:
133
+ soothe config init
134
+ soothe config init --force # Overwrite existing without confirmation
135
+ """
136
+ import shutil
137
+ from importlib.resources import as_file, files
138
+ from pathlib import Path
139
+
140
+ from soothe_sdk import SOOTHE_HOME
141
+
142
+ home = Path(SOOTHE_HOME).expanduser()
143
+ target = home / "config" / "config.yml"
144
+
145
+ # Check if config file exists and ask for confirmation
146
+ if target.exists() and not force:
147
+ typer.echo(f"Config file already exists at: {target}")
148
+ overwrite = typer.confirm("Do you want to overwrite it?", default=False)
149
+ if not overwrite:
150
+ typer.echo("Cancelled.")
151
+ return
152
+
153
+ target.parent.mkdir(parents=True, exist_ok=True)
154
+
155
+ # Try loading from installed package resources first
156
+ template_found = False
157
+ try:
158
+ config_resource = files("soothe_sdk").joinpath("templates/config.yml")
159
+ with as_file(config_resource) as template_path:
160
+ if template_path.exists():
161
+ shutil.copy2(template_path, target)
162
+ typer.echo(f"Created {target}")
163
+ template_found = True
164
+ except (FileNotFoundError, TypeError, AttributeError, ModuleNotFoundError):
165
+ pass
166
+
167
+ # Fallback: try daemon package (co-installed on server)
168
+ if not template_found:
169
+ try:
170
+ config_resource = files("soothe.config").joinpath("config.yml")
171
+ with as_file(config_resource) as template_path:
172
+ if template_path.exists():
173
+ shutil.copy2(template_path, target)
174
+ typer.echo(f"Created {target}")
175
+ template_found = True
176
+ except (FileNotFoundError, TypeError, AttributeError, ModuleNotFoundError):
177
+ pass
178
+
179
+ # Fallback for development/editable installs
180
+ if not template_found:
181
+ template = Path(__file__).resolve().parent.parent.parent.parent / "config" / "config.yml"
182
+ if template.exists():
183
+ shutil.copy2(template, target)
184
+ typer.echo(f"Created {target}")
185
+ template_found = True
186
+
187
+ # Create minimal config if template not found
188
+ if not template_found:
189
+ target.write_text("# Soothe configuration\n# See docs/user_guide.md for options\n")
190
+ typer.echo(f"Created minimal {target}")
191
+
192
+ for subdir in ("runs", "generated_agents", "logs"):
193
+ (home / subdir).mkdir(parents=True, exist_ok=True)
194
+
195
+ typer.echo(f"Soothe home initialized at {home}")
196
+
197
+
198
+ def config_validate(
199
+ config: Annotated[
200
+ str | None,
201
+ typer.Option("--config", "-c", help="Path to configuration file."),
202
+ ] = None,
203
+ ) -> None:
204
+ """Validate configuration file and show basic info.
205
+
206
+ Examples:
207
+ soothe config validate
208
+ soothe config validate --config custom.yml
209
+ """
210
+ from pathlib import Path
211
+
212
+ from soothe_sdk import SOOTHE_HOME
213
+
214
+ try:
215
+ cfg = load_config(config)
216
+
217
+ # Determine config file path
218
+ if config:
219
+ config_path = Path(config).expanduser().resolve()
220
+ else:
221
+ config_path = Path(SOOTHE_HOME).expanduser() / "config" / "config.yml"
222
+
223
+ # Show basic information
224
+ typer.echo(f"\nConfig file: {config_path}")
225
+ typer.echo("Status: ✓ Valid\n")
226
+
227
+ # Show default model provider info
228
+ typer.echo("Default Model Configuration:")
229
+ default_model = cfg.router.default
230
+ provider_name, model_name = (
231
+ default_model.split(":", 1) if ":" in default_model else (default_model, "default")
232
+ )
233
+ typer.echo(f" Provider: {provider_name}")
234
+ typer.echo(f" Model: {model_name}")
235
+
236
+ # Show available providers
237
+ if cfg.providers:
238
+ typer.echo(f"\nConfigured Providers: {len(cfg.providers)}")
239
+ for provider in cfg.providers:
240
+ model_count = len(provider.models)
241
+ is_default = default_model.startswith(f"{provider.name}:")
242
+ default_marker = " (default)" if is_default else ""
243
+ typer.echo(f" • {provider.name}: {model_count} models{default_marker}")
244
+ else:
245
+ typer.echo("\nNo custom providers configured (using defaults)")
246
+
247
+ # Show enabled subagents count
248
+ enabled_subagents = sum(1 for s in cfg.subagents.values() if s.enabled)
249
+ typer.echo(f"\nSubagents: {enabled_subagents} enabled")
250
+
251
+ # Show LangSmith tracing status
252
+ typer.echo("\nObservability:")
253
+ langsmith_tracing = os.getenv("LANGSMITH_TRACING", "").lower()
254
+ langchain_tracing = os.getenv("LANGCHAIN_TRACING_V2", "").lower()
255
+ langsmith_api_key = os.getenv("LANGSMITH_API_KEY")
256
+ langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
257
+ langsmith_project = os.getenv("LANGSMITH_PROJECT")
258
+ langchain_project = os.getenv("LANGCHAIN_PROJECT")
259
+
260
+ tracing_enabled = langsmith_tracing == "true" or langchain_tracing == "true"
261
+ has_api_key = bool(langsmith_api_key or langchain_api_key)
262
+
263
+ if tracing_enabled and has_api_key:
264
+ project_name = langsmith_project or langchain_project or "default"
265
+ typer.echo(f" LangSmith: ✓ Enabled (project: {project_name})")
266
+ elif tracing_enabled and not has_api_key:
267
+ typer.echo(" LangSmith: ⚠ Tracing enabled but API key missing", err=True)
268
+ elif has_api_key and not tracing_enabled:
269
+ typer.echo(" LangSmith: ○ API key found but tracing disabled")
270
+ else:
271
+ typer.echo(" LangSmith: ○ Not configured")
272
+
273
+ typer.echo() # Blank line at end
274
+
275
+ except Exception as e:
276
+ typer.echo(f"\n✗ Configuration error: {e}", err=True)
277
+ sys.exit(1)