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.
Files changed (95) hide show
  1. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/CHANGELOG.md +28 -0
  2. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/pyproject.toml +1 -1
  4. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/cli.py +86 -66
  5. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/config.py +57 -0
  6. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_config.py +73 -1
  7. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  8. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  9. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  10. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/dependabot.yml +0 -0
  11. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/build.yml +0 -0
  12. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/cla.yml +0 -0
  13. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/pages.yml +0 -0
  14. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/publish.yml +0 -0
  15. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.github/workflows/test.yml +0 -0
  16. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/.gitignore +0 -0
  17. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/CODE_OF_CONDUCT.md +0 -0
  18. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/CONTRIBUTING.md +0 -0
  19. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/LICENSE +0 -0
  20. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/README.md +0 -0
  21. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/SECURITY.md +0 -0
  22. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/config/orchestrator.yaml.example +0 -0
  23. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/Gemfile +0 -0
  24. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/_config.yml +0 -0
  25. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/architecture.md +0 -0
  26. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/bridge.md +0 -0
  27. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/cli.md +0 -0
  28. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/configuration.md +0 -0
  29. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/development.md +0 -0
  30. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/feedback-loop.md +0 -0
  31. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/getting-started.md +0 -0
  32. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/index.md +0 -0
  33. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/docs/operations.md +0 -0
  34. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/__init__.py +0 -0
  35. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  36. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  37. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  38. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/server.py +0 -0
  39. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  40. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/__init__.py +0 -0
  41. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/audit.py +0 -0
  42. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  43. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  44. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/github.py +0 -0
  45. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/obs.py +0 -0
  46. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/poller.py +0 -0
  47. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  48. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  49. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/scheduler.py +0 -0
  50. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/state.py +0 -0
  51. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/core/worktree.py +0 -0
  52. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  53. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/dashboard/client.py +0 -0
  54. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  55. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/base.py +0 -0
  56. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/dev.py +0 -0
  57. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  58. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/secops.py +0 -0
  59. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/pipelines/task.py +0 -0
  60. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/__init__.py +0 -0
  61. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/base.py +0 -0
  62. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  63. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  64. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/__init__.py +0 -0
  65. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/conftest.py +0 -0
  66. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_audit.py +0 -0
  67. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_bridge_protocol.py +0 -0
  68. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_bridge_server.py +0 -0
  69. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_checkpoint.py +0 -0
  70. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_ci_wait.py +0 -0
  71. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_dev.py +0 -0
  72. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_repos.py +0 -0
  73. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_secops.py +0 -0
  74. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_start.py +0 -0
  75. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_cli_version.py +0 -0
  76. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dashboard_client.py +0 -0
  77. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dev_integration.py +0 -0
  78. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dev_pipeline.py +0 -0
  79. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_dispatcher.py +0 -0
  80. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_docs_site.py +0 -0
  81. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_github.py +0 -0
  82. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_obs.py +0 -0
  83. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_pipeline_base.py +0 -0
  84. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_poller.py +0 -0
  85. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_post_merge.py +0 -0
  86. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_pr_verifier.py +0 -0
  87. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_pr_watcher.py +0 -0
  88. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_scheduler.py +0 -0
  89. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_secops_integration.py +0 -0
  90. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_secops_pipeline.py +0 -0
  91. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_state.py +0 -0
  92. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_task_pipeline.py +0 -0
  93. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_telegram_handler.py +0 -0
  94. {ctrlrelay-0.2.0 → ctrlrelay-0.2.1}/tests/test_transport.py +0 -0
  95. {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.0
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.2.0"
3
+ version = "0.2.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"
@@ -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
- "config/orchestrator.yaml",
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 = Path(config_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
- "config/orchestrator.yaml",
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 = Path(config_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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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 = Path(config_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
- "config/orchestrator.yaml",
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 = Path(config_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
- "config/orchestrator.yaml",
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(Path(config_path))
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(config_path)
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
- config_path,
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml",
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
- "config/orchestrator.yaml", "--config", "-c", help="Path to orchestrator.yaml"
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
- "config/orchestrator.yaml", "--config", "-c", help="Path to orchestrator.yaml"
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
- "config/orchestrator.yaml", "--config", "-c", help="Path to orchestrator.yaml"
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 AutomationConfig, Config, ConfigError, load_config
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