ctrlrelay 0.10.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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Dashboard client for event push and heartbeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ctrlrelay import __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HeartbeatPayload(BaseModel):
|
|
18
|
+
"""Payload for heartbeat endpoint."""
|
|
19
|
+
|
|
20
|
+
node_id: str
|
|
21
|
+
timestamp: str = ""
|
|
22
|
+
version: str = __version__
|
|
23
|
+
uptime_seconds: int = 0
|
|
24
|
+
platform: str = ""
|
|
25
|
+
active_sessions: list[dict[str, Any]] = []
|
|
26
|
+
last_github_poll: str | None = None
|
|
27
|
+
last_github_poll_status: str = "ok"
|
|
28
|
+
repos_configured: int = 0
|
|
29
|
+
repos_active: int = 0
|
|
30
|
+
|
|
31
|
+
def model_post_init(self, __context: Any) -> None:
|
|
32
|
+
if not self.timestamp:
|
|
33
|
+
object.__setattr__(self, "timestamp", datetime.now(timezone.utc).isoformat())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EventPayload(BaseModel):
|
|
37
|
+
"""Payload for event endpoint."""
|
|
38
|
+
|
|
39
|
+
level: str # info, warning, error
|
|
40
|
+
pipeline: str # secops, dev
|
|
41
|
+
repo: str
|
|
42
|
+
message: str
|
|
43
|
+
session_id: str | None = None
|
|
44
|
+
timestamp: str = ""
|
|
45
|
+
details: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
def model_post_init(self, __context: Any) -> None:
|
|
48
|
+
if not self.timestamp:
|
|
49
|
+
object.__setattr__(
|
|
50
|
+
self, "timestamp", datetime.now(timezone.utc).isoformat()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DashboardClient:
|
|
56
|
+
"""Client for dashboard API with offline queue."""
|
|
57
|
+
|
|
58
|
+
url: str
|
|
59
|
+
auth_token: str
|
|
60
|
+
node_id: str
|
|
61
|
+
queue_dir: Path | None = None
|
|
62
|
+
timeout: int = 30
|
|
63
|
+
max_retries: int = 3
|
|
64
|
+
_queue: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
if self.queue_dir:
|
|
68
|
+
self.queue_dir = Path(self.queue_dir)
|
|
69
|
+
self.queue_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._load_queue()
|
|
71
|
+
|
|
72
|
+
def _load_queue(self) -> None:
|
|
73
|
+
"""Load queued events from disk."""
|
|
74
|
+
if not self.queue_dir:
|
|
75
|
+
return
|
|
76
|
+
queue_file = self.queue_dir / "event_queue.json"
|
|
77
|
+
if queue_file.exists():
|
|
78
|
+
try:
|
|
79
|
+
self._queue = json.loads(queue_file.read_text())
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
self._queue = []
|
|
82
|
+
|
|
83
|
+
def _save_queue(self) -> None:
|
|
84
|
+
"""Save queued events to disk."""
|
|
85
|
+
if not self.queue_dir:
|
|
86
|
+
return
|
|
87
|
+
queue_file = self.queue_dir / "event_queue.json"
|
|
88
|
+
queue_file.write_text(json.dumps(self._queue))
|
|
89
|
+
|
|
90
|
+
def _queue_event(self, event: EventPayload) -> None:
|
|
91
|
+
"""Add event to offline queue."""
|
|
92
|
+
self._queue.append(event.model_dump())
|
|
93
|
+
self._save_queue()
|
|
94
|
+
|
|
95
|
+
async def push_event(self, event: EventPayload) -> bool:
|
|
96
|
+
"""Push event to dashboard, queue on failure."""
|
|
97
|
+
payload = event.model_dump()
|
|
98
|
+
payload["node_id"] = self.node_id
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
102
|
+
response = await client.post(
|
|
103
|
+
f"{self.url}/event",
|
|
104
|
+
json=payload,
|
|
105
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
106
|
+
)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
return True
|
|
109
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
110
|
+
self._queue_event(event)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def heartbeat(self, payload: HeartbeatPayload) -> bool:
|
|
114
|
+
"""Send heartbeat to dashboard."""
|
|
115
|
+
data = payload.model_dump()
|
|
116
|
+
data["node_id"] = self.node_id
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
120
|
+
response = await client.post(
|
|
121
|
+
f"{self.url}/heartbeat",
|
|
122
|
+
json=data,
|
|
123
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
124
|
+
)
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
return True
|
|
127
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
async def drain_queue(self) -> int:
|
|
131
|
+
"""Attempt to send queued events. Returns count of successfully sent."""
|
|
132
|
+
if not self._queue:
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
sent = 0
|
|
136
|
+
remaining = []
|
|
137
|
+
|
|
138
|
+
for event_data in self._queue:
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
141
|
+
event_data["node_id"] = self.node_id
|
|
142
|
+
response = await client.post(
|
|
143
|
+
f"{self.url}/event",
|
|
144
|
+
json=event_data,
|
|
145
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
146
|
+
)
|
|
147
|
+
response.raise_for_status()
|
|
148
|
+
sent += 1
|
|
149
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
150
|
+
remaining.append(event_data)
|
|
151
|
+
|
|
152
|
+
self._queue = remaining
|
|
153
|
+
self._save_queue()
|
|
154
|
+
return sent
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def queue_size(self) -> int:
|
|
158
|
+
"""Number of events in offline queue."""
|
|
159
|
+
return len(self._queue)
|
ctrlrelay/gh_protocol.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Detect the git transport protocol the operator has configured for
|
|
2
|
+
GitHub via the ``gh`` CLI.
|
|
3
|
+
|
|
4
|
+
``ctrlrelay`` clones a handful of GitHub repos on the operator's
|
|
5
|
+
behalf (project repos, the personalization repo). Hardcoding HTTPS
|
|
6
|
+
for these clones breaks operators who've set ``gh config set
|
|
7
|
+
git_protocol ssh`` and have no HTTPS credential helper wired up —
|
|
8
|
+
``git`` then fails with "could not read Username" instead of using
|
|
9
|
+
the SSH key ``gh`` already knows about.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
_VALID_PROTOCOLS = ("ssh", "https")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_git_protocol(*, default: str = "https") -> str:
|
|
20
|
+
"""Return ``"ssh"`` or ``"https"``, per ``gh config get git_protocol``.
|
|
21
|
+
|
|
22
|
+
Falls back to ``default`` whenever ``gh`` isn't installed, isn't
|
|
23
|
+
configured, or returns something unexpected — this is a
|
|
24
|
+
convenience read, not a hard requirement, so any failure here
|
|
25
|
+
should degrade to the previous hardcoded-HTTPS behavior rather
|
|
26
|
+
than blocking a clone.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
["gh", "config", "get", "git_protocol"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=5,
|
|
34
|
+
)
|
|
35
|
+
except (OSError, subprocess.SubprocessError):
|
|
36
|
+
return default
|
|
37
|
+
value = proc.stdout.strip().lower()
|
|
38
|
+
if proc.returncode == 0 and value in _VALID_PROTOCOLS:
|
|
39
|
+
return value
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def github_clone_url(repo: str, *, protocol: str) -> str:
|
|
44
|
+
"""Build a clone URL for ``owner/repo`` under the given protocol."""
|
|
45
|
+
if protocol == "ssh":
|
|
46
|
+
return f"git@github.com:{repo}.git"
|
|
47
|
+
return f"https://github.com/{repo}.git"
|
ctrlrelay/install.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Render and install service unit files (launchd, systemd) from
|
|
2
|
+
in-package templates.
|
|
3
|
+
|
|
4
|
+
The bridge and poller are meant to run as supervised long-lived
|
|
5
|
+
services. Hand-writing platform unit files is error-prone (absolute
|
|
6
|
+
paths, label/filename consistency, exit-timeout values that prevent
|
|
7
|
+
mid-cleanup SIGKILL) and historically left operator boxes with
|
|
8
|
+
hard-coded ``/Users/$ME/...`` strings — fine on one machine, broken
|
|
9
|
+
on the next.
|
|
10
|
+
|
|
11
|
+
This module ships templates inside the package and exposes a small
|
|
12
|
+
substitution + write helper so ``ctrlrelay install launchd|systemd``
|
|
13
|
+
can produce ready-to-load files for whoever is running it. No
|
|
14
|
+
``sudo``, no daemon-level paths — everything lives under the user's
|
|
15
|
+
home directory.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import getpass
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import string
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from importlib import resources
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_TEMPLATE_VAR_RE = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_template(platform: str, name: str) -> str:
|
|
33
|
+
pkg = f"ctrlrelay.templates.{platform}"
|
|
34
|
+
return resources.files(pkg).joinpath(name).read_text(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _substitute(text: str, values: dict[str, str]) -> str:
|
|
38
|
+
# We use Template.safe_substitute over plain str.format because the
|
|
39
|
+
# plist/systemd templates contain literal braces that .format would
|
|
40
|
+
# interpret. safe_substitute leaves unknown ${VAR} references intact
|
|
41
|
+
# rather than raising — useful for the bot-token placeholder when the
|
|
42
|
+
# operator hasn't exported the env var at install time.
|
|
43
|
+
return string.Template(text).safe_substitute(values)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _missing_vars(rendered: str) -> list[str]:
|
|
47
|
+
return sorted(set(_TEMPLATE_VAR_RE.findall(rendered)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RenderedUnit:
|
|
52
|
+
"""A rendered unit file ready to write to disk."""
|
|
53
|
+
|
|
54
|
+
platform: str # "launchd" | "systemd"
|
|
55
|
+
service: str # "bridge" | "poller"
|
|
56
|
+
content: str
|
|
57
|
+
target_path: Path
|
|
58
|
+
# Variables that were left unsubstituted (e.g. CTRLRELAY_TELEGRAM_TOKEN
|
|
59
|
+
# when the env var wasn't set at render time). Caller decides whether
|
|
60
|
+
# to warn the operator or refuse to write.
|
|
61
|
+
unresolved: list[str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_substitution_values(
|
|
65
|
+
*,
|
|
66
|
+
workdir: Path,
|
|
67
|
+
label_prefix: str,
|
|
68
|
+
poller_interval: int,
|
|
69
|
+
ctrlrelay_bin: str | None = None,
|
|
70
|
+
) -> dict[str, str]:
|
|
71
|
+
bin_path = ctrlrelay_bin or shutil.which("ctrlrelay") or "ctrlrelay"
|
|
72
|
+
home = Path.home()
|
|
73
|
+
values = {
|
|
74
|
+
"USER": getpass.getuser(),
|
|
75
|
+
"HOME": str(home),
|
|
76
|
+
"CTRLRELAY_BIN": bin_path,
|
|
77
|
+
"WORKDIR": str(workdir.expanduser()),
|
|
78
|
+
"LABEL_PREFIX": label_prefix,
|
|
79
|
+
"POLLER_INTERVAL": str(poller_interval),
|
|
80
|
+
}
|
|
81
|
+
# Pass through the bot token from the current environment if the
|
|
82
|
+
# operator has it exported at install time. Leaving it out means the
|
|
83
|
+
# placeholder survives in the rendered file and the operator must
|
|
84
|
+
# edit by hand — ``unresolved`` flags this.
|
|
85
|
+
token = os.environ.get("CTRLRELAY_TELEGRAM_TOKEN")
|
|
86
|
+
if token:
|
|
87
|
+
values["CTRLRELAY_TELEGRAM_TOKEN"] = token
|
|
88
|
+
return values
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_launchd(
|
|
92
|
+
*,
|
|
93
|
+
workdir: Path,
|
|
94
|
+
label_prefix: str = "com.ctrlrelay",
|
|
95
|
+
poller_interval: int = 300,
|
|
96
|
+
ctrlrelay_bin: str | None = None,
|
|
97
|
+
target_dir: Path | None = None,
|
|
98
|
+
) -> list[RenderedUnit]:
|
|
99
|
+
"""Render the bridge and poller launchd plists.
|
|
100
|
+
|
|
101
|
+
``target_dir`` defaults to ``~/Library/LaunchAgents`` — override
|
|
102
|
+
in tests. The filename uses ``{label_prefix}.ctrlrelay-{service}.plist``
|
|
103
|
+
so it lines up with the ``<Label>`` value inside the plist (launchd
|
|
104
|
+
won't load otherwise).
|
|
105
|
+
"""
|
|
106
|
+
values = _build_substitution_values(
|
|
107
|
+
workdir=workdir,
|
|
108
|
+
label_prefix=label_prefix,
|
|
109
|
+
poller_interval=poller_interval,
|
|
110
|
+
ctrlrelay_bin=ctrlrelay_bin,
|
|
111
|
+
)
|
|
112
|
+
target_dir = target_dir or Path.home() / "Library" / "LaunchAgents"
|
|
113
|
+
units: list[RenderedUnit] = []
|
|
114
|
+
for service in ("bridge", "poller"):
|
|
115
|
+
raw = _read_template("launchd", f"{service}.plist.template")
|
|
116
|
+
rendered = _substitute(raw, values)
|
|
117
|
+
target = target_dir / f"{label_prefix}.ctrlrelay-{service}.plist"
|
|
118
|
+
units.append(
|
|
119
|
+
RenderedUnit(
|
|
120
|
+
platform="launchd",
|
|
121
|
+
service=service,
|
|
122
|
+
content=rendered,
|
|
123
|
+
target_path=target,
|
|
124
|
+
unresolved=_missing_vars(rendered),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return units
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_systemd(
|
|
131
|
+
*,
|
|
132
|
+
workdir: Path,
|
|
133
|
+
poller_interval: int = 300,
|
|
134
|
+
ctrlrelay_bin: str | None = None,
|
|
135
|
+
target_dir: Path | None = None,
|
|
136
|
+
) -> list[RenderedUnit]:
|
|
137
|
+
"""Render the bridge and poller systemd user unit files.
|
|
138
|
+
|
|
139
|
+
``target_dir`` defaults to ``~/.config/systemd/user`` — the canonical
|
|
140
|
+
location for user-scoped units (no root needed). systemd doesn't
|
|
141
|
+
require label_prefix-style namespacing the way launchd does, so the
|
|
142
|
+
filenames are fixed: ``ctrlrelay-{bridge,poller}.service``.
|
|
143
|
+
"""
|
|
144
|
+
values = _build_substitution_values(
|
|
145
|
+
workdir=workdir,
|
|
146
|
+
# systemd templates don't reference LABEL_PREFIX, but the helper
|
|
147
|
+
# still needs the key for uniformity — pass an unused literal.
|
|
148
|
+
label_prefix="ctrlrelay",
|
|
149
|
+
poller_interval=poller_interval,
|
|
150
|
+
ctrlrelay_bin=ctrlrelay_bin,
|
|
151
|
+
)
|
|
152
|
+
target_dir = target_dir or Path.home() / ".config" / "systemd" / "user"
|
|
153
|
+
units: list[RenderedUnit] = []
|
|
154
|
+
for service in ("bridge", "poller"):
|
|
155
|
+
raw = _read_template("systemd", f"{service}.service.template")
|
|
156
|
+
rendered = _substitute(raw, values)
|
|
157
|
+
target = target_dir / f"ctrlrelay-{service}.service"
|
|
158
|
+
units.append(
|
|
159
|
+
RenderedUnit(
|
|
160
|
+
platform="systemd",
|
|
161
|
+
service=service,
|
|
162
|
+
content=rendered,
|
|
163
|
+
target_path=target,
|
|
164
|
+
unresolved=_missing_vars(rendered),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return units
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_units(
|
|
171
|
+
units: list[RenderedUnit],
|
|
172
|
+
*,
|
|
173
|
+
overwrite: bool = False,
|
|
174
|
+
) -> list[Path]:
|
|
175
|
+
"""Write rendered units to their target paths.
|
|
176
|
+
|
|
177
|
+
Refuses to clobber an existing file unless ``overwrite=True`` —
|
|
178
|
+
silently overwriting an operator's customised plist would be a foot-gun.
|
|
179
|
+
Returns the list of paths actually written. Raises ``FileExistsError``
|
|
180
|
+
if any target exists and overwrite is False.
|
|
181
|
+
|
|
182
|
+
Also ensures ``~/.ctrlrelay/logs/`` exists — both the launchd and
|
|
183
|
+
systemd templates set StandardOutput/StandardError paths under it,
|
|
184
|
+
and systemd refuses to start a unit at all (EXIT_STDOUT, code 209)
|
|
185
|
+
when the directory is missing; combined with ``Restart=always``
|
|
186
|
+
that's an immediate crash-loop (#137).
|
|
187
|
+
|
|
188
|
+
Unit files are chmod'd ``0600`` after writing since they embed
|
|
189
|
+
``CTRLRELAY_TELEGRAM_TOKEN`` as plaintext.
|
|
190
|
+
"""
|
|
191
|
+
(Path.home() / ".ctrlrelay" / "logs").mkdir(parents=True, exist_ok=True)
|
|
192
|
+
written: list[Path] = []
|
|
193
|
+
for unit in units:
|
|
194
|
+
unit.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
if unit.target_path.exists() and not overwrite:
|
|
196
|
+
raise FileExistsError(
|
|
197
|
+
f"refusing to overwrite {unit.target_path}; "
|
|
198
|
+
"pass --force to replace it"
|
|
199
|
+
)
|
|
200
|
+
unit.target_path.write_text(unit.content, encoding="utf-8")
|
|
201
|
+
unit.target_path.chmod(0o600)
|
|
202
|
+
written.append(unit.target_path)
|
|
203
|
+
return written
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Personalization sync — operator state shared across machines.
|
|
2
|
+
|
|
3
|
+
A separate (typically private) GitHub repo holds context that survives
|
|
4
|
+
across sessions and computers but doesn't belong inside any project's
|
|
5
|
+
own source tree: global Claude config, per-project memory, spec docs,
|
|
6
|
+
workspace planning notes. ctrlrelay clones this repo once per machine
|
|
7
|
+
and wires symlinks per the ``personalization.paths`` config so that
|
|
8
|
+
agent and operator writes flow straight into the working tree of the
|
|
9
|
+
checkout, ready to commit.
|
|
10
|
+
|
|
11
|
+
See ``manager.PersonalizationManager`` for the lifecycle.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ctrlrelay.personalization.manager import PersonalizationManager
|
|
15
|
+
from ctrlrelay.personalization.paths import (
|
|
16
|
+
TemplateContext,
|
|
17
|
+
encode_project_path,
|
|
18
|
+
resolve_template,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"PersonalizationManager",
|
|
23
|
+
"TemplateContext",
|
|
24
|
+
"encode_project_path",
|
|
25
|
+
"resolve_template",
|
|
26
|
+
]
|