claude-supervisor 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.
- claude_supervisor/__init__.py +15 -0
- claude_supervisor/cli/__init__.py +7 -0
- claude_supervisor/cli/app.py +386 -0
- claude_supervisor/config/__init__.py +39 -0
- claude_supervisor/config/loader.py +160 -0
- claude_supervisor/config/models.py +227 -0
- claude_supervisor/core/__init__.py +9 -0
- claude_supervisor/core/stats.py +47 -0
- claude_supervisor/core/supervisor.py +288 -0
- claude_supervisor/core/transcript.py +62 -0
- claude_supervisor/logging/__init__.py +7 -0
- claude_supervisor/logging/setup.py +97 -0
- claude_supervisor/parser/__init__.py +31 -0
- claude_supervisor/parser/events.py +45 -0
- claude_supervisor/parser/parser.py +99 -0
- claude_supervisor/parser/patterns.py +194 -0
- claude_supervisor/parser/reset_time.py +115 -0
- claude_supervisor/parser/rules/claude.yaml +65 -0
- claude_supervisor/permissions/__init__.py +15 -0
- claude_supervisor/permissions/engine.py +90 -0
- claude_supervisor/py.typed +0 -0
- claude_supervisor/resume/__init__.py +14 -0
- claude_supervisor/resume/clock.py +95 -0
- claude_supervisor/resume/planner.py +71 -0
- claude_supervisor/session/__init__.py +12 -0
- claude_supervisor/session/manager.py +95 -0
- claude_supervisor/state_machine/__init__.py +19 -0
- claude_supervisor/state_machine/machine.py +114 -0
- claude_supervisor/state_machine/states.py +62 -0
- claude_supervisor/storage/__init__.py +26 -0
- claude_supervisor/storage/base.py +136 -0
- claude_supervisor/storage/sqlite.py +224 -0
- claude_supervisor/terminal/__init__.py +26 -0
- claude_supervisor/terminal/backends.py +153 -0
- claude_supervisor/terminal/base.py +153 -0
- claude_supervisor/terminal/factory.py +46 -0
- claude_supervisor/terminal/threaded.py +134 -0
- claude_supervisor-0.1.0.dist-info/METADATA +302 -0
- claude_supervisor-0.1.0.dist-info/RECORD +42 -0
- claude_supervisor-0.1.0.dist-info/WHEEL +4 -0
- claude_supervisor-0.1.0.dist-info/entry_points.txt +2 -0
- claude_supervisor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Claude Supervisor.
|
|
2
|
+
|
|
3
|
+
A safe, human-in-control companion for Claude Code. It waits for legitimate
|
|
4
|
+
usage resets, resumes the user's existing session, and can optionally automate
|
|
5
|
+
repetitive permission prompts for the *currently active* task only.
|
|
6
|
+
|
|
7
|
+
It never bypasses usage limits, authentication, or subscription requirements,
|
|
8
|
+
and it never starts new work on its own.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Typer application exposing the supervisor's commands.
|
|
2
|
+
|
|
3
|
+
Commands: ``version``, ``config``, ``doctor`` (diagnostics), ``start`` /
|
|
4
|
+
``resume`` (supervise a session), and ``status`` / ``logs`` (inspect recorded
|
|
5
|
+
sessions, statistics, and the log tail).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
from claude_supervisor import __version__
|
|
25
|
+
from claude_supervisor.config import (
|
|
26
|
+
ConfigError,
|
|
27
|
+
PermissionMode,
|
|
28
|
+
SupervisorConfig,
|
|
29
|
+
default_config_path,
|
|
30
|
+
effective_database,
|
|
31
|
+
effective_log_file,
|
|
32
|
+
load_config,
|
|
33
|
+
starter_config,
|
|
34
|
+
)
|
|
35
|
+
from claude_supervisor.core import RunStats, Supervisor, TranscriptWriter
|
|
36
|
+
from claude_supervisor.logging import configure_logging
|
|
37
|
+
from claude_supervisor.parser.patterns import PatternSetError, load_pattern_set
|
|
38
|
+
from claude_supervisor.session import SessionManager
|
|
39
|
+
from claude_supervisor.storage import SqliteStorage
|
|
40
|
+
from claude_supervisor.terminal import TerminalError, terminal_factory
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
name="claude-supervisor",
|
|
44
|
+
help="Safe, human-in-control companion for Claude Code.",
|
|
45
|
+
no_args_is_help=True,
|
|
46
|
+
add_completion=True,
|
|
47
|
+
)
|
|
48
|
+
_console = Console()
|
|
49
|
+
|
|
50
|
+
_MIN_PYTHON = (3, 12)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _config_option() -> Any:
|
|
54
|
+
"""Return a reusable ``--config`` Typer option (an ``OptionInfo``)."""
|
|
55
|
+
return typer.Option(
|
|
56
|
+
None,
|
|
57
|
+
"--config",
|
|
58
|
+
"-c",
|
|
59
|
+
help="Path to config.yaml (defaults to the per-user location).",
|
|
60
|
+
show_default=False,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require_config_exists(config_path: Path | None) -> None:
|
|
65
|
+
"""Error out if an *explicitly provided* config file does not exist.
|
|
66
|
+
|
|
67
|
+
A missing default location is fine (defaults are used); a missing path the
|
|
68
|
+
user typed is almost always a mistake, so we surface it instead of silently
|
|
69
|
+
ignoring their config.
|
|
70
|
+
"""
|
|
71
|
+
if config_path is not None and not config_path.exists():
|
|
72
|
+
_console.print(f"[red]Config error:[/red] file not found: {config_path}")
|
|
73
|
+
raise typer.Exit(code=1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_config(config_path: Path | None) -> SupervisorConfig:
|
|
77
|
+
"""Load config, exiting with a clear message on a missing or invalid file."""
|
|
78
|
+
_require_config_exists(config_path)
|
|
79
|
+
try:
|
|
80
|
+
return load_config(config_path)
|
|
81
|
+
except ConfigError as exc:
|
|
82
|
+
_console.print(f"[red]Config error:[/red] {exc}")
|
|
83
|
+
raise typer.Exit(code=1) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def version() -> None:
|
|
88
|
+
"""Print the installed version."""
|
|
89
|
+
_console.print(f"claude-supervisor {__version__}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def init(
|
|
94
|
+
config_path: Path | None = _config_option(),
|
|
95
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Write a starter config file with sensible, validated defaults."""
|
|
98
|
+
target = Path(config_path) if config_path is not None else default_config_path()
|
|
99
|
+
if target.exists() and not force:
|
|
100
|
+
_console.print(
|
|
101
|
+
f"[yellow]Config already exists at {target}[/yellow] — use --force to overwrite."
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
target.write_text(starter_config(), encoding="utf-8")
|
|
106
|
+
_console.print(f"[green]Wrote starter config to {target}[/green]")
|
|
107
|
+
_console.print("Edit it if your Claude setup differs, then run: claude-supervisor doctor")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def config(config_path: Path | None = _config_option()) -> None:
|
|
112
|
+
"""Show the effective configuration (defaults merged with your file)."""
|
|
113
|
+
cfg = _load_config(config_path)
|
|
114
|
+
source = config_path or default_config_path()
|
|
115
|
+
exists = Path(source).exists()
|
|
116
|
+
_console.print(
|
|
117
|
+
f"[bold]Source:[/bold] {source} {'' if exists else '(not found; using defaults)'}"
|
|
118
|
+
)
|
|
119
|
+
_console.print_json(cfg.model_dump_json(indent=2))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def doctor(config_path: Path | None = _config_option()) -> None:
|
|
124
|
+
"""Run environment and configuration health checks."""
|
|
125
|
+
table = Table(title="claude-supervisor doctor", show_lines=False)
|
|
126
|
+
table.add_column("Check", style="bold")
|
|
127
|
+
table.add_column("Result")
|
|
128
|
+
table.add_column("Detail", overflow="fold")
|
|
129
|
+
|
|
130
|
+
ok = True
|
|
131
|
+
_require_config_exists(config_path)
|
|
132
|
+
|
|
133
|
+
py_ok = sys.version_info[:2] >= _MIN_PYTHON
|
|
134
|
+
ok &= py_ok
|
|
135
|
+
table.add_row(
|
|
136
|
+
"Python >= 3.12",
|
|
137
|
+
_status(py_ok),
|
|
138
|
+
platform.python_version(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Config loads?
|
|
142
|
+
try:
|
|
143
|
+
cfg = load_config(config_path)
|
|
144
|
+
table.add_row("Config loads", _status(True), str(config_path or default_config_path()))
|
|
145
|
+
except ConfigError as exc:
|
|
146
|
+
ok = False
|
|
147
|
+
table.add_row("Config loads", _status(False), str(exc).splitlines()[0])
|
|
148
|
+
cfg = None
|
|
149
|
+
|
|
150
|
+
# Pattern rules compile?
|
|
151
|
+
rules_path = None
|
|
152
|
+
if cfg is not None and cfg.paths.pattern_rules is not None:
|
|
153
|
+
rules_path = cfg.paths.pattern_rules
|
|
154
|
+
try:
|
|
155
|
+
pattern_set = load_pattern_set(rules_path)
|
|
156
|
+
table.add_row(
|
|
157
|
+
"Parser rules compile",
|
|
158
|
+
_status(True),
|
|
159
|
+
f"{len(pattern_set)} patterns (v{pattern_set.version})",
|
|
160
|
+
)
|
|
161
|
+
except PatternSetError as exc:
|
|
162
|
+
ok = False
|
|
163
|
+
table.add_row("Parser rules compile", _status(False), str(exc).splitlines()[0])
|
|
164
|
+
|
|
165
|
+
# Claude CLI present? Informational — the tool itself is healthy without it,
|
|
166
|
+
# but you need it to actually supervise anything.
|
|
167
|
+
claude_exe = cfg.claude_command[0] if cfg is not None else "claude"
|
|
168
|
+
resolved = shutil.which(claude_exe)
|
|
169
|
+
if resolved:
|
|
170
|
+
table.add_row("Claude CLI on PATH", _status(True), resolved)
|
|
171
|
+
else:
|
|
172
|
+
table.add_row(
|
|
173
|
+
"Claude CLI on PATH",
|
|
174
|
+
"[yellow]MISSING[/yellow]",
|
|
175
|
+
f"'{claude_exe}' not found — install: npm install -g @anthropic-ai/claude-code",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
_console.print(table)
|
|
179
|
+
if not ok:
|
|
180
|
+
raise typer.Exit(code=1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_stats(stats: RunStats) -> None:
|
|
184
|
+
table = Table(title="run summary", show_header=False)
|
|
185
|
+
table.add_column("Field", style="bold")
|
|
186
|
+
table.add_column("Value")
|
|
187
|
+
for key, value in stats.as_dict().items():
|
|
188
|
+
table.add_row(key, str(value))
|
|
189
|
+
_console.print(table)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _run_supervisor(
|
|
193
|
+
config: SupervisorConfig,
|
|
194
|
+
argv: Sequence[str],
|
|
195
|
+
*,
|
|
196
|
+
task: str | None = None,
|
|
197
|
+
capture: Path | None = None,
|
|
198
|
+
) -> RunStats:
|
|
199
|
+
"""Set up logging + persistence + signals, run the supervisor, return stats."""
|
|
200
|
+
configure_logging(config.logging, log_file=effective_log_file(config), force=True)
|
|
201
|
+
factory = terminal_factory(cwd=os.getcwd())
|
|
202
|
+
|
|
203
|
+
transcript = TranscriptWriter(capture) if capture is not None else None
|
|
204
|
+
supervisor = Supervisor(config, factory, on_line=transcript)
|
|
205
|
+
|
|
206
|
+
storage = SqliteStorage(effective_database(config))
|
|
207
|
+
manager = SessionManager(storage)
|
|
208
|
+
session_id = manager.begin(
|
|
209
|
+
argv, started_at=supervisor.stats.started_at, machine=supervisor.machine
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
previous = signal.getsignal(signal.SIGINT)
|
|
213
|
+
|
|
214
|
+
def _on_sigint(_signum: int, _frame: object) -> None:
|
|
215
|
+
supervisor.request_stop("keyboard interrupt (SIGINT)")
|
|
216
|
+
|
|
217
|
+
signal.signal(signal.SIGINT, _on_sigint)
|
|
218
|
+
try:
|
|
219
|
+
return supervisor.run(argv, task=task)
|
|
220
|
+
finally:
|
|
221
|
+
signal.signal(signal.SIGINT, previous)
|
|
222
|
+
manager.end(session_id, supervisor.stats, supervisor.machine.state)
|
|
223
|
+
storage.close()
|
|
224
|
+
if transcript is not None:
|
|
225
|
+
transcript.close()
|
|
226
|
+
_console.print(f"[dim]Transcript written to {transcript.path}[/dim]")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command()
|
|
230
|
+
def start(
|
|
231
|
+
task: str | None = typer.Option(
|
|
232
|
+
None, "--task", "-t", help="Task/prompt to run unattended.", show_default=False
|
|
233
|
+
),
|
|
234
|
+
auto_approve: bool = typer.Option(
|
|
235
|
+
False,
|
|
236
|
+
"--auto-approve",
|
|
237
|
+
help="Auto-answer permission prompts for this run (active-task scope).",
|
|
238
|
+
),
|
|
239
|
+
capture: Path | None = typer.Option(
|
|
240
|
+
None,
|
|
241
|
+
"--capture",
|
|
242
|
+
help="Write a transcript of Claude's output + detected events to this file.",
|
|
243
|
+
show_default=False,
|
|
244
|
+
),
|
|
245
|
+
extra_args: list[str] | None = typer.Argument(
|
|
246
|
+
None, help="Extra arguments appended to the configured claude command."
|
|
247
|
+
),
|
|
248
|
+
config_path: Path | None = _config_option(),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Launch and supervise a Claude Code session (optionally an unattended task)."""
|
|
251
|
+
config = _load_config(config_path)
|
|
252
|
+
|
|
253
|
+
if auto_approve:
|
|
254
|
+
config = config.model_copy(
|
|
255
|
+
update={
|
|
256
|
+
"auto_permissions": True,
|
|
257
|
+
"permission_mode": PermissionMode.ACTIVE_TASK_ONLY,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
argv = [*config.claude_command, *(extra_args or [])]
|
|
262
|
+
try:
|
|
263
|
+
stats = _run_supervisor(config, argv, task=task, capture=capture)
|
|
264
|
+
except TerminalError as exc:
|
|
265
|
+
_console.print(f"[red]Terminal error:[/red] {exc}")
|
|
266
|
+
raise typer.Exit(code=1) from exc
|
|
267
|
+
_render_stats(stats)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def resume(config_path: Path | None = _config_option()) -> None:
|
|
272
|
+
"""Resume an existing Claude Code session (waiting for a reset if needed)."""
|
|
273
|
+
config = _load_config(config_path)
|
|
274
|
+
try:
|
|
275
|
+
stats = _run_supervisor(config, config.resume_command)
|
|
276
|
+
except TerminalError as exc:
|
|
277
|
+
_console.print(f"[red]Terminal error:[/red] {exc}")
|
|
278
|
+
raise typer.Exit(code=1) from exc
|
|
279
|
+
_render_stats(stats)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command()
|
|
283
|
+
def status(config_path: Path | None = _config_option()) -> None:
|
|
284
|
+
"""Show the latest session and aggregate statistics."""
|
|
285
|
+
config = _load_config(config_path)
|
|
286
|
+
|
|
287
|
+
database = effective_database(config)
|
|
288
|
+
if not database.exists():
|
|
289
|
+
_console.print("No sessions recorded yet.")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
with SqliteStorage(database) as storage:
|
|
293
|
+
manager = SessionManager(storage)
|
|
294
|
+
latest = manager.latest()
|
|
295
|
+
stats = manager.statistics()
|
|
296
|
+
|
|
297
|
+
if latest is None:
|
|
298
|
+
_console.print("No sessions recorded yet.")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
session_table = Table(title="latest session", show_header=False)
|
|
302
|
+
session_table.add_column("Field", style="bold")
|
|
303
|
+
session_table.add_column("Value", overflow="fold")
|
|
304
|
+
session_table.add_row("id", str(latest.id))
|
|
305
|
+
session_table.add_row("command", " ".join(latest.command))
|
|
306
|
+
session_table.add_row("started_at", latest.started_at)
|
|
307
|
+
session_table.add_row("final_state", latest.final_state or "(running)")
|
|
308
|
+
session_table.add_row("completed", str(latest.completed))
|
|
309
|
+
session_table.add_row("resumes", str(latest.resumes))
|
|
310
|
+
session_table.add_row("approvals", str(latest.approvals))
|
|
311
|
+
session_table.add_row("stop_reason", latest.stop_reason or "-")
|
|
312
|
+
if latest.error:
|
|
313
|
+
session_table.add_row("error", latest.error)
|
|
314
|
+
_console.print(session_table)
|
|
315
|
+
|
|
316
|
+
stats_table = Table(title="statistics (all sessions)", show_header=False)
|
|
317
|
+
stats_table.add_column("Metric", style="bold")
|
|
318
|
+
stats_table.add_column("Value")
|
|
319
|
+
for key, value in stats.as_dict().items():
|
|
320
|
+
stats_table.add_row(key, str(value))
|
|
321
|
+
_console.print(stats_table)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command()
|
|
325
|
+
def logs(
|
|
326
|
+
lines: int = typer.Option(40, "--lines", "-n", help="Number of trailing log lines to show."),
|
|
327
|
+
config_path: Path | None = _config_option(),
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Show the tail of the supervisor log file."""
|
|
330
|
+
config = _load_config(config_path)
|
|
331
|
+
log_file = effective_log_file(config)
|
|
332
|
+
if not log_file.exists():
|
|
333
|
+
_console.print(f"No log file yet at {log_file}")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
content = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
337
|
+
tail = content[-lines:] if lines > 0 else content
|
|
338
|
+
_console.print("\n".join(tail))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command()
|
|
342
|
+
def statusline() -> None:
|
|
343
|
+
"""Emit a one-line status summary for Claude Code's status line.
|
|
344
|
+
|
|
345
|
+
Designed to be wired into Claude Code's ``statusLine`` setting. It reads the
|
|
346
|
+
session database and prints a single plain-text line, and is deliberately
|
|
347
|
+
defensive: any error yields a minimal line rather than a traceback, so it can
|
|
348
|
+
never disrupt the Claude Code UI.
|
|
349
|
+
"""
|
|
350
|
+
# The line renders inside another program's UI (often UTF-8), but may also be
|
|
351
|
+
# printed to a legacy console (cp1252 on Windows). Force UTF-8 with replacement
|
|
352
|
+
# so the emoji never raises UnicodeEncodeError.
|
|
353
|
+
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
|
354
|
+
if reconfigure is not None:
|
|
355
|
+
with contextlib.suppress(ValueError, OSError):
|
|
356
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
config = load_config(None)
|
|
360
|
+
database = effective_database(config)
|
|
361
|
+
if not database.exists():
|
|
362
|
+
typer.echo("🛡 claude-supervisor · no runs yet")
|
|
363
|
+
return
|
|
364
|
+
with SqliteStorage(database) as storage:
|
|
365
|
+
stats = storage.statistics()
|
|
366
|
+
parts = [f"{stats.total_sessions} run" + ("s" if stats.total_sessions != 1 else "")]
|
|
367
|
+
if stats.resumes:
|
|
368
|
+
parts.append(f"{stats.resumes} resume" + ("s" if stats.resumes != 1 else ""))
|
|
369
|
+
if stats.hours_saved >= 0.05:
|
|
370
|
+
parts.append(f"{stats.hours_saved:.1f}h saved")
|
|
371
|
+
typer.echo("🛡 " + " · ".join(parts))
|
|
372
|
+
except Exception: # pragma: no cover - never break the host UI
|
|
373
|
+
typer.echo("🛡 claude-supervisor")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _status(ok: bool) -> str:
|
|
377
|
+
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main() -> None:
|
|
381
|
+
"""Console-script entry point."""
|
|
382
|
+
app()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__": # pragma: no cover
|
|
386
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Configuration subsystem: typed, validated settings loaded from YAML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from claude_supervisor.config.loader import (
|
|
6
|
+
ConfigError,
|
|
7
|
+
default_config_path,
|
|
8
|
+
dump_config,
|
|
9
|
+
effective_database,
|
|
10
|
+
effective_log_file,
|
|
11
|
+
effective_state_dir,
|
|
12
|
+
load_config,
|
|
13
|
+
starter_config,
|
|
14
|
+
)
|
|
15
|
+
from claude_supervisor.config.models import (
|
|
16
|
+
CompletionMode,
|
|
17
|
+
LoggingConfig,
|
|
18
|
+
PathsConfig,
|
|
19
|
+
PermissionMode,
|
|
20
|
+
SupervisorConfig,
|
|
21
|
+
TaskDelivery,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CompletionMode",
|
|
26
|
+
"ConfigError",
|
|
27
|
+
"LoggingConfig",
|
|
28
|
+
"PathsConfig",
|
|
29
|
+
"PermissionMode",
|
|
30
|
+
"SupervisorConfig",
|
|
31
|
+
"TaskDelivery",
|
|
32
|
+
"default_config_path",
|
|
33
|
+
"dump_config",
|
|
34
|
+
"effective_database",
|
|
35
|
+
"effective_log_file",
|
|
36
|
+
"effective_state_dir",
|
|
37
|
+
"load_config",
|
|
38
|
+
"starter_config",
|
|
39
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Load and persist :class:`SupervisorConfig` from YAML.
|
|
2
|
+
|
|
3
|
+
The loader is tolerant of the flat, spec-style config shown in the project
|
|
4
|
+
specification (``log_level: INFO``) and transparently maps such keys onto the
|
|
5
|
+
richer nested model, so users never have to migrate their file by hand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from claude_supervisor.config.models import SupervisorConfig
|
|
18
|
+
|
|
19
|
+
_APP_NAME = "claude-supervisor"
|
|
20
|
+
|
|
21
|
+
# Flat spec-style keys that live under a nested section in the model.
|
|
22
|
+
_FLAT_TO_NESTED: dict[str, tuple[str, str]] = {
|
|
23
|
+
"log_level": ("logging", "level"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(RuntimeError):
|
|
28
|
+
"""Raised when a configuration file cannot be read, parsed, or validated."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_config_dir() -> Path:
|
|
32
|
+
"""Return the platform-appropriate config directory.
|
|
33
|
+
|
|
34
|
+
Honors ``CLAUDE_SUPERVISOR_HOME`` when set, else ``XDG_CONFIG_HOME`` on
|
|
35
|
+
POSIX and ``%APPDATA%`` on Windows, falling back to ``~/.config``.
|
|
36
|
+
"""
|
|
37
|
+
override = os.environ.get("CLAUDE_SUPERVISOR_HOME")
|
|
38
|
+
if override:
|
|
39
|
+
return Path(override).expanduser()
|
|
40
|
+
|
|
41
|
+
if os.name == "nt":
|
|
42
|
+
base = os.environ.get("APPDATA")
|
|
43
|
+
if base:
|
|
44
|
+
return Path(base) / _APP_NAME
|
|
45
|
+
return Path.home() / "AppData" / "Roaming" / _APP_NAME
|
|
46
|
+
|
|
47
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
48
|
+
if base:
|
|
49
|
+
return Path(base) / _APP_NAME
|
|
50
|
+
return Path.home() / ".config" / _APP_NAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_config_path() -> Path:
|
|
54
|
+
"""Return the default ``config.yaml`` path."""
|
|
55
|
+
return default_config_dir() / "config.yaml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _migrate_flat_keys(raw: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
"""Fold flat spec-style keys into their nested homes without clobbering."""
|
|
60
|
+
data = dict(raw)
|
|
61
|
+
for flat_key, (section, field) in _FLAT_TO_NESTED.items():
|
|
62
|
+
if flat_key not in data:
|
|
63
|
+
continue
|
|
64
|
+
value = data.pop(flat_key)
|
|
65
|
+
section_data = dict(data.get(section) or {})
|
|
66
|
+
section_data.setdefault(field, value)
|
|
67
|
+
data[section] = section_data
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config(path: str | os.PathLike[str] | None = None) -> SupervisorConfig:
|
|
72
|
+
"""Load configuration from ``path`` (or the default location).
|
|
73
|
+
|
|
74
|
+
A missing file yields a fully-defaulted config; that is a valid, supported
|
|
75
|
+
state rather than an error. Malformed YAML or values that fail validation
|
|
76
|
+
raise :class:`ConfigError` with a human-readable message.
|
|
77
|
+
"""
|
|
78
|
+
resolved = Path(path) if path is not None else default_config_path()
|
|
79
|
+
|
|
80
|
+
if not resolved.exists():
|
|
81
|
+
return SupervisorConfig()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
text = resolved.read_text(encoding="utf-8")
|
|
85
|
+
except OSError as exc: # pragma: no cover - platform/permission dependent
|
|
86
|
+
raise ConfigError(f"Could not read config file {resolved}: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
raw = yaml.safe_load(text) or {}
|
|
90
|
+
except yaml.YAMLError as exc:
|
|
91
|
+
raise ConfigError(f"Config file {resolved} is not valid YAML: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
if not isinstance(raw, dict):
|
|
94
|
+
raise ConfigError(
|
|
95
|
+
f"Config file {resolved} must contain a top-level mapping, got {type(raw).__name__}."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
return SupervisorConfig.model_validate(_migrate_flat_keys(raw))
|
|
100
|
+
except ValidationError as exc:
|
|
101
|
+
raise ConfigError(f"Config file {resolved} failed validation:\n{exc}") from exc
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_STARTER_CONFIG = """\
|
|
105
|
+
# Claude Supervisor configuration.
|
|
106
|
+
# Full reference: examples/config.yaml in the project repository.
|
|
107
|
+
|
|
108
|
+
# How Claude is launched for an unattended task. `-p` (print/headless) runs
|
|
109
|
+
# one-shot; the permission flag lets Claude use tools without prompting.
|
|
110
|
+
# For broader access use "--dangerously-skip-permissions" instead (with care).
|
|
111
|
+
claude_command: ["claude", "-p", "--permission-mode", "acceptEdits"]
|
|
112
|
+
|
|
113
|
+
# How to resume after a usage-limit reset (continues the most recent session).
|
|
114
|
+
resume_command: ["claude", "-p", "--continue", "--permission-mode", "acceptEdits"]
|
|
115
|
+
|
|
116
|
+
# Wait for a legitimate reset and resume automatically.
|
|
117
|
+
auto_resume: true
|
|
118
|
+
|
|
119
|
+
# Fallback wait (hours) if Claude reports no reset time.
|
|
120
|
+
default_reset_hours: 5
|
|
121
|
+
|
|
122
|
+
# A completion marker OR a clean process exit counts as done; 'heuristic' also
|
|
123
|
+
# stops when Claude goes idle. Headless `claude -p` exits cleanly when finished.
|
|
124
|
+
completion_mode: heuristic
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def starter_config() -> str:
|
|
129
|
+
"""Return the text of a sensible, validated starter config file."""
|
|
130
|
+
return _STARTER_CONFIG
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def effective_state_dir(config: SupervisorConfig) -> Path:
|
|
134
|
+
"""Return the runtime state directory (``paths.state_dir`` or the default)."""
|
|
135
|
+
return config.paths.state_dir or default_config_dir()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def effective_log_file(config: SupervisorConfig) -> Path:
|
|
139
|
+
"""Return the resolved log-file path (``paths.log_file`` or a derived default)."""
|
|
140
|
+
return config.paths.log_file or (effective_state_dir(config) / "supervisor.log")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def effective_database(config: SupervisorConfig) -> Path:
|
|
144
|
+
"""Return the resolved SQLite path (``paths.database`` or a derived default)."""
|
|
145
|
+
return config.paths.database or (effective_state_dir(config) / "supervisor.db")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def dump_config(config: SupervisorConfig, path: str | os.PathLike[str]) -> Path:
|
|
149
|
+
"""Serialize ``config`` to ``path`` as YAML, creating parent dirs as needed.
|
|
150
|
+
|
|
151
|
+
Returns the path written to.
|
|
152
|
+
"""
|
|
153
|
+
resolved = Path(path)
|
|
154
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
payload = config.model_dump(mode="json", exclude_none=True)
|
|
156
|
+
resolved.write_text(
|
|
157
|
+
yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
|
|
158
|
+
encoding="utf-8",
|
|
159
|
+
)
|
|
160
|
+
return resolved
|