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.
Files changed (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. 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