Skip to content

Commit 08e56b7

Browse files
JRemitzclaude
andauthored
Release v0.0.26 (#7)
* Release v0.0.26 — plugin install from git, config path fix, hook shared data - Plugin install uses git+{homepage} for GitHub/GitLab repos - Post-install verification catches uv no-op bug - detect_installer() targets correct Python with --python flag - save_config() respects REELN_CONFIG/REELN_PROFILE env vars - HookContext.shared dict for plugin-to-core data passing - GameState.livestreams field persists URLs from hook plugins - resolve_config_path() extracted for reuse - Version strings removed from test assertions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update docs for v0.0.26: plugin git install, HookContext.shared, config path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix ruff SIM102: combine nested if statements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e6c2c73 commit 08e56b7

14 files changed

Lines changed: 261 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
77

88
## [Unreleased]
99

10+
## [0.0.26] - 2026-03-04
11+
12+
### Added
13+
- `HookContext.shared` dict for plugins to pass data back (e.g. livestream URLs)
14+
- `GameState.livestreams` field — persists livestream URLs written by hook plugins
15+
- `resolve_config_path()` — extracted config path resolution for reuse
16+
17+
### Fixed
18+
- Plugin install now uses `git+{homepage}` for GitHub/GitLab plugins instead of PyPI lookup
19+
- Post-install verification catches silent `uv pip install` no-ops
20+
- `save_config()` now respects `REELN_CONFIG` / `REELN_PROFILE` env vars (previously always wrote to default path)
21+
- `detect_installer()` passes `--python sys.executable` to uv so plugins install into the correct environment
22+
- Hardcoded version strings removed from tests (use `__version__` dynamically)
23+
1024
## [0.0.25] - 2026-03-04
1125

1226
### Fixed

docs/cli/plugins.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ reeln plugins install youtube --installer uv
8484
| `--dry-run` | Preview the install command without executing |
8585
| `--installer` | Force a specific installer (`pip` or `uv`) |
8686

87-
After installation, the plugin is automatically enabled in your config. The installer is auto-detected: `uv` is preferred when available, otherwise falls back to `pip`. On permission failures, the command auto-retries with `--user`.
87+
After installation, the plugin is automatically enabled in your config.
88+
89+
**Install source:** When a plugin's registry entry has a GitHub or GitLab homepage, the install uses `git+{homepage}` (e.g. `git+https://github.com/StreamnDad/reeln-plugin-streamn-scoreboard`). Plugins without a git homepage fall back to the PyPI package name.
90+
91+
The installer is auto-detected: `uv` is preferred when available, otherwise falls back to `pip`. On permission failures, the command auto-retries with `--user`.
8892

8993
### `reeln plugins update`
9094

@@ -181,6 +185,19 @@ reeln exposes lifecycle hooks that plugins can subscribe to:
181185
| `ON_SEGMENT_COMPLETE` | After segment merge and state update |
182186
| `ON_ERROR` | When an error occurs in core operations |
183187

188+
Hooks receive a `HookContext` with three fields:
189+
190+
- `hook` — the hook type (e.g. `Hook.ON_GAME_INIT`)
191+
- `data` — read-only data from core (game directory, team profiles, etc.)
192+
- `shared` — writable dict for plugins to pass data back to core
193+
194+
```python
195+
def on_game_init(context: HookContext) -> None:
196+
game_dir = context.data["game_dir"]
197+
# Write data back — core persists shared["livestreams"] to game.json
198+
context.shared["livestreams"] = {"youtube": "https://..."}
199+
```
200+
184201
## Capability protocols
185202

186203
Plugins can implement typed capability interfaces:

docs/guide/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ Priority order (highest wins):
289289
4. `REELN_PROFILE` env var
290290
5. Default XDG path (`config.json`)
291291

292+
This priority applies to both reading and writing. When a command modifies config (e.g. `reeln plugins enable`), the changes are written back to the same resolved path.
293+
292294
```bash
293295
# Use a specific config file
294296
export REELN_CONFIG=~/projects/tournament/reeln.json

reeln/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
from __future__ import annotations
44

5-
__version__ = "0.0.25"
5+
__version__ = "0.0.26"

reeln/core/config.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -338,24 +338,19 @@ def validate_plugin_configs(settings: dict[str, dict[str, Any]]) -> list[str]:
338338
# ---------------------------------------------------------------------------
339339

340340

341-
def load_config(
341+
def resolve_config_path(
342342
path: Path | None = None,
343343
profile: str | None = None,
344-
) -> AppConfig:
345-
"""Load config from disk with env var overrides.
344+
) -> Path:
345+
"""Resolve the config file path using the standard priority order.
346346
347-
Loading order: bundled defaults → user config file → env vars.
348-
349-
The config file path can be set via (in priority order):
347+
Priority:
350348
1. ``path`` argument (e.g. ``--config`` CLI flag)
351349
2. ``REELN_CONFIG`` environment variable
352350
3. ``profile`` argument (e.g. ``--profile`` CLI flag)
353351
4. ``REELN_PROFILE`` environment variable
354352
5. Default XDG path (``~/.config/reeln/config.json``)
355353
"""
356-
base = config_to_dict(default_config())
357-
358-
# Resolve config file: explicit path → env var → profile → default
359354
if path is None:
360355
env_config = os.environ.get("REELN_CONFIG")
361356
if env_config:
@@ -364,7 +359,27 @@ def load_config(
364359
env_profile = os.environ.get("REELN_PROFILE")
365360
if env_profile:
366361
profile = env_profile
367-
file_path = path or default_config_path(profile)
362+
return path or default_config_path(profile)
363+
364+
365+
def load_config(
366+
path: Path | None = None,
367+
profile: str | None = None,
368+
) -> AppConfig:
369+
"""Load config from disk with env var overrides.
370+
371+
Loading order: bundled defaults → user config file → env vars.
372+
373+
The config file path can be set via (in priority order):
374+
1. ``path`` argument (e.g. ``--config`` CLI flag)
375+
2. ``REELN_CONFIG`` environment variable
376+
3. ``profile`` argument (e.g. ``--profile`` CLI flag)
377+
4. ``REELN_PROFILE`` environment variable
378+
5. Default XDG path (``~/.config/reeln/config.json``)
379+
"""
380+
base = config_to_dict(default_config())
381+
382+
file_path = resolve_config_path(path, profile)
368383
if file_path.is_file():
369384
try:
370385
raw = json.loads(file_path.read_text(encoding="utf-8"))
@@ -392,8 +407,11 @@ def save_config(config: AppConfig, path: Path | None = None) -> Path:
392407
"""Atomically write config to disk.
393408
394409
Uses tempfile + ``Path.replace()`` to prevent corruption.
410+
Respects ``REELN_CONFIG`` / ``REELN_PROFILE`` env vars when no
411+
explicit *path* is given, matching the resolution order of
412+
:func:`load_config`.
395413
"""
396-
file_path = path or default_config_path()
414+
file_path = resolve_config_path(path)
397415
file_path.parent.mkdir(parents=True, exist_ok=True)
398416

399417
data = config_to_dict(config)

reeln/core/highlights.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,15 @@ def init_game(
167167
if away_profile is not None:
168168
hook_data["away_profile"] = away_profile
169169

170-
get_registry().emit(
171-
Hook.ON_GAME_INIT,
172-
HookContext(hook=Hook.ON_GAME_INIT, data=hook_data),
173-
)
170+
ctx = HookContext(hook=Hook.ON_GAME_INIT, data=hook_data)
171+
get_registry().emit(Hook.ON_GAME_INIT, ctx)
172+
173+
# Persist livestream URLs written by plugins (e.g. Google, Meta)
174+
livestreams = ctx.shared.get("livestreams", {})
175+
if livestreams:
176+
state = load_game_state(game_dir)
177+
state.livestreams = dict(livestreams)
178+
save_game_state(state, game_dir)
174179

175180
messages.append(f"Created {_GAME_STATE_FILE}")
176181
log.info("Game initialized: %s", game_dir)

reeln/core/plugin_registry.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,12 @@ class PipResult:
264264
def detect_installer() -> list[str]:
265265
"""Detect the best available installer.
266266
267-
Returns the command prefix: ``["uv", "pip", "install"]`` if uv is
268-
available, otherwise ``[sys.executable, "-m", "pip", "install"]``.
267+
Returns the command prefix targeting the *running* Python environment
268+
so that plugins are installed alongside reeln-cli (even when reeln is
269+
a uv tool and cwd contains a different ``.venv``).
269270
"""
270271
if shutil.which("uv"):
271-
return ["uv", "pip", "install"]
272+
return ["uv", "pip", "install", "--python", sys.executable]
272273
return [sys.executable, "-m", "pip", "install"]
273274

274275

@@ -403,15 +404,14 @@ def install_plugin(
403404
result = _run_pip([target], dry_run=dry_run, installer=installer)
404405

405406
# Verify the package is actually installed (uv can return 0 for no-ops)
406-
if result.success and not dry_run:
407-
if not get_installed_version(entry.package):
408-
return PipResult(
409-
success=False,
410-
package=entry.package,
411-
action="install",
412-
error=f"Package '{entry.package}' not found after install. "
413-
f"Check that the repository has a valid Python package.",
414-
)
407+
if result.success and not dry_run and not get_installed_version(entry.package):
408+
return PipResult(
409+
success=False,
410+
package=entry.package,
411+
action="install",
412+
error=f"Package '{entry.package}' not found after install. "
413+
f"Check that the repository has a valid Python package.",
414+
)
415415
return result
416416

417417

reeln/models/game.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class GameState:
5858
finished_at: str = ""
5959
renders: list[RenderEntry] = field(default_factory=list)
6060
events: list[GameEvent] = field(default_factory=list)
61+
livestreams: dict[str, str] = field(default_factory=dict)
6162

6263

6364
# ---------------------------------------------------------------------------
@@ -156,6 +157,7 @@ def game_state_to_dict(state: GameState) -> dict[str, Any]:
156157
"finished_at": state.finished_at,
157158
"renders": [render_entry_to_dict(r) for r in state.renders],
158159
"events": [game_event_to_dict(e) for e in state.events],
160+
"livestreams": dict(state.livestreams),
159161
}
160162

161163

@@ -172,4 +174,5 @@ def dict_to_game_state(data: dict[str, Any]) -> GameState:
172174
finished_at=str(data.get("finished_at", "")),
173175
renders=[dict_to_render_entry(r) for r in renders_raw],
174176
events=[dict_to_game_event(e) for e in events_raw],
177+
livestreams=dict(data.get("livestreams", {})),
175178
)

reeln/plugins/hooks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class HookContext:
2929

3030
hook: Hook
3131
data: dict[str, Any] = field(default_factory=dict)
32+
shared: dict[str, Any] = field(default_factory=dict)
3233

3334

3435
class HookHandler(Protocol):

tests/unit/core/test_config.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,65 @@ def test_save_config_cleans_up_on_error(tmp_path: Path) -> None:
800800
assert tmp_files == []
801801

802802

803+
def test_save_config_respects_reeln_config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
804+
env_path = tmp_path / "custom" / "config.json"
805+
monkeypatch.setenv("REELN_CONFIG", str(env_path))
806+
807+
save_config(AppConfig())
808+
809+
assert env_path.is_file()
810+
data = json.loads(env_path.read_text())
811+
assert "config_version" in data
812+
813+
814+
def test_save_config_respects_reeln_profile_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
815+
monkeypatch.setenv("REELN_PROFILE", "game")
816+
monkeypatch.delenv("REELN_CONFIG", raising=False)
817+
818+
expected = default_config_path("game")
819+
result = save_config(AppConfig(), path=expected)
820+
821+
assert result == expected
822+
823+
824+
# ---------------------------------------------------------------------------
825+
# resolve_config_path
826+
# ---------------------------------------------------------------------------
827+
828+
829+
def test_resolve_config_path_explicit_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
830+
from reeln.core.config import resolve_config_path
831+
832+
monkeypatch.setenv("REELN_CONFIG", "/should/be/ignored")
833+
explicit = tmp_path / "explicit.json"
834+
assert resolve_config_path(path=explicit) == explicit
835+
836+
837+
def test_resolve_config_path_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
838+
from reeln.core.config import resolve_config_path
839+
840+
env_path = tmp_path / "env.json"
841+
monkeypatch.setenv("REELN_CONFIG", str(env_path))
842+
assert resolve_config_path() == env_path
843+
844+
845+
def test_resolve_config_path_profile(monkeypatch: pytest.MonkeyPatch) -> None:
846+
from reeln.core.config import resolve_config_path
847+
848+
monkeypatch.delenv("REELN_CONFIG", raising=False)
849+
monkeypatch.delenv("REELN_PROFILE", raising=False)
850+
result = resolve_config_path(profile="game")
851+
assert result == default_config_path("game")
852+
853+
854+
def test_resolve_config_path_default(monkeypatch: pytest.MonkeyPatch) -> None:
855+
from reeln.core.config import resolve_config_path
856+
857+
monkeypatch.delenv("REELN_CONFIG", raising=False)
858+
monkeypatch.delenv("REELN_PROFILE", raising=False)
859+
assert resolve_config_path() == default_config_path()
860+
861+
803862
# ---------------------------------------------------------------------------
804863
# validate_plugin_configs
805864
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)