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.
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/CHANGELOG.md +28 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/PKG-INFO +1 -1
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/pyproject.toml +1 -1
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/cli.py +10 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/setup.py +185 -5
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_setup.py +280 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/.gitignore +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/LICENSE +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/README.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/SECURITY.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/cli.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/configuration.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/development.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/index.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/operations.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_config.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.4.1}/tests/test_transport.py +0 -0
- {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.
|
|
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/
|
|
@@ -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,
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|