ctrlrelay 0.2.0__tar.gz → 0.2.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.2.0 → ctrlrelay-0.2.1}/CHANGELOG.md +28 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/PKG-INFO +1 -1
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/pyproject.toml +1 -1
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/cli.py +86 -66
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/config.py +57 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_config.py +73 -1
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.gitignore +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/LICENSE +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/README.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/SECURITY.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/cli.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/configuration.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/development.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/index.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/operations.md +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_transport.py +0 -0
- {ctrlrelay-0.2.0 → ctrlrelay-0.2.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.2.1] - 2026-04-28
|
|
11
|
+
|
|
12
|
+
Patch release. Fixes a long-standing UX bug where `ctrlrelay` could
|
|
13
|
+
only be invoked from a directory containing `config/orchestrator.yaml`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`--config` now auto-discovers `orchestrator.yaml`.** Every CLI
|
|
18
|
+
command previously hardcoded a relative `config/orchestrator.yaml`
|
|
19
|
+
default, so running `ctrlrelay status` (or any other subcommand)
|
|
20
|
+
from `/tmp`, `$HOME`, or anywhere outside the project root failed
|
|
21
|
+
with `Config file not found: config/orchestrator.yaml`. The CLI
|
|
22
|
+
now resolves the config in this order:
|
|
23
|
+
|
|
24
|
+
1. The path passed to `--config` / `-c`, if any.
|
|
25
|
+
2. `$CTRLRELAY_CONFIG` (a new environment variable).
|
|
26
|
+
3. `./config/orchestrator.yaml`, walking up from the current
|
|
27
|
+
working directory to the filesystem root — matches how `git`
|
|
28
|
+
and `uv` find their config.
|
|
29
|
+
4. `$XDG_CONFIG_HOME/ctrlrelay/orchestrator.yaml` (defaults to
|
|
30
|
+
`~/.config/ctrlrelay/orchestrator.yaml`).
|
|
31
|
+
|
|
32
|
+
When nothing matches, the error now lists every location searched
|
|
33
|
+
so it's clear where to drop the file or which env var to set.
|
|
34
|
+
Daemon spawn paths (`ctrlrelay poller start` re-exec under
|
|
35
|
+
`--foreground`) pass the *resolved* absolute path to the child so
|
|
36
|
+
the daemon doesn't break when launchd starts it from `/`.
|
|
37
|
+
|
|
10
38
|
## [0.2.0] - 2026-04-27
|
|
11
39
|
|
|
12
40
|
Minor release. Adds bulk repo operations driven by
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.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/
|
|
@@ -7,7 +7,7 @@ from rich.console import Console
|
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
9
|
from ctrlrelay import __version__
|
|
10
|
-
from ctrlrelay.core.config import ConfigError, load_config
|
|
10
|
+
from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
|
|
11
11
|
from ctrlrelay.core.github import GitHubCLI, GitHubError
|
|
12
12
|
from ctrlrelay.core.pr_verifier import PRVerifier
|
|
13
13
|
|
|
@@ -39,6 +39,15 @@ def main(
|
|
|
39
39
|
"""ctrlrelay orchestrator CLI."""
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _resolve_config_or_exit(config_path: str | None) -> Path:
|
|
43
|
+
"""Resolve --config (or auto-discover) and exit with a friendly error if missing."""
|
|
44
|
+
try:
|
|
45
|
+
return resolve_config_path(config_path)
|
|
46
|
+
except ConfigError as e:
|
|
47
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
42
51
|
# Subcommand groups
|
|
43
52
|
config_app = typer.Typer(help="Configuration commands.")
|
|
44
53
|
app.add_typer(config_app, name="config")
|
|
@@ -46,15 +55,15 @@ app.add_typer(config_app, name="config")
|
|
|
46
55
|
|
|
47
56
|
@config_app.command("validate")
|
|
48
57
|
def config_validate(
|
|
49
|
-
config_path: str = typer.Option(
|
|
50
|
-
|
|
58
|
+
config_path: str | None = typer.Option(
|
|
59
|
+
None,
|
|
51
60
|
"--config",
|
|
52
61
|
"-c",
|
|
53
|
-
help="Path to orchestrator.yaml",
|
|
62
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
54
63
|
),
|
|
55
64
|
) -> None:
|
|
56
65
|
"""Validate orchestrator.yaml configuration."""
|
|
57
|
-
path =
|
|
66
|
+
path = _resolve_config_or_exit(config_path)
|
|
58
67
|
|
|
59
68
|
if not path.exists():
|
|
60
69
|
console.print(f"[red]Error:[/red] Config file not found: {path}")
|
|
@@ -75,15 +84,15 @@ def config_validate(
|
|
|
75
84
|
|
|
76
85
|
@config_app.command("repos")
|
|
77
86
|
def config_repos(
|
|
78
|
-
config_path: str = typer.Option(
|
|
79
|
-
|
|
87
|
+
config_path: str | None = typer.Option(
|
|
88
|
+
None,
|
|
80
89
|
"--config",
|
|
81
90
|
"-c",
|
|
82
|
-
help="Path to orchestrator.yaml",
|
|
91
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
83
92
|
),
|
|
84
93
|
) -> None:
|
|
85
94
|
"""List configured repositories."""
|
|
86
|
-
path =
|
|
95
|
+
path = _resolve_config_or_exit(config_path)
|
|
87
96
|
|
|
88
97
|
try:
|
|
89
98
|
config = load_config(path)
|
|
@@ -112,13 +121,13 @@ skills_app = typer.Typer(help="Skill management commands.")
|
|
|
112
121
|
app.add_typer(skills_app, name="skills")
|
|
113
122
|
|
|
114
123
|
|
|
115
|
-
def _resolve_skills_dir(skills_path: str | None, config_path: str) -> Path:
|
|
124
|
+
def _resolve_skills_dir(skills_path: str | None, config_path: str | None) -> Path:
|
|
116
125
|
"""Resolve skills directory from flag or config."""
|
|
117
126
|
if skills_path is not None:
|
|
118
127
|
skills_dir = Path(skills_path).expanduser().resolve()
|
|
119
128
|
else:
|
|
120
129
|
try:
|
|
121
|
-
config = load_config(config_path)
|
|
130
|
+
config = load_config(resolve_config_path(config_path))
|
|
122
131
|
skills_dir = config.paths.skills.expanduser().resolve()
|
|
123
132
|
except ConfigError as e:
|
|
124
133
|
console.print(f"[red]Error loading config:[/red] {e}")
|
|
@@ -140,11 +149,11 @@ def skills_audit(
|
|
|
140
149
|
"-p",
|
|
141
150
|
help="Path to skills directory (default: from config)",
|
|
142
151
|
),
|
|
143
|
-
config_path: str = typer.Option(
|
|
144
|
-
|
|
152
|
+
config_path: str | None = typer.Option(
|
|
153
|
+
None,
|
|
145
154
|
"--config",
|
|
146
155
|
"-c",
|
|
147
|
-
help="Path to orchestrator.yaml",
|
|
156
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
148
157
|
),
|
|
149
158
|
) -> None:
|
|
150
159
|
"""Audit skills for orchestrator readiness."""
|
|
@@ -176,11 +185,11 @@ def skills_list(
|
|
|
176
185
|
"-p",
|
|
177
186
|
help="Path to skills directory (default: from config)",
|
|
178
187
|
),
|
|
179
|
-
config_path: str = typer.Option(
|
|
180
|
-
|
|
188
|
+
config_path: str | None = typer.Option(
|
|
189
|
+
None,
|
|
181
190
|
"--config",
|
|
182
191
|
"-c",
|
|
183
|
-
help="Path to orchestrator.yaml",
|
|
192
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
184
193
|
),
|
|
185
194
|
) -> None:
|
|
186
195
|
"""List available skills."""
|
|
@@ -209,10 +218,10 @@ bridge_app = typer.Typer(help="Telegram bridge commands.")
|
|
|
209
218
|
app.add_typer(bridge_app, name="bridge")
|
|
210
219
|
|
|
211
220
|
|
|
212
|
-
def _get_socket_path(config_path: str) -> Path:
|
|
221
|
+
def _get_socket_path(config_path: str | None) -> Path:
|
|
213
222
|
"""Get socket path from config."""
|
|
214
223
|
try:
|
|
215
|
-
config = load_config(config_path)
|
|
224
|
+
config = load_config(resolve_config_path(config_path))
|
|
216
225
|
if config.transport.telegram:
|
|
217
226
|
return config.transport.telegram.socket_path.expanduser().resolve()
|
|
218
227
|
except ConfigError:
|
|
@@ -227,11 +236,11 @@ def _get_bridge_pid_file(socket_path: Path) -> Path:
|
|
|
227
236
|
|
|
228
237
|
@bridge_app.command("start")
|
|
229
238
|
def bridge_start(
|
|
230
|
-
config_path: str = typer.Option(
|
|
231
|
-
|
|
239
|
+
config_path: str | None = typer.Option(
|
|
240
|
+
None,
|
|
232
241
|
"--config",
|
|
233
242
|
"-c",
|
|
234
|
-
help="Path to orchestrator.yaml",
|
|
243
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
235
244
|
),
|
|
236
245
|
foreground: bool = typer.Option(
|
|
237
246
|
False,
|
|
@@ -251,7 +260,7 @@ def bridge_start(
|
|
|
251
260
|
import sys
|
|
252
261
|
|
|
253
262
|
try:
|
|
254
|
-
config = load_config(config_path)
|
|
263
|
+
config = load_config(resolve_config_path(config_path))
|
|
255
264
|
except ConfigError as e:
|
|
256
265
|
console.print(f"[red]Error loading config:[/red] {e}")
|
|
257
266
|
raise typer.Exit(1)
|
|
@@ -400,11 +409,11 @@ def bridge_start(
|
|
|
400
409
|
|
|
401
410
|
@bridge_app.command("stop")
|
|
402
411
|
def bridge_stop(
|
|
403
|
-
config_path: str = typer.Option(
|
|
404
|
-
|
|
412
|
+
config_path: str | None = typer.Option(
|
|
413
|
+
None,
|
|
405
414
|
"--config",
|
|
406
415
|
"-c",
|
|
407
|
-
help="Path to orchestrator.yaml",
|
|
416
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
408
417
|
),
|
|
409
418
|
) -> None:
|
|
410
419
|
"""Stop the Telegram bridge."""
|
|
@@ -433,11 +442,11 @@ def bridge_stop(
|
|
|
433
442
|
|
|
434
443
|
@bridge_app.command("status")
|
|
435
444
|
def bridge_status(
|
|
436
|
-
config_path: str = typer.Option(
|
|
437
|
-
|
|
445
|
+
config_path: str | None = typer.Option(
|
|
446
|
+
None,
|
|
438
447
|
"--config",
|
|
439
448
|
"-c",
|
|
440
|
-
help="Path to orchestrator.yaml",
|
|
449
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
441
450
|
),
|
|
442
451
|
) -> None:
|
|
443
452
|
"""Check bridge status."""
|
|
@@ -477,11 +486,11 @@ def bridge_test(
|
|
|
477
486
|
"-m",
|
|
478
487
|
help="Message to send",
|
|
479
488
|
),
|
|
480
|
-
config_path: str = typer.Option(
|
|
481
|
-
|
|
489
|
+
config_path: str | None = typer.Option(
|
|
490
|
+
None,
|
|
482
491
|
"--config",
|
|
483
492
|
"-c",
|
|
484
|
-
help="Path to orchestrator.yaml",
|
|
493
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
485
494
|
),
|
|
486
495
|
) -> None:
|
|
487
496
|
"""Send a test message to verify bridge is working."""
|
|
@@ -518,11 +527,11 @@ app.add_typer(run_app, name="run")
|
|
|
518
527
|
|
|
519
528
|
@run_app.command("secops")
|
|
520
529
|
def run_secops(
|
|
521
|
-
config_path: str = typer.Option(
|
|
522
|
-
|
|
530
|
+
config_path: str | None = typer.Option(
|
|
531
|
+
None,
|
|
523
532
|
"--config",
|
|
524
533
|
"-c",
|
|
525
|
-
help="Path to orchestrator.yaml",
|
|
534
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
526
535
|
),
|
|
527
536
|
repo: str = typer.Option(
|
|
528
537
|
None,
|
|
@@ -541,7 +550,7 @@ def run_secops(
|
|
|
541
550
|
from ctrlrelay.dashboard.client import DashboardClient
|
|
542
551
|
from ctrlrelay.pipelines.secops import run_secops_all
|
|
543
552
|
|
|
544
|
-
path =
|
|
553
|
+
path = _resolve_config_or_exit(config_path)
|
|
545
554
|
|
|
546
555
|
try:
|
|
547
556
|
config = load_config(path)
|
|
@@ -635,11 +644,11 @@ def run_dev(
|
|
|
635
644
|
"-r",
|
|
636
645
|
help="Run on specific repo only",
|
|
637
646
|
),
|
|
638
|
-
config_path: str = typer.Option(
|
|
639
|
-
|
|
647
|
+
config_path: str | None = typer.Option(
|
|
648
|
+
None,
|
|
640
649
|
"--config",
|
|
641
650
|
"-c",
|
|
642
|
-
help="Path to orchestrator.yaml",
|
|
651
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
643
652
|
),
|
|
644
653
|
) -> None:
|
|
645
654
|
"""Run dev pipeline for a GitHub issue."""
|
|
@@ -651,7 +660,7 @@ def run_dev(
|
|
|
651
660
|
from ctrlrelay.core.worktree import WorktreeManager
|
|
652
661
|
from ctrlrelay.pipelines.dev import run_dev_issue
|
|
653
662
|
|
|
654
|
-
path =
|
|
663
|
+
path = _resolve_config_or_exit(config_path)
|
|
655
664
|
|
|
656
665
|
try:
|
|
657
666
|
config = load_config(path)
|
|
@@ -830,10 +839,10 @@ poller_app = typer.Typer(help="Issue poller commands.")
|
|
|
830
839
|
app.add_typer(poller_app, name="poller")
|
|
831
840
|
|
|
832
841
|
|
|
833
|
-
def _get_poller_pid_file(config_path: str) -> Path:
|
|
842
|
+
def _get_poller_pid_file(config_path: str | None) -> Path:
|
|
834
843
|
"""Get PID file path for poller process."""
|
|
835
844
|
try:
|
|
836
|
-
config = load_config(config_path)
|
|
845
|
+
config = load_config(resolve_config_path(config_path))
|
|
837
846
|
return config.paths.state_db.parent / "poller.pid"
|
|
838
847
|
except ConfigError:
|
|
839
848
|
pass
|
|
@@ -842,11 +851,11 @@ def _get_poller_pid_file(config_path: str) -> Path:
|
|
|
842
851
|
|
|
843
852
|
@poller_app.command("start")
|
|
844
853
|
def poller_start(
|
|
845
|
-
config_path: str = typer.Option(
|
|
846
|
-
|
|
854
|
+
config_path: str | None = typer.Option(
|
|
855
|
+
None,
|
|
847
856
|
"--config",
|
|
848
857
|
"-c",
|
|
849
|
-
help="Path to orchestrator.yaml",
|
|
858
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
850
859
|
),
|
|
851
860
|
foreground: bool = typer.Option(
|
|
852
861
|
False,
|
|
@@ -873,13 +882,15 @@ def poller_start(
|
|
|
873
882
|
import subprocess
|
|
874
883
|
import sys
|
|
875
884
|
|
|
885
|
+
resolved_config = _resolve_config_or_exit(config_path)
|
|
886
|
+
|
|
876
887
|
try:
|
|
877
|
-
config = load_config(
|
|
888
|
+
config = load_config(resolved_config)
|
|
878
889
|
except ConfigError as e:
|
|
879
890
|
console.print(f"[red]Error loading config:[/red] {e}")
|
|
880
891
|
raise typer.Exit(1)
|
|
881
892
|
|
|
882
|
-
pid_file = _get_poller_pid_file(
|
|
893
|
+
pid_file = _get_poller_pid_file(str(resolved_config))
|
|
883
894
|
if pid_file.exists():
|
|
884
895
|
try:
|
|
885
896
|
pid = int(pid_file.read_text().strip())
|
|
@@ -903,7 +914,7 @@ def poller_start(
|
|
|
903
914
|
"poller",
|
|
904
915
|
"start",
|
|
905
916
|
"--config",
|
|
906
|
-
|
|
917
|
+
str(resolved_config),
|
|
907
918
|
"--interval",
|
|
908
919
|
str(interval),
|
|
909
920
|
"--foreground",
|
|
@@ -1917,11 +1928,11 @@ def poller_start(
|
|
|
1917
1928
|
|
|
1918
1929
|
@poller_app.command("stop")
|
|
1919
1930
|
def poller_stop(
|
|
1920
|
-
config_path: str = typer.Option(
|
|
1921
|
-
|
|
1931
|
+
config_path: str | None = typer.Option(
|
|
1932
|
+
None,
|
|
1922
1933
|
"--config",
|
|
1923
1934
|
"-c",
|
|
1924
|
-
help="Path to orchestrator.yaml",
|
|
1935
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
1925
1936
|
),
|
|
1926
1937
|
) -> None:
|
|
1927
1938
|
"""Stop the issue poller."""
|
|
@@ -1949,11 +1960,11 @@ def poller_stop(
|
|
|
1949
1960
|
|
|
1950
1961
|
@poller_app.command("status")
|
|
1951
1962
|
def poller_status(
|
|
1952
|
-
config_path: str = typer.Option(
|
|
1953
|
-
|
|
1963
|
+
config_path: str | None = typer.Option(
|
|
1964
|
+
None,
|
|
1954
1965
|
"--config",
|
|
1955
1966
|
"-c",
|
|
1956
|
-
help="Path to orchestrator.yaml",
|
|
1967
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
1957
1968
|
),
|
|
1958
1969
|
) -> None:
|
|
1959
1970
|
"""Check poller status."""
|
|
@@ -1982,18 +1993,18 @@ def version() -> None:
|
|
|
1982
1993
|
|
|
1983
1994
|
@app.command("status")
|
|
1984
1995
|
def status(
|
|
1985
|
-
config_path: str = typer.Option(
|
|
1986
|
-
|
|
1996
|
+
config_path: str | None = typer.Option(
|
|
1997
|
+
None,
|
|
1987
1998
|
"--config",
|
|
1988
1999
|
"-c",
|
|
1989
|
-
help="Path to orchestrator.yaml",
|
|
2000
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
1990
2001
|
),
|
|
1991
2002
|
) -> None:
|
|
1992
2003
|
"""Show orchestrator status and active sessions."""
|
|
1993
2004
|
from ctrlrelay.core.state import StateDB
|
|
1994
2005
|
|
|
1995
2006
|
try:
|
|
1996
|
-
config = load_config(config_path)
|
|
2007
|
+
config = load_config(resolve_config_path(config_path))
|
|
1997
2008
|
except ConfigError as e:
|
|
1998
2009
|
console.print(f"[red]Error loading config:[/red] {e}")
|
|
1999
2010
|
raise typer.Exit(1)
|
|
@@ -2063,10 +2074,10 @@ repos_app = typer.Typer(help="Bulk repo operations across the orchestrator manif
|
|
|
2063
2074
|
app.add_typer(repos_app, name="repos")
|
|
2064
2075
|
|
|
2065
2076
|
|
|
2066
|
-
def _iter_repos(config_path: str, filter_str: str | None):
|
|
2077
|
+
def _iter_repos(config_path: str | None, filter_str: str | None):
|
|
2067
2078
|
"""Yield (name, org, repo, remote) tuples for repos in the orchestrator config."""
|
|
2068
2079
|
try:
|
|
2069
|
-
config = load_config(config_path)
|
|
2080
|
+
config = load_config(resolve_config_path(config_path))
|
|
2070
2081
|
except ConfigError as e:
|
|
2071
2082
|
console.print(f"[red]Error loading config:[/red] {e}")
|
|
2072
2083
|
raise typer.Exit(1)
|
|
@@ -2088,8 +2099,11 @@ def _iter_repos(config_path: str, filter_str: str | None):
|
|
|
2088
2099
|
@repos_app.command("clone-all")
|
|
2089
2100
|
def repos_clone_all(
|
|
2090
2101
|
dest: Path = typer.Argument(..., help="Workspace root (e.g. ~/code/myproject)"),
|
|
2091
|
-
config_path: str = typer.Option(
|
|
2092
|
-
|
|
2102
|
+
config_path: str | None = typer.Option(
|
|
2103
|
+
None,
|
|
2104
|
+
"--config",
|
|
2105
|
+
"-c",
|
|
2106
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2093
2107
|
),
|
|
2094
2108
|
filter_str: str | None = typer.Option(
|
|
2095
2109
|
None, "--filter", "-f", help="Substring filter on repo name (e.g. 'AInvirion')"
|
|
@@ -2139,8 +2153,11 @@ def repos_clone_all(
|
|
|
2139
2153
|
@repos_app.command("pull-all")
|
|
2140
2154
|
def repos_pull_all(
|
|
2141
2155
|
dest: Path = typer.Argument(..., help="Workspace root to pull (must already be cloned)"),
|
|
2142
|
-
config_path: str = typer.Option(
|
|
2143
|
-
|
|
2156
|
+
config_path: str | None = typer.Option(
|
|
2157
|
+
None,
|
|
2158
|
+
"--config",
|
|
2159
|
+
"-c",
|
|
2160
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2144
2161
|
),
|
|
2145
2162
|
filter_str: str | None = typer.Option(
|
|
2146
2163
|
None, "--filter", "-f", help="Substring filter on repo name"
|
|
@@ -2213,8 +2230,11 @@ def repos_pull_all(
|
|
|
2213
2230
|
@repos_app.command("status")
|
|
2214
2231
|
def repos_status(
|
|
2215
2232
|
dest: Path = typer.Argument(..., help="Workspace root to inspect"),
|
|
2216
|
-
config_path: str = typer.Option(
|
|
2217
|
-
|
|
2233
|
+
config_path: str | None = typer.Option(
|
|
2234
|
+
None,
|
|
2235
|
+
"--config",
|
|
2236
|
+
"-c",
|
|
2237
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2218
2238
|
),
|
|
2219
2239
|
filter_str: str | None = typer.Option(
|
|
2220
2240
|
None, "--filter", "-f", help="Substring filter on repo name"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
from enum import Enum
|
|
7
8
|
from pathlib import Path
|
|
@@ -10,6 +11,10 @@ from typing import Any
|
|
|
10
11
|
import yaml
|
|
11
12
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
12
13
|
|
|
14
|
+
CONFIG_FILENAME = "orchestrator.yaml"
|
|
15
|
+
CONFIG_SUBDIR = "config"
|
|
16
|
+
CONFIG_ENV_VAR = "CTRLRELAY_CONFIG"
|
|
17
|
+
|
|
13
18
|
|
|
14
19
|
class ConfigError(Exception):
|
|
15
20
|
"""Raised when configuration loading or validation fails."""
|
|
@@ -286,6 +291,58 @@ class Config(BaseModel):
|
|
|
286
291
|
return v
|
|
287
292
|
|
|
288
293
|
|
|
294
|
+
def _xdg_config_home() -> Path:
|
|
295
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
296
|
+
if xdg:
|
|
297
|
+
return Path(xdg).expanduser()
|
|
298
|
+
return Path.home() / ".config"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def default_config_search_paths() -> list[Path]:
|
|
302
|
+
"""Ordered list of locations searched when no --config is supplied.
|
|
303
|
+
|
|
304
|
+
Order: $CTRLRELAY_CONFIG, then ./config/orchestrator.yaml walking up from
|
|
305
|
+
cwd to filesystem root, then $XDG_CONFIG_HOME/ctrlrelay/orchestrator.yaml
|
|
306
|
+
(defaulting to ~/.config/ctrlrelay/orchestrator.yaml).
|
|
307
|
+
"""
|
|
308
|
+
paths: list[Path] = []
|
|
309
|
+
|
|
310
|
+
env = os.environ.get(CONFIG_ENV_VAR)
|
|
311
|
+
if env:
|
|
312
|
+
paths.append(Path(env).expanduser())
|
|
313
|
+
|
|
314
|
+
cwd = Path.cwd().resolve()
|
|
315
|
+
for parent in [cwd, *cwd.parents]:
|
|
316
|
+
paths.append(parent / CONFIG_SUBDIR / CONFIG_FILENAME)
|
|
317
|
+
|
|
318
|
+
paths.append(_xdg_config_home() / "ctrlrelay" / CONFIG_FILENAME)
|
|
319
|
+
|
|
320
|
+
return paths
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def resolve_config_path(explicit: Path | str | None = None) -> Path:
|
|
324
|
+
"""Resolve the orchestrator.yaml path.
|
|
325
|
+
|
|
326
|
+
If ``explicit`` is given, it is returned as-is (the caller decides how to
|
|
327
|
+
handle a missing file). Otherwise, the first existing path among the
|
|
328
|
+
defaults is returned. If nothing exists, raises ConfigError listing the
|
|
329
|
+
locations searched so users know where to put the file.
|
|
330
|
+
"""
|
|
331
|
+
if explicit is not None:
|
|
332
|
+
return Path(explicit).expanduser()
|
|
333
|
+
|
|
334
|
+
candidates = default_config_search_paths()
|
|
335
|
+
for candidate in candidates:
|
|
336
|
+
if candidate.is_file():
|
|
337
|
+
return candidate
|
|
338
|
+
|
|
339
|
+
searched = "\n ".join(str(p) for p in candidates)
|
|
340
|
+
raise ConfigError(
|
|
341
|
+
"No orchestrator.yaml found. Set $CTRLRELAY_CONFIG, pass --config, "
|
|
342
|
+
"or place the file at one of:\n " + searched
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
289
346
|
def load_config(path: Path | str) -> Config:
|
|
290
347
|
"""Load and validate configuration from a YAML file.
|
|
291
348
|
|
|
@@ -5,7 +5,13 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
import yaml
|
|
7
7
|
|
|
8
|
-
from ctrlrelay.core.config import
|
|
8
|
+
from ctrlrelay.core.config import (
|
|
9
|
+
AutomationConfig,
|
|
10
|
+
Config,
|
|
11
|
+
ConfigError,
|
|
12
|
+
load_config,
|
|
13
|
+
resolve_config_path,
|
|
14
|
+
)
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
class TestConfigLoading:
|
|
@@ -246,3 +252,69 @@ class TestAutomationExcludeLabels:
|
|
|
246
252
|
config = load_config(cfg_path)
|
|
247
253
|
|
|
248
254
|
assert config.repos[0].automation.exclude_labels == ["no-agent", "wontfix"]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class TestResolveConfigPath:
|
|
258
|
+
def test_explicit_path_returned_as_is(self, tmp_path: Path) -> None:
|
|
259
|
+
"""An explicit --config value is returned even if it doesn't exist (caller validates)."""
|
|
260
|
+
target = tmp_path / "anywhere.yaml"
|
|
261
|
+
assert resolve_config_path(target) == target
|
|
262
|
+
assert resolve_config_path(str(target)) == target
|
|
263
|
+
|
|
264
|
+
def test_env_var_takes_precedence(
|
|
265
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
266
|
+
) -> None:
|
|
267
|
+
"""$CTRLRELAY_CONFIG wins over cwd-walk-up and home dir."""
|
|
268
|
+
env_target = tmp_path / "env.yaml"
|
|
269
|
+
env_target.write_text("version: '1'\n")
|
|
270
|
+
monkeypatch.setenv("CTRLRELAY_CONFIG", str(env_target))
|
|
271
|
+
monkeypatch.chdir(tmp_path)
|
|
272
|
+
# Even with a config/orchestrator.yaml in cwd, env wins.
|
|
273
|
+
cwd_cfg = tmp_path / "config" / "orchestrator.yaml"
|
|
274
|
+
cwd_cfg.parent.mkdir()
|
|
275
|
+
cwd_cfg.write_text("version: '1'\n")
|
|
276
|
+
|
|
277
|
+
assert resolve_config_path(None) == env_target
|
|
278
|
+
|
|
279
|
+
def test_walks_up_from_cwd(
|
|
280
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
281
|
+
) -> None:
|
|
282
|
+
"""When run from a subdir, finds config/orchestrator.yaml in an ancestor."""
|
|
283
|
+
monkeypatch.delenv("CTRLRELAY_CONFIG", raising=False)
|
|
284
|
+
cfg = tmp_path / "config" / "orchestrator.yaml"
|
|
285
|
+
cfg.parent.mkdir()
|
|
286
|
+
cfg.write_text("version: '1'\n")
|
|
287
|
+
deep = tmp_path / "a" / "b" / "c"
|
|
288
|
+
deep.mkdir(parents=True)
|
|
289
|
+
monkeypatch.chdir(deep)
|
|
290
|
+
|
|
291
|
+
assert resolve_config_path(None) == cfg
|
|
292
|
+
|
|
293
|
+
def test_falls_back_to_xdg_config_home(
|
|
294
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
295
|
+
) -> None:
|
|
296
|
+
"""With no env var and no cwd-walk-up hit, uses $XDG_CONFIG_HOME/ctrlrelay/."""
|
|
297
|
+
monkeypatch.delenv("CTRLRELAY_CONFIG", raising=False)
|
|
298
|
+
xdg = tmp_path / "xdg"
|
|
299
|
+
cfg = xdg / "ctrlrelay" / "orchestrator.yaml"
|
|
300
|
+
cfg.parent.mkdir(parents=True)
|
|
301
|
+
cfg.write_text("version: '1'\n")
|
|
302
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
|
|
303
|
+
empty_cwd = tmp_path / "empty"
|
|
304
|
+
empty_cwd.mkdir()
|
|
305
|
+
monkeypatch.chdir(empty_cwd)
|
|
306
|
+
|
|
307
|
+
assert resolve_config_path(None) == cfg
|
|
308
|
+
|
|
309
|
+
def test_raises_when_nothing_found(
|
|
310
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
311
|
+
) -> None:
|
|
312
|
+
"""No env var, no cwd hit, no XDG hit → ConfigError lists searched paths."""
|
|
313
|
+
monkeypatch.delenv("CTRLRELAY_CONFIG", raising=False)
|
|
314
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "nope"))
|
|
315
|
+
empty = tmp_path / "empty"
|
|
316
|
+
empty.mkdir()
|
|
317
|
+
monkeypatch.chdir(empty)
|
|
318
|
+
|
|
319
|
+
with pytest.raises(ConfigError, match="No orchestrator.yaml found"):
|
|
320
|
+
resolve_config_path(None)
|
|
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
|