clauster 0.2.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.
- clauster/__init__.py +3 -0
- clauster/__main__.py +342 -0
- clauster/app.py +746 -0
- clauster/auth.py +311 -0
- clauster/bridge_log.py +80 -0
- clauster/claude_cli.py +36 -0
- clauster/claude_md.py +178 -0
- clauster/clone_jobs.py +121 -0
- clauster/config.py +313 -0
- clauster/discovery.py +75 -0
- clauster/environments.py +284 -0
- clauster/hooks/__init__.py +5 -0
- clauster/hooks/resume_recap.py +211 -0
- clauster/inspector.py +68 -0
- clauster/logstream.py +44 -0
- clauster/models.py +148 -0
- clauster/ops.py +470 -0
- clauster/pointers.py +63 -0
- clauster/procutil.py +141 -0
- clauster/provisioning.py +362 -0
- clauster/pty_keeper.py +205 -0
- clauster/recap.py +112 -0
- clauster/redact.py +64 -0
- clauster/runner.py +1039 -0
- clauster/state.py +93 -0
- clauster/static/alpine.LICENSE +21 -0
- clauster/static/alpine.min.js +5 -0
- clauster/static/clauster.css +340 -0
- clauster/static/favicon.svg +14 -0
- clauster/static/vendor/iconoir/LICENSE +21 -0
- clauster/static/vendor/iconoir/README.md +45 -0
- clauster/static/vendor/tabler/LICENSE +21 -0
- clauster/static/vendor/tabler/README.md +30 -0
- clauster/static/vendor/tabler/css/tabler.min.css +9 -0
- clauster/static/vendor/tabler/js/tabler.min.js +13 -0
- clauster/static/vendor/versions.txt +11 -0
- clauster/templates/_iconoir_sprite.html +82 -0
- clauster/templates/_project_card.html +166 -0
- clauster/templates/dashboard.html +760 -0
- clauster/templates/login.html +77 -0
- clauster/trust.py +117 -0
- clauster/usage.py +212 -0
- clauster-0.2.0.dist-info/METADATA +280 -0
- clauster-0.2.0.dist-info/RECORD +47 -0
- clauster-0.2.0.dist-info/WHEEL +4 -0
- clauster-0.2.0.dist-info/entry_points.txt +2 -0
- clauster-0.2.0.dist-info/licenses/LICENSE +202 -0
clauster/__init__.py
ADDED
clauster/__main__.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Entry point: ``clauster`` / ``python -m clauster``.
|
|
2
|
+
|
|
3
|
+
Subcommands: ``run`` (default), ``hash-password``, ``doctor``, ``backup``,
|
|
4
|
+
``restore``, ``migrate``, ``install-service``. Bare ``clauster`` and
|
|
5
|
+
``clauster -c <cfg>`` still mean ``run`` for backward compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import getpass
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
from . import __version__, claude_cli, environments, ops, usage
|
|
19
|
+
from .app import create_app
|
|
20
|
+
from .auth import hash_password, make_hasher
|
|
21
|
+
from .config import load_config
|
|
22
|
+
|
|
23
|
+
_COMMANDS = {
|
|
24
|
+
"run",
|
|
25
|
+
"hash-password",
|
|
26
|
+
"doctor",
|
|
27
|
+
"backup",
|
|
28
|
+
"restore",
|
|
29
|
+
"migrate",
|
|
30
|
+
"install-service",
|
|
31
|
+
"reap-environments",
|
|
32
|
+
"usage",
|
|
33
|
+
}
|
|
34
|
+
_TOP_LEVEL_FLAGS = {"-h", "--help", "--version"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
"""Parse ``argv``, dispatch to the requested subcommand, and return its exit code."""
|
|
39
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
40
|
+
parser = argparse.ArgumentParser(prog="clauster", description=__doc__)
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"clauster {__version__}")
|
|
42
|
+
sub = parser.add_subparsers(dest="command")
|
|
43
|
+
|
|
44
|
+
run_p = sub.add_parser("run", help="run the server (default)")
|
|
45
|
+
run_p.add_argument("-c", "--config", help="path to clauster.yml")
|
|
46
|
+
sub.add_parser("hash-password", help="hash a password for auth.password_hash")
|
|
47
|
+
|
|
48
|
+
doctor_p = sub.add_parser("doctor", help="diagnose config / environment")
|
|
49
|
+
doctor_p.add_argument("-c", "--config", help="path to clauster.yml")
|
|
50
|
+
|
|
51
|
+
backup_p = sub.add_parser("backup", help="back up state_dir + config to a tar.gz")
|
|
52
|
+
backup_p.add_argument("-c", "--config", help="path to clauster.yml")
|
|
53
|
+
backup_p.add_argument("-o", "--output", default=".", help="output file or directory")
|
|
54
|
+
|
|
55
|
+
restore_p = sub.add_parser(
|
|
56
|
+
"restore", help="restore state (and optionally config) from a backup"
|
|
57
|
+
)
|
|
58
|
+
restore_p.add_argument("backup", help="path to a clauster backup tar.gz")
|
|
59
|
+
restore_p.add_argument("--state-dir", required=True, help="state_dir to restore into")
|
|
60
|
+
restore_p.add_argument("--config-out", help="also restore the config to this path")
|
|
61
|
+
restore_p.add_argument("--force", action="store_true", help="overwrite a non-empty target")
|
|
62
|
+
|
|
63
|
+
migrate_p = sub.add_parser("migrate", help="migrate state.json to the current schema")
|
|
64
|
+
migrate_p.add_argument("-c", "--config", help="path to clauster.yml")
|
|
65
|
+
|
|
66
|
+
svc_p = sub.add_parser(
|
|
67
|
+
"install-service", help="print a service unit (systemd/launchd/windows)"
|
|
68
|
+
)
|
|
69
|
+
svc_p.add_argument("kind", choices=("systemd", "launchd", "windows"))
|
|
70
|
+
svc_p.add_argument("-c", "--config", help="config path to embed in the unit")
|
|
71
|
+
svc_p.add_argument("--user", help="run-as user (systemd)")
|
|
72
|
+
|
|
73
|
+
reap_p = sub.add_parser(
|
|
74
|
+
"reap-environments",
|
|
75
|
+
help="archive ghost bridge environments (dry-run by default)",
|
|
76
|
+
)
|
|
77
|
+
reap_p.add_argument("-c", "--config", help="path to clauster.yml")
|
|
78
|
+
reap_p.add_argument("--archive", action="store_true", help="archive the ghosts (reversible)")
|
|
79
|
+
reap_p.add_argument(
|
|
80
|
+
"--force-delete",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="hard-delete ghosts, discarding queued work (instead of archiving)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
usage_p = sub.add_parser("usage", help="token + approx cost summary for a session transcript")
|
|
86
|
+
usage_p.add_argument("transcript", help="path to a session transcript .jsonl")
|
|
87
|
+
|
|
88
|
+
# Treat bare `clauster` / `clauster -c x` as `run` for backward compatibility.
|
|
89
|
+
if argv and argv[0] not in _COMMANDS and argv[0] not in _TOP_LEVEL_FLAGS:
|
|
90
|
+
argv = ["run", *argv]
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
if args.command == "hash-password":
|
|
94
|
+
return _hash_password()
|
|
95
|
+
if args.command == "doctor":
|
|
96
|
+
return _doctor(args.config)
|
|
97
|
+
if args.command == "backup":
|
|
98
|
+
return _backup(args.config, args.output)
|
|
99
|
+
if args.command == "restore":
|
|
100
|
+
return _restore(args.backup, args.state_dir, args.config_out, args.force)
|
|
101
|
+
if args.command == "migrate":
|
|
102
|
+
return _migrate(args.config)
|
|
103
|
+
if args.command == "install-service":
|
|
104
|
+
return _install_service(args.kind, args.config, args.user)
|
|
105
|
+
if args.command == "reap-environments":
|
|
106
|
+
return _reap_environments(args.config, args.archive, args.force_delete)
|
|
107
|
+
if args.command == "usage":
|
|
108
|
+
return _usage(args.transcript)
|
|
109
|
+
return _run(getattr(args, "config", None))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _hash_password() -> int:
|
|
113
|
+
password = getpass.getpass("Password: ")
|
|
114
|
+
if not password:
|
|
115
|
+
print("clauster: empty password", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
if password != getpass.getpass("Confirm: "):
|
|
118
|
+
print("clauster: passwords do not match", file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
print(hash_password(make_hasher(), password))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_STATUS_MARK = {ops.OK: "✓", ops.WARN: "!", ops.FAIL: "✗"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _doctor(config_path: str | None) -> int:
|
|
128
|
+
checks, ok = ops.run_doctor(config_path)
|
|
129
|
+
for c in checks:
|
|
130
|
+
print(
|
|
131
|
+
f" {_STATUS_MARK.get(c.status, '?')} {c.name:<16} {c.detail}",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
print(
|
|
135
|
+
("clauster: all checks passed" if ok else "clauster: FAILURES above"),
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
return 0 if ok else 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_or_exit(config_path: str | None):
|
|
142
|
+
try:
|
|
143
|
+
return load_config(config_path)
|
|
144
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
145
|
+
print(f"clauster: config error: {exc}", file=sys.stderr)
|
|
146
|
+
raise SystemExit(2) from exc
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _backup(config_path: str | None, output: str) -> int:
|
|
150
|
+
config = _load_or_exit(config_path)
|
|
151
|
+
try:
|
|
152
|
+
path = ops.make_backup(config, Path(output))
|
|
153
|
+
except OSError as exc: # disk full / unwritable dest — clean exit, not a traceback
|
|
154
|
+
print(f"clauster: backup failed: {exc}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
print(f"clauster: wrote backup {path}", file=sys.stderr)
|
|
157
|
+
print(
|
|
158
|
+
"clauster: note — the backed-up config contains the argon2 password hash; "
|
|
159
|
+
"store the archive securely.",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
print(path)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _restore(backup: str, state_dir: str, config_out: str | None, force: bool) -> int:
|
|
167
|
+
try:
|
|
168
|
+
result = ops.restore_backup(
|
|
169
|
+
Path(backup),
|
|
170
|
+
state_dir=Path(state_dir),
|
|
171
|
+
config_out=Path(config_out) if config_out else None,
|
|
172
|
+
force=force,
|
|
173
|
+
)
|
|
174
|
+
except FileNotFoundError as exc:
|
|
175
|
+
print(f"clauster: {exc}", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
except FileExistsError as exc:
|
|
178
|
+
print(f"clauster: {exc}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
except ValueError as exc: # unsafe archive member
|
|
181
|
+
print(f"clauster: refused unsafe backup: {exc}", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
print(
|
|
184
|
+
f"clauster: restored {result['state_files']} state file(s)"
|
|
185
|
+
+ (f"; config -> {result['config']}" if result["config"] else ""),
|
|
186
|
+
file=sys.stderr,
|
|
187
|
+
)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _migrate(config_path: str | None) -> int:
|
|
192
|
+
config = _load_or_exit(config_path)
|
|
193
|
+
result = ops.migrate_state(config)
|
|
194
|
+
print(
|
|
195
|
+
f"clauster: state at schema {result['schema_version']} "
|
|
196
|
+
f"({result['instances']} instance record(s))",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _install_service(kind: str, config_path: str | None, user: str | None) -> int:
|
|
203
|
+
print(ops.render_service_unit(kind, config_path=config_path, user=user))
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _reap_environments(config_path: str | None, archive: bool, force_delete: bool) -> int:
|
|
208
|
+
config = _load_or_exit(config_path)
|
|
209
|
+
try:
|
|
210
|
+
creds = environments.load_credentials(now_ms=int(time.time() * 1000))
|
|
211
|
+
except environments.CredentialsError as exc:
|
|
212
|
+
print(f"clauster: credentials error: {exc}", file=sys.stderr)
|
|
213
|
+
return 2
|
|
214
|
+
print(
|
|
215
|
+
f"clauster: org {creds.organization_uuid}, token {creds.masked_token()}",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
client = environments.EnvironmentsClient(creds)
|
|
219
|
+
try:
|
|
220
|
+
envs = client.list_environments()
|
|
221
|
+
except environments.EnvironmentsAPIError as exc:
|
|
222
|
+
print(f"clauster: {exc}", file=sys.stderr)
|
|
223
|
+
return 2
|
|
224
|
+
# SAFETY: never reap without a trustworthy live set. If we can't enumerate live
|
|
225
|
+
# bridges, abort — proceeding could archive a still-live environment.
|
|
226
|
+
try:
|
|
227
|
+
live = environments.live_bridge_directories(config.claude.binary, config.projects_root)
|
|
228
|
+
except Exception as exc: # noqa: BLE001 - fail closed on ANY liveness-probe failure
|
|
229
|
+
print(
|
|
230
|
+
f"clauster: refusing to reap — could not determine live bridges: {exc}",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
return 2
|
|
234
|
+
|
|
235
|
+
ghosts = environments.find_ghosts(envs, live)
|
|
236
|
+
print(
|
|
237
|
+
f"clauster: {len(envs)} env(s), {len(live)} live dir(s), {len(ghosts)} ghost(s)",
|
|
238
|
+
file=sys.stderr,
|
|
239
|
+
)
|
|
240
|
+
for g in ghosts:
|
|
241
|
+
print(
|
|
242
|
+
f" - {g.id} {g.config.directory or '(no dir)'} ({g.name})",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
if not ghosts:
|
|
246
|
+
return 0
|
|
247
|
+
if not (archive or force_delete):
|
|
248
|
+
print(
|
|
249
|
+
"clauster: dry-run (no changes). Pass --archive to archive (reversible).",
|
|
250
|
+
file=sys.stderr,
|
|
251
|
+
)
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
action = "delete" if force_delete else "archive"
|
|
255
|
+
for g in ghosts:
|
|
256
|
+
try:
|
|
257
|
+
if force_delete:
|
|
258
|
+
client.delete_environment(g.id, force=True)
|
|
259
|
+
else:
|
|
260
|
+
client.archive_environment(g.id)
|
|
261
|
+
except environments.EnvironmentsAPIError as exc:
|
|
262
|
+
print(f"clauster: failed to {action} {g.id}: {exc}", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
print(f"clauster: {action}d {len(ghosts)} ghost environment(s)", file=sys.stderr)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _usage(transcript: str) -> int:
|
|
269
|
+
try:
|
|
270
|
+
u = usage.parse_transcript(Path(transcript))
|
|
271
|
+
except FileNotFoundError as exc:
|
|
272
|
+
print(f"clauster: {exc}", file=sys.stderr)
|
|
273
|
+
return 2
|
|
274
|
+
for model, t in sorted(u.by_model.items()):
|
|
275
|
+
c = usage.cost_usd(model, t)
|
|
276
|
+
cstr = f"≈${c:.4f}" if c is not None else "(unpriced)"
|
|
277
|
+
print(
|
|
278
|
+
f" {model:<22} in={t.input} out={t.output} "
|
|
279
|
+
f"cache_w={t.cache_creation} cache_r={t.cache_read} {cstr}",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
tot = u.totals
|
|
283
|
+
print(
|
|
284
|
+
f"clauster: {tot.messages} assistant msg(s), {tot.total_tokens} tokens, "
|
|
285
|
+
f"≈${u.cost_usd():.4f} total (approx)",
|
|
286
|
+
file=sys.stderr,
|
|
287
|
+
)
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _warn_if_cookie_insecure(config) -> None:
|
|
292
|
+
"""Warn when auth is on but the session cookie will likely ship without Secure.
|
|
293
|
+
|
|
294
|
+
Happens on a plain-http LAN with no TLS-terminating proxy — the cookie is then
|
|
295
|
+
sniffable on the wire.
|
|
296
|
+
"""
|
|
297
|
+
a = config.auth
|
|
298
|
+
if not (a.enabled and a.password_required):
|
|
299
|
+
return
|
|
300
|
+
if a.cookie_secure == "always":
|
|
301
|
+
return # Secure forced regardless of scheme
|
|
302
|
+
if a.reverse_proxy.enabled:
|
|
303
|
+
return # a TLS proxy is expected to terminate https and set X-Forwarded-Proto
|
|
304
|
+
print(
|
|
305
|
+
"clauster: WARNING — auth is enabled but the session cookie may ship without "
|
|
306
|
+
"the Secure flag over plain http; put Clauster behind https/a TLS proxy, or set "
|
|
307
|
+
"auth.cookie_secure: always.",
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run(config_path: str | None) -> int:
|
|
313
|
+
try:
|
|
314
|
+
config = load_config(config_path)
|
|
315
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
316
|
+
print(f"clauster: config error: {exc}", file=sys.stderr)
|
|
317
|
+
return 2
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
version = claude_cli.claude_version(config.claude.binary)
|
|
321
|
+
except claude_cli.ClaudeNotFound as exc:
|
|
322
|
+
print(f"clauster: {exc}", file=sys.stderr)
|
|
323
|
+
return 2
|
|
324
|
+
except Exception as exc: # noqa: BLE001 - surface any probe failure clearly
|
|
325
|
+
print(f"clauster: could not probe claude version: {exc}", file=sys.stderr)
|
|
326
|
+
return 2
|
|
327
|
+
|
|
328
|
+
print(
|
|
329
|
+
f"clauster {__version__} | claude {version} | "
|
|
330
|
+
f"projects_root={config.projects_root} | http://{config.host}:{config.port}",
|
|
331
|
+
file=sys.stderr,
|
|
332
|
+
)
|
|
333
|
+
_warn_if_cookie_insecure(config)
|
|
334
|
+
app = create_app(config)
|
|
335
|
+
# proxy_headers=False: keep request.client.host as the real socket peer so the
|
|
336
|
+
# reverse-proxy IP allowlist can't be defeated via a spoofed X-Forwarded-For.
|
|
337
|
+
uvicorn.run(app, host=config.host, port=config.port, log_level="info", proxy_headers=False)
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
raise SystemExit(main())
|