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
soothe_cli/__init__.py
ADDED
|
@@ -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)
|