ctrlrelay 0.4.0__tar.gz → 0.4.1__tar.gz

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 (112) hide show
  1. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/CHANGELOG.md +28 -0
  2. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/pyproject.toml +1 -1
  4. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/cli.py +10 -0
  5. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/setup.py +185 -5
  6. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_setup.py +280 -0
  7. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  8. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  9. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  10. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/dependabot.yml +0 -0
  11. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/build.yml +0 -0
  12. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/cla.yml +0 -0
  13. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/pages.yml +0 -0
  14. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/publish.yml +0 -0
  15. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/test.yml +0 -0
  16. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.gitignore +0 -0
  17. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/CODE_OF_CONDUCT.md +0 -0
  18. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/CONTRIBUTING.md +0 -0
  19. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/LICENSE +0 -0
  20. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/README.md +0 -0
  21. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/SECURITY.md +0 -0
  22. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/config/orchestrator.yaml.example +0 -0
  23. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/Gemfile +0 -0
  24. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/_config.yml +0 -0
  25. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/architecture.md +0 -0
  26. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/bridge.md +0 -0
  27. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/cli.md +0 -0
  28. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/configuration.md +0 -0
  29. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/development.md +0 -0
  30. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/feedback-loop.md +0 -0
  31. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/getting-started.md +0 -0
  32. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/index.md +0 -0
  33. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/operations.md +0 -0
  34. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/personalization.md +0 -0
  35. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/__init__.py +0 -0
  36. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  37. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  38. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  39. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/server.py +0 -0
  40. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  41. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/__init__.py +0 -0
  42. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/audit.py +0 -0
  43. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  44. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/config.py +0 -0
  45. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  46. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/github.py +0 -0
  47. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/obs.py +0 -0
  48. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/poller.py +0 -0
  49. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  50. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  51. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/scheduler.py +0 -0
  52. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/state.py +0 -0
  53. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/worktree.py +0 -0
  54. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  55. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/dashboard/client.py +0 -0
  56. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/install.py +0 -0
  57. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  58. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/personalization/manager.py +0 -0
  59. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/personalization/paths.py +0 -0
  60. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  61. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/base.py +0 -0
  62. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/dev.py +0 -0
  63. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  64. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/secops.py +0 -0
  65. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/task.py +0 -0
  66. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/__init__.py +0 -0
  67. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  68. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  69. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  70. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  71. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  72. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  73. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  74. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/__init__.py +0 -0
  75. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/base.py +0 -0
  76. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  77. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  78. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/__init__.py +0 -0
  79. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/conftest.py +0 -0
  80. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_audit.py +0 -0
  81. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_bridge_protocol.py +0 -0
  82. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_bridge_server.py +0 -0
  83. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_checkpoint.py +0 -0
  84. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_ci_wait.py +0 -0
  85. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_dev.py +0 -0
  86. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_repos.py +0 -0
  87. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_secops.py +0 -0
  88. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_start.py +0 -0
  89. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_version.py +0 -0
  90. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_config.py +0 -0
  91. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dashboard_client.py +0 -0
  92. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dev_integration.py +0 -0
  93. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dev_pipeline.py +0 -0
  94. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dispatcher.py +0 -0
  95. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_docs_site.py +0 -0
  96. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_github.py +0 -0
  97. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_install.py +0 -0
  98. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_obs.py +0 -0
  99. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_personalization.py +0 -0
  100. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_pipeline_base.py +0 -0
  101. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_poller.py +0 -0
  102. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_post_merge.py +0 -0
  103. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_pr_verifier.py +0 -0
  104. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_pr_watcher.py +0 -0
  105. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_scheduler.py +0 -0
  106. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_secops_integration.py +0 -0
  107. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_secops_pipeline.py +0 -0
  108. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_state.py +0 -0
  109. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_task_pipeline.py +0 -0
  110. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_telegram_handler.py +0 -0
  111. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_transport.py +0 -0
  112. {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_worktree.py +0 -0
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-05-08
11
+
12
+ Patch release. Two follow-ups against the v0.4.0 setup flow surfaced
13
+ during the dogfood validation:
14
+
15
+ ### Fixed
16
+
17
+ - **`setup` no longer lists the personalization repo under
18
+ `repos:`.** When the operator's personalization repo lives under
19
+ one of the configured owners (e.g. `alice/dotclaude` while `alice`
20
+ is also an enumerated owner), it was being added to the dev
21
+ pipeline's monitored set — the poller would have polled it for
22
+ issues and worktree-cloned it. The repo is the cross-machine sync
23
+ target, not a project; setup now drops it from the enumerated list
24
+ before generating the YAML.
25
+
26
+ ### Added
27
+
28
+ - **Auto-wire detected skills on `setup`.** When the personalization
29
+ repo already contains `global/skills/<name>/` directories (e.g.
30
+ from a prior `personalization push` on another machine), setup now
31
+ pre-clones the repo, scans for skill subdirectories, and adds one
32
+ `paths:` entry per skill to the generated `personalization.paths`
33
+ block. Operators don't have to hand-edit the config to wire each
34
+ skill back on a new machine. Pass `--no-wire-skills` to opt out.
35
+ Hidden directories and stray top-level files are ignored — only
36
+ real skill packages count.
37
+
10
38
  ## [0.4.0] - 2026-05-08
11
39
 
12
40
  Minor release. One new feature plus one schema simplification:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrlrelay
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -2252,6 +2252,15 @@ def setup(
2252
2252
  "--no-personalization",
2253
2253
  help="Skip the personalization prompt entirely (non-interactive mode).",
2254
2254
  ),
2255
+ wire_skills: bool = typer.Option(
2256
+ True,
2257
+ "--wire-skills/--no-wire-skills",
2258
+ help=(
2259
+ "When the personalization repo already contains "
2260
+ "global/skills/<name>/ directories, add a paths: entry per "
2261
+ "skill so they sync to ~/.claude/skills/<name>/. Default: on."
2262
+ ),
2263
+ ),
2255
2264
  install_daemons: bool = typer.Option(
2256
2265
  False,
2257
2266
  "--install-daemons",
@@ -2412,6 +2421,7 @@ def setup(
2412
2421
  telegram_chat_id=telegram_chat_id,
2413
2422
  telegram_token=token,
2414
2423
  personalization_repo=chosen_personalization,
2424
+ wire_skills=wire_skills,
2415
2425
  install_daemons=chosen_install_daemons,
2416
2426
  skip_archived=skip_archived,
2417
2427
  skip_forks=skip_forks,
@@ -23,11 +23,14 @@ from ctrlrelay.core.config import Config, load_config
23
23
  __all__ = [
24
24
  "SetupOptions",
25
25
  "SetupResult",
26
+ "PersonalizationPath",
26
27
  "GhAuthError",
27
28
  "VALID_TRANSPORTS",
28
29
  "DEFAULT_CONFIG_OUT",
30
+ "DEFAULT_PERSONALIZATION_CHECKOUT",
29
31
  "detect_owners",
30
32
  "list_repos",
33
+ "detect_personalization_skills",
31
34
  "build_orchestrator_yaml",
32
35
  "clone_repos",
33
36
  "run_setup",
@@ -45,6 +48,11 @@ VALID_TRANSPORTS = ("file_mock", "telegram")
45
48
  # orphan them, so setup refuses that combo (see ``run_setup``).
46
49
  DEFAULT_CONFIG_OUT = Path("~/.config/ctrlrelay/orchestrator.yaml").expanduser()
47
50
 
51
+ # Mirrors ``Personalization.checkout_path``'s default. Setup pre-clones
52
+ # into this path before generating the YAML so we can scan for skill
53
+ # directories and wire them in the same setup run (see #129 follow-up).
54
+ DEFAULT_PERSONALIZATION_CHECKOUT = Path("~/.ctrlrelay/personalization").expanduser()
55
+
48
56
 
49
57
  class GhAuthError(RuntimeError):
50
58
  """Raised when ``gh auth status`` fails — setup needs an authenticated gh CLI."""
@@ -72,11 +80,32 @@ class SetupOptions:
72
80
  telegram_chat_id: int | None = None
73
81
  telegram_token: str | None = None # only used when transport == "telegram"
74
82
  personalization_repo: str | None = None # e.g. "alice/dotclaude"
83
+ # When True (the default) and ``personalization_repo`` is set,
84
+ # setup pre-clones the personalization repo and scans
85
+ # ``global/skills/<name>/`` for existing skill directories,
86
+ # wiring each as its own ``paths:`` entry. Skills already laid
87
+ # down by the operator on a prior machine then auto-sync to this
88
+ # one without a hand-edit.
89
+ wire_skills: bool = True
75
90
  install_daemons: bool = False
76
91
  force: bool = False
77
92
  skip_clone: bool = False # for tests / dry-run scenarios
78
93
 
79
94
 
95
+ @dataclass
96
+ class PersonalizationPath:
97
+ """One source/target pair for the personalization block.
98
+
99
+ Setup uses this to assemble the ``personalization.paths`` list:
100
+ the always-on ``global/CLAUDE.md`` entry plus any auto-detected
101
+ skills. Kept as a flat dataclass so tests can construct lists
102
+ without touching the YAML output format.
103
+ """
104
+
105
+ source: str
106
+ target: str
107
+
108
+
80
109
  @dataclass
81
110
  class SetupResult:
82
111
  """End-state summary returned by :func:`run_setup`."""
@@ -168,15 +197,118 @@ def list_repos(
168
197
  return sorted(result, key=lambda r: r["nameWithOwner"].lower())
169
198
 
170
199
 
200
+ # ---------------------------------------------------------------------------
201
+ # personalization helpers
202
+
203
+
204
+ def _ensure_personalization_clone(
205
+ repo: str, checkout: Path
206
+ ) -> bool:
207
+ """Make sure ``checkout`` is a clone of ``github.com:<repo>.git``.
208
+
209
+ Returns True if the clone is present and matches ``repo`` (existing
210
+ or freshly created), False if the remote refused (auth error, repo
211
+ doesn't exist) OR the existing checkout points at a different repo.
212
+ Errors are swallowed so setup proceeds with the default
213
+ CLAUDE.md-only personalization paths — a missing or unrelated
214
+ remote shouldn't abort the whole onboarding flow, but we also must
215
+ not scan an unrelated working tree's skills and bake them into the
216
+ new config (codex review pass 2).
217
+ """
218
+ if (checkout / ".git").is_dir():
219
+ return _checkout_matches_repo(checkout, repo)
220
+ checkout.parent.mkdir(parents=True, exist_ok=True)
221
+ # Use HTTPS to match ``PersonalizationManager.repo_url`` so the
222
+ # pre-scan works for operators authenticated to GitHub via gh
223
+ # tokens / HTTPS without an SSH key on the machine. Codex review
224
+ # pass 3 caught the SSH/HTTPS mismatch — pre-scan would silently
225
+ # fail, the manager's own init() would later succeed via HTTPS,
226
+ # and the auto-wire feature would be bypassed in a common setup.
227
+ proc = subprocess.run(
228
+ ["git", "clone", "--quiet", f"https://github.com/{repo}.git", str(checkout)],
229
+ capture_output=True,
230
+ text=True,
231
+ )
232
+ return proc.returncode == 0
233
+
234
+
235
+ def _checkout_matches_repo(checkout: Path, repo: str) -> bool:
236
+ """Return True iff the existing ``checkout``'s origin remote points
237
+ at ``github.com:<repo>``.
238
+
239
+ Accepts the three URL forms git emits for github.com remotes:
240
+ ``https://github.com/owner/repo(.git)``,
241
+ ``git@github.com:owner/repo(.git)``, and
242
+ ``ssh://git@github.com/owner/repo(.git)``. Comparison is
243
+ case-insensitive — GitHub repo names are. Any other host or any
244
+ error reading the remote returns False (treated as "not ours").
245
+ """
246
+ proc = subprocess.run(
247
+ ["git", "-C", str(checkout), "remote", "get-url", "origin"],
248
+ capture_output=True,
249
+ text=True,
250
+ )
251
+ if proc.returncode != 0:
252
+ return False
253
+ url = proc.stdout.strip()
254
+ expected = repo.lower()
255
+ # Strip the optional ``.git`` suffix and reduce each accepted URL
256
+ # form to ``owner/repo`` for the comparison.
257
+ if url.endswith(".git"):
258
+ url = url[: -len(".git")]
259
+ for prefix in (
260
+ "https://github.com/",
261
+ "ssh://git@github.com/",
262
+ "git@github.com:",
263
+ ):
264
+ if url.startswith(prefix):
265
+ return url[len(prefix):].lower() == expected
266
+ return False
267
+
268
+
269
+ def detect_personalization_skills(checkout: Path) -> list[PersonalizationPath]:
270
+ """Return a sorted list of per-skill ``paths:`` entries derived from
271
+ ``<checkout>/global/skills/``.
272
+
273
+ Each direct subdirectory becomes one entry mapping
274
+ ``global/skills/<name>/`` -> ``~/.claude/skills/<name>/``. Hidden
275
+ dirs (``.foo``) and files at the top level are skipped — only
276
+ actual skill packages count. Returns an empty list when the
277
+ directory is missing.
278
+ """
279
+ skills_dir = checkout / "global" / "skills"
280
+ if not skills_dir.is_dir():
281
+ return []
282
+ paths: list[PersonalizationPath] = []
283
+ for entry in sorted(skills_dir.iterdir(), key=lambda p: p.name.lower()):
284
+ if not entry.is_dir() or entry.name.startswith("."):
285
+ continue
286
+ paths.append(
287
+ PersonalizationPath(
288
+ source=f"global/skills/{entry.name}/",
289
+ target=f"~/.claude/skills/{entry.name}/",
290
+ )
291
+ )
292
+ return paths
293
+
294
+
171
295
  # ---------------------------------------------------------------------------
172
296
  # config generation
173
297
 
174
298
 
175
299
  def build_orchestrator_yaml(
176
- options: SetupOptions, repos_by_owner: dict[str, list[dict]]
300
+ options: SetupOptions,
301
+ repos_by_owner: dict[str, list[dict]],
302
+ personalization_paths: list[PersonalizationPath] | None = None,
177
303
  ) -> str:
178
304
  """Render the orchestrator.yaml as a string (no pyyaml — we want full
179
305
  control over comments and key order).
306
+
307
+ ``personalization_paths`` is the full list of ``paths:`` entries to
308
+ emit under the personalization block. When ``None`` and a
309
+ personalization repo is configured, defaults to the always-on
310
+ ``global/CLAUDE.md`` mapping. Pass an explicit list to add detected
311
+ skills (see :func:`detect_personalization_skills`).
180
312
  """
181
313
  lines: list[str] = []
182
314
  a = lines.append
@@ -222,11 +354,19 @@ def build_orchestrator_yaml(
222
354
  a(' secops_cron: "0 6 * * *"')
223
355
  a("")
224
356
  if options.personalization_repo:
357
+ if personalization_paths is None:
358
+ personalization_paths = [
359
+ PersonalizationPath(
360
+ source="global/CLAUDE.md",
361
+ target="~/.claude/CLAUDE.md",
362
+ )
363
+ ]
225
364
  a("personalization:")
226
365
  a(f' repo: "{options.personalization_repo}"')
227
366
  a(" paths:")
228
- a(' - source: "global/CLAUDE.md"')
229
- a(' target: "~/.claude/CLAUDE.md"')
367
+ for entry in personalization_paths:
368
+ a(f' - source: "{_yaml_escape(entry.source)}"')
369
+ a(f' target: "{_yaml_escape(entry.target)}"')
230
370
  a("")
231
371
  a("# Repos discovered via `gh repo list`. Filters applied:")
232
372
  a(
@@ -345,11 +485,51 @@ def run_setup(options: SetupOptions) -> SetupResult:
345
485
 
346
486
  repos_by_owner: dict[str, list[dict]] = {}
347
487
  for owner in options.owners:
348
- repos_by_owner[owner] = list_repos(
488
+ repos = list_repos(
349
489
  owner, skip_archived=options.skip_archived, skip_forks=options.skip_forks
350
490
  )
491
+ # Drop the personalization repo if it surfaced under one of the
492
+ # configured owners. It's the cross-machine sync target, not a
493
+ # project the dev pipeline should monitor for issues — listing
494
+ # it under ``repos:`` alongside the per-machine personalization
495
+ # checkout would have ctrlrelay polling and worktree-cloning
496
+ # the operator's own dotfiles. Comparison is case-insensitive
497
+ # because GitHub repo names are case-insensitive: ``alice/foo``
498
+ # and ``Alice/Foo`` resolve to the same repo, so an operator
499
+ # passing one casing while ``gh repo list`` returned the other
500
+ # would otherwise dodge the filter (codex review pass 1).
501
+ if options.personalization_repo:
502
+ personalization_norm = options.personalization_repo.lower()
503
+ repos = [
504
+ r for r in repos
505
+ if r["nameWithOwner"].lower() != personalization_norm
506
+ ]
507
+ repos_by_owner[owner] = repos
508
+
509
+ # Build the personalization paths list BEFORE writing the YAML so
510
+ # auto-detected skills end up baked into the file the operator
511
+ # eventually edits. Pre-clone the personalization repo if needed
512
+ # (it might already exist from a prior setup run on this machine).
513
+ personalization_paths: list[PersonalizationPath] | None = None
514
+ if options.personalization_repo:
515
+ personalization_paths = [
516
+ PersonalizationPath(
517
+ source="global/CLAUDE.md",
518
+ target="~/.claude/CLAUDE.md",
519
+ )
520
+ ]
521
+ if options.wire_skills:
522
+ cloned_for_scan = _ensure_personalization_clone(
523
+ options.personalization_repo, DEFAULT_PERSONALIZATION_CHECKOUT
524
+ )
525
+ if cloned_for_scan:
526
+ personalization_paths.extend(
527
+ detect_personalization_skills(DEFAULT_PERSONALIZATION_CHECKOUT)
528
+ )
351
529
 
352
- yaml_text = build_orchestrator_yaml(options, repos_by_owner)
530
+ yaml_text = build_orchestrator_yaml(
531
+ options, repos_by_owner, personalization_paths=personalization_paths
532
+ )
353
533
  options.config_out.parent.mkdir(parents=True, exist_ok=True)
354
534
  options.config_out.write_text(yaml_text)
355
535
 
@@ -17,6 +17,7 @@ from ctrlrelay.setup import (
17
17
  assert_gh_auth,
18
18
  build_orchestrator_yaml,
19
19
  detect_owners,
20
+ detect_personalization_skills,
20
21
  list_repos,
21
22
  run_setup,
22
23
  )
@@ -24,6 +25,22 @@ from ctrlrelay.setup import (
24
25
  runner = CliRunner()
25
26
 
26
27
 
28
+ def _write_fake_checkout(checkout: Path, origin_url: str) -> None:
29
+ """Lay down a checkout that ``git remote get-url origin`` would
30
+ accept, without needing a real ``git init`` (the test fixtures
31
+ patch ``subprocess.run`` and would intercept real git ops).
32
+ """
33
+ git_dir = checkout / ".git"
34
+ git_dir.mkdir(parents=True, exist_ok=True)
35
+ (git_dir / "HEAD").write_text("ref: refs/heads/main\n")
36
+ (git_dir / "config").write_text(
37
+ "[core]\n"
38
+ "\trepositoryformatversion = 0\n"
39
+ f'[remote "origin"]\n'
40
+ f"\turl = {origin_url}\n"
41
+ )
42
+
43
+
27
44
  # ---------------------------------------------------------------------------
28
45
  # gh helpers
29
46
 
@@ -231,6 +248,28 @@ def fake_gh(monkeypatch: pytest.MonkeyPatch) -> dict:
231
248
  target = Path(cmd[-1])
232
249
  (target / ".git").mkdir(parents=True, exist_ok=True)
233
250
  return subprocess.CompletedProcess(cmd, 0, "", "")
251
+ # git -C <checkout> remote get-url origin — used by the
252
+ # personalization-checkout origin verification.
253
+ if (
254
+ len(cmd) >= 6
255
+ and cmd[0] == "git"
256
+ and cmd[1] == "-C"
257
+ and cmd[3:6] == ["remote", "get-url", "origin"]
258
+ ):
259
+ checkout = Path(cmd[2])
260
+ cfg = checkout / ".git" / "config"
261
+ if cfg.is_file():
262
+ # Pull `url = ...` out of the [remote "origin"] block.
263
+ in_origin = False
264
+ for line in cfg.read_text().splitlines():
265
+ s = line.strip()
266
+ if s.startswith("["):
267
+ in_origin = s == '[remote "origin"]'
268
+ continue
269
+ if in_origin and s.startswith("url"):
270
+ url = s.split("=", 1)[1].strip()
271
+ return subprocess.CompletedProcess(cmd, 0, url + "\n", "")
272
+ return subprocess.CompletedProcess(cmd, 1, "", "no origin")
234
273
  raise AssertionError(f"unexpected command: {cmd}")
235
274
 
236
275
  monkeypatch.setattr("subprocess.run", fake_run)
@@ -360,6 +399,247 @@ class TestRunSetup:
360
399
  assert opts.config_out.is_file()
361
400
 
362
401
 
402
+ # ---------------------------------------------------------------------------
403
+ # personalization follow-ups: filter the personalization repo, auto-wire skills
404
+
405
+
406
+ class TestDetectPersonalizationSkills:
407
+ def test_returns_sorted_subdirs_only(self, tmp_path: Path) -> None:
408
+ skills_dir = tmp_path / "global" / "skills"
409
+ skills_dir.mkdir(parents=True)
410
+ (skills_dir / "Bravo").mkdir()
411
+ (skills_dir / "alpha").mkdir()
412
+ (skills_dir / "charlie").mkdir()
413
+ # A stray file at the top level — must be ignored.
414
+ (skills_dir / "README.md").write_text("not a skill\n")
415
+ # A hidden dir — must be ignored.
416
+ (skills_dir / ".hidden").mkdir()
417
+
418
+ paths = detect_personalization_skills(tmp_path)
419
+ names = [p.source.removeprefix("global/skills/").removesuffix("/") for p in paths]
420
+ # Case-insensitive sort: 'alpha', 'Bravo', 'charlie'.
421
+ assert names == ["alpha", "Bravo", "charlie"]
422
+
423
+ def test_returns_empty_when_skills_dir_missing(self, tmp_path: Path) -> None:
424
+ # Personalization repo without a global/skills/ tree at all.
425
+ assert detect_personalization_skills(tmp_path) == []
426
+
427
+ def test_target_uses_tilde_home_for_portability(self, tmp_path: Path) -> None:
428
+ skills_dir = tmp_path / "global" / "skills"
429
+ skills_dir.mkdir(parents=True)
430
+ (skills_dir / "gh-secops").mkdir()
431
+ paths = detect_personalization_skills(tmp_path)
432
+ # Target must use ``~/.claude/...`` rather than an absolute home
433
+ # path so the same config works across machines with different
434
+ # operator home directories.
435
+ assert paths[0].target == "~/.claude/skills/gh-secops/"
436
+
437
+
438
+ class TestPersonalizationRepoFilteredOutOfRepos:
439
+ def test_personalization_repo_not_listed_under_owner(
440
+ self, fake_gh: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
441
+ ) -> None:
442
+ """When the personalization repo lives under one of the
443
+ configured owners (e.g. ``alice/dotclaude`` while ``alice`` is
444
+ also an enumerated owner), it must not appear in the ``repos:``
445
+ block — it's the sync target, not a project to monitor."""
446
+ from ctrlrelay import setup as setup_mod
447
+ from ctrlrelay.personalization import manager as mgr_mod
448
+
449
+ # Don't try to clone the personalization repo for skill scan
450
+ # or actually run init in this test — we're focused on the
451
+ # repos: filter.
452
+ monkeypatch.setattr(
453
+ setup_mod, "_ensure_personalization_clone", lambda repo, checkout: False
454
+ )
455
+ monkeypatch.setattr(
456
+ mgr_mod.PersonalizationManager, "init", lambda self, **kw: "stub-init"
457
+ )
458
+ fake_gh["repos_by_owner"] = {
459
+ "alice": ["alice/foo", "alice/dotclaude", "alice/bar"],
460
+ "AInvirion": ["AInvirion/baz"],
461
+ }
462
+ opts = SetupOptions(
463
+ owners=["alice", "AInvirion"],
464
+ repo_root=tmp_path / "Projects",
465
+ config_out=tmp_path / "cfg.yaml",
466
+ personalization_repo="alice/dotclaude",
467
+ wire_skills=False, # skip scan in this test
468
+ )
469
+ result = run_setup(opts)
470
+ # 3 repos remain (alice/foo, alice/bar, AInvirion/baz). The
471
+ # personalization repo is excluded but other alice repos pass.
472
+ assert result.n_repos == 3
473
+ text = opts.config_out.read_text()
474
+ assert "alice/foo" in text
475
+ assert "alice/bar" in text
476
+ assert "AInvirion/baz" in text
477
+ assert 'name: "alice/dotclaude"' not in text
478
+
479
+
480
+ def test_personalization_repo_filter_is_case_insensitive(
481
+ self, fake_gh: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
482
+ ) -> None:
483
+ """GitHub repo names are case-insensitive (``alice/dotclaude``
484
+ and ``Alice/DotClaude`` resolve to the same repo). The filter
485
+ must match regardless of casing — codex review pass 1 caught
486
+ the case where ``gh repo list`` returns the canonical casing
487
+ but the operator's --personalization-repo arg differs."""
488
+ from ctrlrelay import setup as setup_mod
489
+ from ctrlrelay.personalization import manager as mgr_mod
490
+
491
+ monkeypatch.setattr(
492
+ setup_mod, "_ensure_personalization_clone", lambda repo, checkout: False
493
+ )
494
+ monkeypatch.setattr(
495
+ mgr_mod.PersonalizationManager, "init", lambda self, **kw: "stub-init"
496
+ )
497
+ # gh returns the canonical casing; operator typed lowercase.
498
+ fake_gh["repos_by_owner"] = {
499
+ "alice": ["alice/foo", "Alice/DotClaude"],
500
+ "AInvirion": [],
501
+ }
502
+ opts = SetupOptions(
503
+ owners=["alice", "AInvirion"],
504
+ repo_root=tmp_path / "Projects",
505
+ config_out=tmp_path / "cfg.yaml",
506
+ personalization_repo="alice/dotclaude", # lowercase
507
+ wire_skills=False,
508
+ )
509
+ result = run_setup(opts)
510
+ # Only alice/foo remains — the canonical-cased dotclaude was filtered.
511
+ assert result.n_repos == 1
512
+ text = opts.config_out.read_text()
513
+ assert "alice/foo" in text
514
+ assert "DotClaude" not in text
515
+ assert "dotclaude" not in text.replace(opts.personalization_repo or "", "")
516
+
517
+
518
+ class TestSkillAutoWiringInRunSetup:
519
+ def test_detected_skills_appear_in_personalization_paths(
520
+ self, fake_gh: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
521
+ ) -> None:
522
+ """End-to-end: when --wire-skills is on (default) and the
523
+ personalization checkout has skill subdirs, run_setup adds
524
+ per-skill paths: entries to the generated YAML."""
525
+ from ctrlrelay import setup as setup_mod
526
+ from ctrlrelay.personalization import manager as mgr_mod
527
+
528
+ # Stand up a fake personalization checkout with two skills.
529
+ fake_checkout = tmp_path / "personalization-checkout"
530
+ skills = fake_checkout / "global" / "skills"
531
+ skills.mkdir(parents=True)
532
+ (skills / "gh-secops").mkdir()
533
+ (skills / "codex-review-loop").mkdir()
534
+ # Origin must match `personalization_repo` for the existence
535
+ # check to accept this checkout (codex review pass 2 added the
536
+ # check; without an origin, scan would be skipped).
537
+ _write_fake_checkout(fake_checkout, "git@github.com:alice/dotclaude.git")
538
+
539
+ # Point setup at the fake checkout instead of ~/.ctrlrelay/...
540
+ monkeypatch.setattr(
541
+ setup_mod, "DEFAULT_PERSONALIZATION_CHECKOUT", fake_checkout
542
+ )
543
+ # Don't run real PersonalizationManager.init — that would issue
544
+ # additional git commands the fake_gh fixture doesn't mock.
545
+ monkeypatch.setattr(
546
+ mgr_mod.PersonalizationManager, "init", lambda self, **kw: "stub-init"
547
+ )
548
+
549
+ fake_gh["repos_by_owner"] = {"alice": ["alice/foo"], "AInvirion": []}
550
+ opts = SetupOptions(
551
+ owners=["alice", "AInvirion"],
552
+ repo_root=tmp_path / "Projects",
553
+ config_out=tmp_path / "cfg.yaml",
554
+ personalization_repo="alice/dotclaude",
555
+ wire_skills=True,
556
+ )
557
+ run_setup(opts)
558
+ text = opts.config_out.read_text()
559
+ # CLAUDE.md still there; skills appended in detect order.
560
+ assert 'source: "global/CLAUDE.md"' in text
561
+ assert 'source: "global/skills/codex-review-loop/"' in text
562
+ assert 'source: "global/skills/gh-secops/"' in text
563
+ assert 'target: "~/.claude/skills/codex-review-loop/"' in text
564
+
565
+ def test_existing_checkout_with_different_origin_is_not_scanned(
566
+ self, fake_gh: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
567
+ ) -> None:
568
+ """If ~/.ctrlrelay/personalization already holds a clone of a
569
+ DIFFERENT repo than what setup is configuring, the helper must
570
+ treat it as unusable and skip the skill scan. Otherwise we'd
571
+ bake the unrelated repo's skill names into the new config and
572
+ PersonalizationManager.init would later refuse the checkout.
573
+ Codex review pass 2 caught this."""
574
+ from ctrlrelay import setup as setup_mod
575
+ from ctrlrelay.personalization import manager as mgr_mod
576
+
577
+ # Stand up a fake checkout whose origin is for "other/unrelated",
578
+ # but configure setup with personalization-repo "alice/dotclaude".
579
+ fake_checkout = tmp_path / "stale-checkout"
580
+ skills = fake_checkout / "global" / "skills"
581
+ skills.mkdir(parents=True)
582
+ (skills / "should-not-leak").mkdir()
583
+ _write_fake_checkout(fake_checkout, "git@github.com:other/unrelated.git")
584
+
585
+ monkeypatch.setattr(
586
+ setup_mod, "DEFAULT_PERSONALIZATION_CHECKOUT", fake_checkout
587
+ )
588
+ monkeypatch.setattr(
589
+ mgr_mod.PersonalizationManager, "init", lambda self, **kw: "stub-init"
590
+ )
591
+
592
+ fake_gh["repos_by_owner"] = {"alice": ["alice/foo"], "AInvirion": []}
593
+ opts = SetupOptions(
594
+ owners=["alice", "AInvirion"],
595
+ repo_root=tmp_path / "Projects",
596
+ config_out=tmp_path / "cfg.yaml",
597
+ personalization_repo="alice/dotclaude",
598
+ wire_skills=True,
599
+ )
600
+ run_setup(opts)
601
+ text = opts.config_out.read_text()
602
+ # CLAUDE.md is the only paths: entry. The unrelated checkout's
603
+ # skill name does NOT leak into the generated config.
604
+ assert 'source: "global/CLAUDE.md"' in text
605
+ assert "should-not-leak" not in text
606
+ assert "global/skills/" not in text
607
+
608
+ def test_no_wire_skills_emits_only_default_path(
609
+ self, fake_gh: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
610
+ ) -> None:
611
+ """``--no-wire-skills`` must NOT scan or add per-skill
612
+ entries even if the personalization checkout has skill dirs."""
613
+ from ctrlrelay import setup as setup_mod
614
+ from ctrlrelay.personalization import manager as mgr_mod
615
+
616
+ fake_checkout = tmp_path / "personalization-checkout"
617
+ skills = fake_checkout / "global" / "skills"
618
+ skills.mkdir(parents=True)
619
+ (skills / "gh-secops").mkdir()
620
+ (fake_checkout / ".git").mkdir()
621
+ monkeypatch.setattr(
622
+ setup_mod, "DEFAULT_PERSONALIZATION_CHECKOUT", fake_checkout
623
+ )
624
+ monkeypatch.setattr(
625
+ mgr_mod.PersonalizationManager, "init", lambda self, **kw: "stub-init"
626
+ )
627
+
628
+ fake_gh["repos_by_owner"] = {"alice": ["alice/foo"], "AInvirion": []}
629
+ opts = SetupOptions(
630
+ owners=["alice", "AInvirion"],
631
+ repo_root=tmp_path / "Projects",
632
+ config_out=tmp_path / "cfg.yaml",
633
+ personalization_repo="alice/dotclaude",
634
+ wire_skills=False,
635
+ )
636
+ run_setup(opts)
637
+ text = opts.config_out.read_text()
638
+ assert 'source: "global/CLAUDE.md"' in text
639
+ # No per-skill entries written when wire-skills is off.
640
+ assert "global/skills/" not in text
641
+
642
+
363
643
  # ---------------------------------------------------------------------------
364
644
  # CLI surface
365
645
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes