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
ctrlrelay/setup.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""First-run setup: detect orgs, enumerate repos, write config, clone, optional daemon install.
|
|
2
|
+
|
|
3
|
+
Composes the building blocks operators previously had to wire together by
|
|
4
|
+
hand: gh-based owner discovery, per-owner repo enumeration with
|
|
5
|
+
archive/fork filters, ``orchestrator.yaml`` generation, per-repo git
|
|
6
|
+
clone using the same path resolver the dev pipeline uses, optional
|
|
7
|
+
personalization wiring, and optional launchd/systemd unit install.
|
|
8
|
+
|
|
9
|
+
The CLI wrapper in ``cli.py`` owns the interactive prompts. This module
|
|
10
|
+
takes a fully-resolved :class:`SetupOptions` and runs the work.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ctrlrelay.core.config import Config, load_config
|
|
22
|
+
from ctrlrelay.gh_protocol import detect_git_protocol, github_clone_url
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SetupOptions",
|
|
26
|
+
"SetupResult",
|
|
27
|
+
"PersonalizationPath",
|
|
28
|
+
"GhAuthError",
|
|
29
|
+
"VALID_TRANSPORTS",
|
|
30
|
+
"DEFAULT_CONFIG_OUT",
|
|
31
|
+
"DEFAULT_PERSONALIZATION_CHECKOUT",
|
|
32
|
+
"detect_owners",
|
|
33
|
+
"list_repos",
|
|
34
|
+
"detect_personalization_skills",
|
|
35
|
+
"build_orchestrator_yaml",
|
|
36
|
+
"clone_repos",
|
|
37
|
+
"run_setup",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Mirrors the keys accepted by ``TransportConfig.type`` in the schema.
|
|
42
|
+
# Keep these in sync if a new transport adapter ships.
|
|
43
|
+
VALID_TRANSPORTS = ("file_mock", "telegram")
|
|
44
|
+
|
|
45
|
+
# Default destination for ``--config-out``. The daemon plists shipped
|
|
46
|
+
# in ``src/ctrlrelay/templates/launchd|systemd/`` rely on the config
|
|
47
|
+
# being at one of the standard auto-discovery locations; pointing the
|
|
48
|
+
# operator at a non-default path while also installing daemons would
|
|
49
|
+
# orphan them, so setup refuses that combo (see ``run_setup``).
|
|
50
|
+
DEFAULT_CONFIG_OUT = Path("~/.config/ctrlrelay/orchestrator.yaml").expanduser()
|
|
51
|
+
|
|
52
|
+
# Mirrors ``Personalization.checkout_path``'s default. Setup pre-clones
|
|
53
|
+
# into this path before generating the YAML so we can scan for skill
|
|
54
|
+
# directories and wire them in the same setup run (see #129 follow-up).
|
|
55
|
+
DEFAULT_PERSONALIZATION_CHECKOUT = Path("~/.ctrlrelay/personalization").expanduser()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GhAuthError(RuntimeError):
|
|
59
|
+
"""Raised when ``gh auth status`` fails — setup needs an authenticated gh CLI."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SetupOptions:
|
|
64
|
+
"""Resolved choices for one ``ctrlrelay setup`` run.
|
|
65
|
+
|
|
66
|
+
The CLI fills these from a mix of flags and interactive prompts; the
|
|
67
|
+
setup logic doesn't care which path each value came from.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
owners: list[str] = field(default_factory=list)
|
|
71
|
+
skip_archived: bool = True
|
|
72
|
+
skip_forks: bool = True
|
|
73
|
+
repo_root: Path = field(
|
|
74
|
+
default_factory=lambda: Path("~/Projects").expanduser()
|
|
75
|
+
)
|
|
76
|
+
config_out: Path = field(
|
|
77
|
+
default_factory=lambda: Path("~/.config/ctrlrelay/orchestrator.yaml").expanduser()
|
|
78
|
+
)
|
|
79
|
+
timezone: str = "UTC"
|
|
80
|
+
transport: str = "file_mock" # or "telegram"
|
|
81
|
+
telegram_chat_id: int | None = None
|
|
82
|
+
telegram_token: str | None = None # only used when transport == "telegram"
|
|
83
|
+
personalization_repo: str | None = None # e.g. "alice/dotclaude"
|
|
84
|
+
# When True (the default) and ``personalization_repo`` is set,
|
|
85
|
+
# setup pre-clones the personalization repo and scans
|
|
86
|
+
# ``global/skills/<name>/`` for existing skill directories,
|
|
87
|
+
# wiring each as its own ``paths:`` entry. Skills already laid
|
|
88
|
+
# down by the operator on a prior machine then auto-sync to this
|
|
89
|
+
# one without a hand-edit.
|
|
90
|
+
wire_skills: bool = True
|
|
91
|
+
install_daemons: bool = False
|
|
92
|
+
force: bool = False
|
|
93
|
+
skip_clone: bool = False # for tests / dry-run scenarios
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PersonalizationPath:
|
|
98
|
+
"""One source/target pair for the personalization block.
|
|
99
|
+
|
|
100
|
+
Setup uses this to assemble the ``personalization.paths`` list:
|
|
101
|
+
the always-on ``global/CLAUDE.md`` entry plus any auto-detected
|
|
102
|
+
skills. Kept as a flat dataclass so tests can construct lists
|
|
103
|
+
without touching the YAML output format.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
source: str
|
|
107
|
+
target: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class SetupResult:
|
|
112
|
+
"""End-state summary returned by :func:`run_setup`."""
|
|
113
|
+
|
|
114
|
+
config_out: Path
|
|
115
|
+
owners: list[str]
|
|
116
|
+
n_repos: int
|
|
117
|
+
cloned: int
|
|
118
|
+
skipped: int
|
|
119
|
+
failed: int
|
|
120
|
+
personalization_summary: str | None = None
|
|
121
|
+
personalization_failed: bool = False
|
|
122
|
+
daemon_units: list[Path] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# gh helpers
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_gh(args: list[str]) -> str:
|
|
130
|
+
"""Run ``gh`` and return stdout. Captures stderr into the raised error."""
|
|
131
|
+
proc = subprocess.run(["gh", *args], capture_output=True, text=True)
|
|
132
|
+
if proc.returncode != 0:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f"gh {' '.join(args)} failed (exit {proc.returncode}): "
|
|
135
|
+
f"{proc.stderr.strip() or proc.stdout.strip()}"
|
|
136
|
+
)
|
|
137
|
+
return proc.stdout
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def assert_gh_auth() -> None:
|
|
141
|
+
"""Refuse to proceed without an authenticated gh CLI.
|
|
142
|
+
|
|
143
|
+
``gh auth status`` writes its result to stderr (success or failure), so we
|
|
144
|
+
just check the exit code rather than parsing the output.
|
|
145
|
+
"""
|
|
146
|
+
proc = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
raise GhAuthError(
|
|
149
|
+
"gh CLI is not authenticated. Run `gh auth login` and retry. "
|
|
150
|
+
f"(gh auth status: {proc.stderr.strip() or proc.stdout.strip()})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def detect_owners() -> list[str]:
|
|
155
|
+
"""Return ``[user_login, *org_logins]`` for the authenticated gh user.
|
|
156
|
+
|
|
157
|
+
The user's own login is included so personal-account repos are an
|
|
158
|
+
obvious option in the setup flow alongside any orgs they belong to.
|
|
159
|
+
"""
|
|
160
|
+
user = _run_gh(["api", "user", "--jq", ".login"]).strip()
|
|
161
|
+
orgs_raw = _run_gh(
|
|
162
|
+
["api", "user/orgs", "--paginate", "--jq", ".[].login"]
|
|
163
|
+
).strip()
|
|
164
|
+
orgs = [o for o in orgs_raw.splitlines() if o]
|
|
165
|
+
return [user, *orgs]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def list_repos(
|
|
169
|
+
owner: str, *, skip_archived: bool = True, skip_forks: bool = True
|
|
170
|
+
) -> list[dict]:
|
|
171
|
+
"""Return non-empty repos for ``owner``, applying the chosen filters.
|
|
172
|
+
|
|
173
|
+
Empty repos (no default branch) are always excluded — they can't be
|
|
174
|
+
cloned meaningfully, so adding them to ``repos:`` would just produce
|
|
175
|
+
poller errors.
|
|
176
|
+
"""
|
|
177
|
+
args = [
|
|
178
|
+
"repo", "list", owner,
|
|
179
|
+
"--limit", "1000",
|
|
180
|
+
"--json", "nameWithOwner,isFork,isEmpty,defaultBranchRef",
|
|
181
|
+
]
|
|
182
|
+
if skip_archived:
|
|
183
|
+
args.append("--no-archived")
|
|
184
|
+
if skip_forks:
|
|
185
|
+
# Exclude forks at the API level too. Without --source, gh
|
|
186
|
+
# returns forks; we'd still filter them in the loop below, but
|
|
187
|
+
# asking gh to omit them up-front halves the response payload
|
|
188
|
+
# for accounts with lots of fork noise.
|
|
189
|
+
args.append("--source")
|
|
190
|
+
raw = _run_gh(args)
|
|
191
|
+
data = json.loads(raw or "[]")
|
|
192
|
+
result: list[dict] = []
|
|
193
|
+
for r in data:
|
|
194
|
+
if skip_forks and r.get("isFork"):
|
|
195
|
+
continue
|
|
196
|
+
if r.get("isEmpty") or not r.get("defaultBranchRef"):
|
|
197
|
+
continue
|
|
198
|
+
result.append(r)
|
|
199
|
+
return sorted(result, key=lambda r: r["nameWithOwner"].lower())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# personalization helpers
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _ensure_personalization_clone(
|
|
207
|
+
repo: str, checkout: Path
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""Make sure ``checkout`` is a clone of ``github.com:<repo>.git``.
|
|
210
|
+
|
|
211
|
+
Returns True if the clone is present and matches ``repo`` (existing
|
|
212
|
+
or freshly created), False if the remote refused (auth error, repo
|
|
213
|
+
doesn't exist) OR the existing checkout points at a different repo.
|
|
214
|
+
Errors are swallowed so setup proceeds with the default
|
|
215
|
+
CLAUDE.md-only personalization paths — a missing or unrelated
|
|
216
|
+
remote shouldn't abort the whole onboarding flow, but we also must
|
|
217
|
+
not scan an unrelated working tree's skills and bake them into the
|
|
218
|
+
new config (codex review pass 2).
|
|
219
|
+
"""
|
|
220
|
+
if (checkout / ".git").is_dir():
|
|
221
|
+
return _checkout_matches_repo(checkout, repo)
|
|
222
|
+
checkout.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
# Match the protocol ``PersonalizationManager.repo_url`` will use
|
|
224
|
+
# (gh's configured git_protocol) so the pre-scan doesn't fail for
|
|
225
|
+
# SSH-only operators while the manager's own later clone succeeds
|
|
226
|
+
# (or vice versa) — see #137.
|
|
227
|
+
url = github_clone_url(repo, protocol=detect_git_protocol())
|
|
228
|
+
proc = subprocess.run(
|
|
229
|
+
["git", "clone", "--quiet", url, str(checkout)],
|
|
230
|
+
capture_output=True,
|
|
231
|
+
text=True,
|
|
232
|
+
)
|
|
233
|
+
return proc.returncode == 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _checkout_matches_repo(checkout: Path, repo: str) -> bool:
|
|
237
|
+
"""Return True iff the existing ``checkout``'s origin remote points
|
|
238
|
+
at ``github.com:<repo>``.
|
|
239
|
+
|
|
240
|
+
Accepts the three URL forms git emits for github.com remotes:
|
|
241
|
+
``https://github.com/owner/repo(.git)``,
|
|
242
|
+
``git@github.com:owner/repo(.git)``, and
|
|
243
|
+
``ssh://git@github.com/owner/repo(.git)``. Comparison is
|
|
244
|
+
case-insensitive — GitHub repo names are. Any other host or any
|
|
245
|
+
error reading the remote returns False (treated as "not ours").
|
|
246
|
+
"""
|
|
247
|
+
proc = subprocess.run(
|
|
248
|
+
["git", "-C", str(checkout), "remote", "get-url", "origin"],
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
)
|
|
252
|
+
if proc.returncode != 0:
|
|
253
|
+
return False
|
|
254
|
+
url = proc.stdout.strip()
|
|
255
|
+
expected = repo.lower()
|
|
256
|
+
# Strip the optional ``.git`` suffix and reduce each accepted URL
|
|
257
|
+
# form to ``owner/repo`` for the comparison.
|
|
258
|
+
if url.endswith(".git"):
|
|
259
|
+
url = url[: -len(".git")]
|
|
260
|
+
for prefix in (
|
|
261
|
+
"https://github.com/",
|
|
262
|
+
"ssh://git@github.com/",
|
|
263
|
+
"git@github.com:",
|
|
264
|
+
):
|
|
265
|
+
if url.startswith(prefix):
|
|
266
|
+
return url[len(prefix):].lower() == expected
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def detect_personalization_skills(checkout: Path) -> list[PersonalizationPath]:
|
|
271
|
+
"""Return a sorted list of per-skill ``paths:`` entries derived from
|
|
272
|
+
``<checkout>/global/skills/``.
|
|
273
|
+
|
|
274
|
+
Each direct subdirectory becomes one entry mapping
|
|
275
|
+
``global/skills/<name>/`` -> ``~/.claude/skills/<name>/``. Hidden
|
|
276
|
+
dirs (``.foo``) and files at the top level are skipped — only
|
|
277
|
+
actual skill packages count. Returns an empty list when the
|
|
278
|
+
directory is missing.
|
|
279
|
+
"""
|
|
280
|
+
skills_dir = checkout / "global" / "skills"
|
|
281
|
+
if not skills_dir.is_dir():
|
|
282
|
+
return []
|
|
283
|
+
paths: list[PersonalizationPath] = []
|
|
284
|
+
for entry in sorted(skills_dir.iterdir(), key=lambda p: p.name.lower()):
|
|
285
|
+
if not entry.is_dir() or entry.name.startswith("."):
|
|
286
|
+
continue
|
|
287
|
+
paths.append(
|
|
288
|
+
PersonalizationPath(
|
|
289
|
+
source=f"global/skills/{entry.name}/",
|
|
290
|
+
target=f"~/.claude/skills/{entry.name}/",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return paths
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# config generation
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_orchestrator_yaml(
|
|
301
|
+
options: SetupOptions,
|
|
302
|
+
repos_by_owner: dict[str, list[dict]],
|
|
303
|
+
personalization_paths: list[PersonalizationPath] | None = None,
|
|
304
|
+
) -> str:
|
|
305
|
+
"""Render the orchestrator.yaml as a string (no pyyaml — we want full
|
|
306
|
+
control over comments and key order).
|
|
307
|
+
|
|
308
|
+
``personalization_paths`` is the full list of ``paths:`` entries to
|
|
309
|
+
emit under the personalization block. When ``None`` and a
|
|
310
|
+
personalization repo is configured, defaults to the always-on
|
|
311
|
+
``global/CLAUDE.md`` mapping. Pass an explicit list to add detected
|
|
312
|
+
skills (see :func:`detect_personalization_skills`).
|
|
313
|
+
"""
|
|
314
|
+
lines: list[str] = []
|
|
315
|
+
a = lines.append
|
|
316
|
+
|
|
317
|
+
a("# ctrlrelay orchestrator configuration")
|
|
318
|
+
a("# Generated by `ctrlrelay setup`. Edit freely; re-running setup")
|
|
319
|
+
a("# refuses to overwrite without --force.")
|
|
320
|
+
a("")
|
|
321
|
+
a('version: "1"')
|
|
322
|
+
a(f'timezone: "{options.timezone}"')
|
|
323
|
+
a("")
|
|
324
|
+
a("paths:")
|
|
325
|
+
a(' state_db: "~/.ctrlrelay/state.db"')
|
|
326
|
+
a(' worktrees: "~/.ctrlrelay/worktrees"')
|
|
327
|
+
a(' bare_repos: "~/.ctrlrelay/repos"')
|
|
328
|
+
a(' contexts: "~/.ctrlrelay/contexts"')
|
|
329
|
+
a(' skills: "~/.claude/skills"')
|
|
330
|
+
a(f' repo_root: "{_yaml_escape(str(options.repo_root))}"')
|
|
331
|
+
a("")
|
|
332
|
+
a("agent:")
|
|
333
|
+
a(' type: "claude"')
|
|
334
|
+
a(' binary: "claude"')
|
|
335
|
+
a(" default_timeout_seconds: 1800")
|
|
336
|
+
a(' output_format: "json"')
|
|
337
|
+
a("")
|
|
338
|
+
a("transport:")
|
|
339
|
+
if options.transport == "telegram":
|
|
340
|
+
a(' type: "telegram"')
|
|
341
|
+
a(" telegram:")
|
|
342
|
+
a(' bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"')
|
|
343
|
+
a(f" chat_id: {options.telegram_chat_id or 0}")
|
|
344
|
+
a(' socket_path: "~/.ctrlrelay/ctrlrelay.sock"')
|
|
345
|
+
a(" # Seconds a pipeline waits on your reply before giving up")
|
|
346
|
+
a(" # and persisting the session as BLOCKED. Only the in-session")
|
|
347
|
+
a(" # fast path - a later reply still resolves via the saved")
|
|
348
|
+
a(" # session. Don't raise this to cover being asleep: a sweep")
|
|
349
|
+
a(" # runs repos one at a time and holds each lock while it waits.")
|
|
350
|
+
a(" ask_timeout_seconds: 900")
|
|
351
|
+
else:
|
|
352
|
+
a(' type: "file_mock"')
|
|
353
|
+
a(" file_mock:")
|
|
354
|
+
a(' inbox: "~/.ctrlrelay/inbox.txt"')
|
|
355
|
+
a(' outbox: "~/.ctrlrelay/outbox.txt"')
|
|
356
|
+
a("")
|
|
357
|
+
a("dashboard:")
|
|
358
|
+
a(" enabled: false")
|
|
359
|
+
a("")
|
|
360
|
+
a("schedules:")
|
|
361
|
+
a(' secops_cron: "0 6 * * *"')
|
|
362
|
+
a("")
|
|
363
|
+
if options.personalization_repo:
|
|
364
|
+
if personalization_paths is None:
|
|
365
|
+
personalization_paths = [
|
|
366
|
+
PersonalizationPath(
|
|
367
|
+
source="global/CLAUDE.md",
|
|
368
|
+
target="~/.claude/CLAUDE.md",
|
|
369
|
+
)
|
|
370
|
+
]
|
|
371
|
+
a("personalization:")
|
|
372
|
+
a(f' repo: "{options.personalization_repo}"')
|
|
373
|
+
a(" paths:")
|
|
374
|
+
for entry in personalization_paths:
|
|
375
|
+
a(f' - source: "{_yaml_escape(entry.source)}"')
|
|
376
|
+
a(f' target: "{_yaml_escape(entry.target)}"')
|
|
377
|
+
a("")
|
|
378
|
+
a("# Repos discovered via `gh repo list`. Filters applied:")
|
|
379
|
+
a(
|
|
380
|
+
f"# skip_archived={options.skip_archived}, "
|
|
381
|
+
f"skip_forks={options.skip_forks}. Empty repos always skipped."
|
|
382
|
+
)
|
|
383
|
+
total_repos = sum(len(rs) for rs in repos_by_owner.values())
|
|
384
|
+
if total_repos == 0:
|
|
385
|
+
# An empty mapping must serialize as ``repos: []``. A bare
|
|
386
|
+
# ``repos:`` with only comment children parses as null and
|
|
387
|
+
# the Pydantic schema rejects it.
|
|
388
|
+
a("repos: []")
|
|
389
|
+
else:
|
|
390
|
+
a("repos:")
|
|
391
|
+
for owner in options.owners:
|
|
392
|
+
repos = repos_by_owner.get(owner, [])
|
|
393
|
+
a(f" # --- {owner} ({len(repos)} repo(s)) ---")
|
|
394
|
+
for r in repos:
|
|
395
|
+
a(f' - name: "{r["nameWithOwner"]}"')
|
|
396
|
+
a(" automation:")
|
|
397
|
+
a(" dependabot_patch: auto")
|
|
398
|
+
a(" dependabot_minor: ask")
|
|
399
|
+
a(" dependabot_major: never")
|
|
400
|
+
return "\n".join(lines) + "\n"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _yaml_escape(value: str) -> str:
|
|
404
|
+
"""Minimal escape for double-quoted YAML scalars."""
|
|
405
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
# clone
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def clone_repos(config: Config) -> tuple[int, int, int]:
|
|
413
|
+
"""Clone every configured repo to its resolved ``local_path``.
|
|
414
|
+
|
|
415
|
+
Returns ``(cloned, skipped, failed)``. Idempotent: a repo whose
|
|
416
|
+
target already has a ``.git`` is treated as already-cloned and
|
|
417
|
+
counted under ``skipped``.
|
|
418
|
+
"""
|
|
419
|
+
cloned = skipped = failed = 0
|
|
420
|
+
for r in config.repos:
|
|
421
|
+
target = Path(str(r.local_path)).expanduser()
|
|
422
|
+
if (target / ".git").is_dir():
|
|
423
|
+
skipped += 1
|
|
424
|
+
continue
|
|
425
|
+
if target.exists() and any(target.iterdir()):
|
|
426
|
+
failed += 1
|
|
427
|
+
continue
|
|
428
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
proc = subprocess.run(
|
|
430
|
+
["git", "clone", "--quiet", f"git@github.com:{r.name}.git", str(target)],
|
|
431
|
+
capture_output=True,
|
|
432
|
+
text=True,
|
|
433
|
+
)
|
|
434
|
+
if proc.returncode == 0:
|
|
435
|
+
cloned += 1
|
|
436
|
+
else:
|
|
437
|
+
failed += 1
|
|
438
|
+
return cloned, skipped, failed
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# top-level driver
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def run_setup(options: SetupOptions) -> SetupResult:
|
|
446
|
+
"""Execute the full setup flow against the given options.
|
|
447
|
+
|
|
448
|
+
Order:
|
|
449
|
+
1. Refuse if ``config_out`` exists and ``force`` is False — protects
|
|
450
|
+
a hand-tuned operator config from being clobbered.
|
|
451
|
+
2. Verify ``gh`` is authenticated.
|
|
452
|
+
3. Per owner: enumerate repos via ``gh repo list``.
|
|
453
|
+
4. Render and write ``orchestrator.yaml``.
|
|
454
|
+
5. Validate by reloading through the Pydantic schema.
|
|
455
|
+
6. Clone every repo to its resolved ``local_path`` (skipped when
|
|
456
|
+
``options.skip_clone`` is set).
|
|
457
|
+
7. Optionally call ``personalization init`` if a repo was set.
|
|
458
|
+
8. Optionally render and write daemon unit files.
|
|
459
|
+
"""
|
|
460
|
+
if options.transport not in VALID_TRANSPORTS:
|
|
461
|
+
# Reject mistypes loudly. Pre-fix, an unknown transport silently
|
|
462
|
+
# fell through to the file_mock branch in build_orchestrator_yaml,
|
|
463
|
+
# so an operator typing ``--transport telegrm`` would land a
|
|
464
|
+
# file_mock config without realising. Codex review pass 2 caught
|
|
465
|
+
# this — the lookup must match TransportConfig.type's enum.
|
|
466
|
+
raise ValueError(
|
|
467
|
+
f"unknown transport {options.transport!r}; "
|
|
468
|
+
f"expected one of {', '.join(VALID_TRANSPORTS)}"
|
|
469
|
+
)
|
|
470
|
+
if options.install_daemons and options.config_out != DEFAULT_CONFIG_OUT:
|
|
471
|
+
# The shipped launchd/systemd templates don't carry a
|
|
472
|
+
# CTRLRELAY_CONFIG environment variable. A daemon started from
|
|
473
|
+
# them auto-discovers via the default search path, which
|
|
474
|
+
# wouldn't find a config dropped at e.g. ``/srv/ctrlrelay.yaml``.
|
|
475
|
+
# Refuse the combo so the operator splits the steps explicitly.
|
|
476
|
+
# Codex review pass 2 caught this orphan-daemon footgun.
|
|
477
|
+
raise ValueError(
|
|
478
|
+
f"--install-daemons requires the default --config-out "
|
|
479
|
+
f"({DEFAULT_CONFIG_OUT}); got {options.config_out}. Either "
|
|
480
|
+
"drop --config-out or run `ctrlrelay install launchd|systemd` "
|
|
481
|
+
"manually after editing the rendered plists to set "
|
|
482
|
+
"CTRLRELAY_CONFIG."
|
|
483
|
+
)
|
|
484
|
+
if options.config_out.exists() and not options.force:
|
|
485
|
+
raise FileExistsError(
|
|
486
|
+
f"{options.config_out} already exists; pass --force to overwrite"
|
|
487
|
+
)
|
|
488
|
+
assert_gh_auth()
|
|
489
|
+
|
|
490
|
+
if not options.owners:
|
|
491
|
+
raise ValueError("setup requires at least one owner")
|
|
492
|
+
|
|
493
|
+
repos_by_owner: dict[str, list[dict]] = {}
|
|
494
|
+
for owner in options.owners:
|
|
495
|
+
repos = list_repos(
|
|
496
|
+
owner, skip_archived=options.skip_archived, skip_forks=options.skip_forks
|
|
497
|
+
)
|
|
498
|
+
# Drop the personalization repo if it surfaced under one of the
|
|
499
|
+
# configured owners. It's the cross-machine sync target, not a
|
|
500
|
+
# project the dev pipeline should monitor for issues — listing
|
|
501
|
+
# it under ``repos:`` alongside the per-machine personalization
|
|
502
|
+
# checkout would have ctrlrelay polling and worktree-cloning
|
|
503
|
+
# the operator's own dotfiles. Comparison is case-insensitive
|
|
504
|
+
# because GitHub repo names are case-insensitive: ``alice/foo``
|
|
505
|
+
# and ``Alice/Foo`` resolve to the same repo, so an operator
|
|
506
|
+
# passing one casing while ``gh repo list`` returned the other
|
|
507
|
+
# would otherwise dodge the filter (codex review pass 1).
|
|
508
|
+
if options.personalization_repo:
|
|
509
|
+
personalization_norm = options.personalization_repo.lower()
|
|
510
|
+
repos = [
|
|
511
|
+
r for r in repos
|
|
512
|
+
if r["nameWithOwner"].lower() != personalization_norm
|
|
513
|
+
]
|
|
514
|
+
repos_by_owner[owner] = repos
|
|
515
|
+
|
|
516
|
+
# Build the personalization paths list BEFORE writing the YAML so
|
|
517
|
+
# auto-detected skills end up baked into the file the operator
|
|
518
|
+
# eventually edits. Pre-clone the personalization repo if needed
|
|
519
|
+
# (it might already exist from a prior setup run on this machine).
|
|
520
|
+
personalization_paths: list[PersonalizationPath] | None = None
|
|
521
|
+
if options.personalization_repo:
|
|
522
|
+
personalization_paths = [
|
|
523
|
+
PersonalizationPath(
|
|
524
|
+
source="global/CLAUDE.md",
|
|
525
|
+
target="~/.claude/CLAUDE.md",
|
|
526
|
+
)
|
|
527
|
+
]
|
|
528
|
+
if options.wire_skills:
|
|
529
|
+
cloned_for_scan = _ensure_personalization_clone(
|
|
530
|
+
options.personalization_repo, DEFAULT_PERSONALIZATION_CHECKOUT
|
|
531
|
+
)
|
|
532
|
+
if cloned_for_scan:
|
|
533
|
+
personalization_paths.extend(
|
|
534
|
+
detect_personalization_skills(DEFAULT_PERSONALIZATION_CHECKOUT)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
yaml_text = build_orchestrator_yaml(
|
|
538
|
+
options, repos_by_owner, personalization_paths=personalization_paths
|
|
539
|
+
)
|
|
540
|
+
options.config_out.parent.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
options.config_out.write_text(yaml_text)
|
|
542
|
+
|
|
543
|
+
config = load_config(options.config_out)
|
|
544
|
+
|
|
545
|
+
cloned = skipped = failed = 0
|
|
546
|
+
if not options.skip_clone:
|
|
547
|
+
cloned, skipped, failed = clone_repos(config)
|
|
548
|
+
|
|
549
|
+
personalization_summary: str | None = None
|
|
550
|
+
personalization_failed = False
|
|
551
|
+
if options.personalization_repo:
|
|
552
|
+
from ctrlrelay.personalization import PersonalizationManager
|
|
553
|
+
from ctrlrelay.personalization.manager import PersonalizationError
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
mgr = PersonalizationManager(config)
|
|
557
|
+
personalization_summary = mgr.init(adopt=True)
|
|
558
|
+
except PersonalizationError as e:
|
|
559
|
+
personalization_summary = f"(failed: {e})"
|
|
560
|
+
personalization_failed = True
|
|
561
|
+
|
|
562
|
+
daemon_units: list[Path] = []
|
|
563
|
+
if options.install_daemons:
|
|
564
|
+
daemon_units = _install_daemons(options)
|
|
565
|
+
|
|
566
|
+
return SetupResult(
|
|
567
|
+
config_out=options.config_out,
|
|
568
|
+
owners=list(options.owners),
|
|
569
|
+
n_repos=sum(len(rs) for rs in repos_by_owner.values()),
|
|
570
|
+
cloned=cloned,
|
|
571
|
+
skipped=skipped,
|
|
572
|
+
failed=failed,
|
|
573
|
+
personalization_summary=personalization_summary,
|
|
574
|
+
personalization_failed=personalization_failed,
|
|
575
|
+
daemon_units=daemon_units,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _install_daemons(options: SetupOptions) -> list[Path]:
|
|
580
|
+
"""Render and write launchd or systemd unit files based on platform.
|
|
581
|
+
|
|
582
|
+
Sets ``CTRLRELAY_TELEGRAM_TOKEN`` from ``options.telegram_token`` so
|
|
583
|
+
the rendered unit doesn't carry the literal ``${...}`` placeholder.
|
|
584
|
+
Returns the list of paths written so the CLI can print them.
|
|
585
|
+
"""
|
|
586
|
+
import sys
|
|
587
|
+
|
|
588
|
+
from ctrlrelay.install import render_launchd, render_systemd, write_units
|
|
589
|
+
|
|
590
|
+
# Restore the token to the env so render_* picks it up. We don't
|
|
591
|
+
# leak it back to the caller's env — restore the prior value at the
|
|
592
|
+
# end so a parent shell that was already exporting a different
|
|
593
|
+
# value isn't disturbed.
|
|
594
|
+
prior_token = os.environ.get("CTRLRELAY_TELEGRAM_TOKEN")
|
|
595
|
+
if options.transport == "telegram" and options.telegram_token:
|
|
596
|
+
os.environ["CTRLRELAY_TELEGRAM_TOKEN"] = options.telegram_token
|
|
597
|
+
|
|
598
|
+
# Use the operator's home as the daemon working directory. Stable,
|
|
599
|
+
# always exists, doesn't tie service lifetime to a particular project
|
|
600
|
+
# checkout. Operators who want a different workdir can re-run
|
|
601
|
+
# `ctrlrelay install launchd --workdir <path> --force` later.
|
|
602
|
+
workdir = Path.home()
|
|
603
|
+
try:
|
|
604
|
+
if sys.platform == "darwin":
|
|
605
|
+
units = render_launchd(
|
|
606
|
+
workdir=workdir, label_prefix="com.ctrlrelay"
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
units = render_systemd(workdir=workdir)
|
|
610
|
+
written = write_units(units, overwrite=options.force)
|
|
611
|
+
finally:
|
|
612
|
+
if prior_token is None:
|
|
613
|
+
os.environ.pop("CTRLRELAY_TELEGRAM_TOKEN", None)
|
|
614
|
+
else:
|
|
615
|
+
os.environ["CTRLRELAY_TELEGRAM_TOKEN"] = prior_token
|
|
616
|
+
|
|
617
|
+
return written
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Append the block below to your global ~/.claude/CLAUDE.md. It establishes
|
|
3
|
+
a filing convention for spec docs and superpower outputs that pairs with
|
|
4
|
+
ctrlrelay's personalization sync — Claude writes to predictable paths,
|
|
5
|
+
ctrlrelay's `paths` config picks them up by default, and nothing pollutes
|
|
6
|
+
the project's own git repo.
|
|
7
|
+
|
|
8
|
+
The placeholders below (<project>, <project-parent>, <workspace>) are
|
|
9
|
+
illustrative for human readers. Claude resolves them from the cwd it
|
|
10
|
+
was launched in; ctrlrelay's `${PROJECT}`, `${PROJECT_PARENT}`, etc.
|
|
11
|
+
resolve them from `RepoConfig.local_path` at symlink-time.
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
## Filing convention for generated artifacts
|
|
15
|
+
|
|
16
|
+
When you produce artifacts that should outlive a single conversation (spec
|
|
17
|
+
docs, design notes, superpower definitions, multi-step plans, runbooks),
|
|
18
|
+
follow these paths so they sync across machines via ctrlrelay's
|
|
19
|
+
personalization repo and stay out of the project's own git history.
|
|
20
|
+
|
|
21
|
+
- **Per-project specs and superpower outputs** — write to:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
<project-parent>/specs/<owner>--<project-name>/<topic>.md
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The `<owner>--<project-name>` (DOUBLE hyphen) flat slug matches the
|
|
28
|
+
`${PROJECT}` placeholder in `orchestrator.yaml` (e.g.
|
|
29
|
+
`AInvirion--ctrlrelay`), so the file lands inside the directory ctrlrelay
|
|
30
|
+
symlinks into the personalization repo. Double hyphen avoids collisions
|
|
31
|
+
like `a-b/c` vs `a/b-c` flattening to the same single-hyphen string.
|
|
32
|
+
Using the bare repo name (e.g. just `ctrlrelay`) instead would write
|
|
33
|
+
outside the symlinked dir and the artifact would never sync.
|
|
34
|
+
|
|
35
|
+
Example: working in `~/Projects/AINVIRION/ctrlrelay`, a feature spec for
|
|
36
|
+
"personalization-sync" goes in
|
|
37
|
+
`~/Projects/AINVIRION/specs/AInvirion--ctrlrelay/personalization-sync.md`.
|
|
38
|
+
Never inside the project repo itself — keeps the project's `git status`
|
|
39
|
+
and PR diff clean and avoids leaking instructions/strategy notes if the
|
|
40
|
+
repo is shared or made public.
|
|
41
|
+
|
|
42
|
+
- **Multi-repo workspace planning docs** — write to:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
<workspace>/notes/<topic>.md
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Example: `~/Projects/RESEARCH/DMP/notes/RELEASE_ROADMAP.md`. Use this
|
|
49
|
+
for content that spans several repos under one parent directory.
|
|
50
|
+
|
|
51
|
+
- **Per-project Claude memory** — keep using
|
|
52
|
+
`~/.claude/projects/<encoded>/memory/` as you do now; ctrlrelay
|
|
53
|
+
manages the symlink to the personalization repo.
|
|
54
|
+
|
|
55
|
+
- **Global Claude config (this file, skills, agents, commands)** —
|
|
56
|
+
edit normally under `~/.claude/`; ctrlrelay's symlinks make those
|
|
57
|
+
edits land in the personalization repo for sync.
|
|
58
|
+
|
|
59
|
+
If you produce an artifact and these paths don't fit, ASK before writing
|
|
60
|
+
it inside a project's source tree. The default answer is "outside the
|
|
61
|
+
project, in the workspace's `specs/` or `notes/`."
|
|
File without changes
|