openreflex 0.9.2__tar.gz → 0.9.3__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.
- {openreflex-0.9.2/src/openreflex.egg-info → openreflex-0.9.3}/PKG-INFO +1 -1
- {openreflex-0.9.2 → openreflex-0.9.3}/pyproject.toml +1 -1
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/__init__.py +1 -1
- openreflex-0.9.3/src/openreflex/claude_ui.py +186 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/entrypoint.py +8 -4
- {openreflex-0.9.2 → openreflex-0.9.3/src/openreflex.egg-info}/PKG-INFO +1 -1
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/SOURCES.txt +1 -0
- openreflex-0.9.3/tests/test_claude_ui.py +152 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_tui_memory.py +11 -11
- openreflex-0.9.2/src/openreflex/claude_ui.py +0 -77
- {openreflex-0.9.2 → openreflex-0.9.3}/LICENSE +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/README.md +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/setup.cfg +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/__main__.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/benchmark.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/cli.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/control.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/decision.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/detect.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/engine.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/execution_state.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/hooks.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/install.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/integrations/opencode.ts +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/learning.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/mcp_contracts.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/mcp_server.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/metrics.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/models.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/policies/default.toml +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/policy.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/privacy.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/project.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/project_map.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/project_memory.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/reflex_index.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/reflexes.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/routing.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/store.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/tui.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/updater.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/usage.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/dependency_links.txt +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/entry_points.txt +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/requires.txt +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/top_level.txt +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_completion_recap_semantics.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_engine.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_execution_state.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_hooks.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_install_cli.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_live_eval.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_mcp_metrics_benchmark.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_outcome_closure.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_policy_recap.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_primer_background.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_map.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_memory.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_reflexes.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_scope.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_quality_hardening.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_reflex_index.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_release_consistency.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_release_pipeline.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_reliability_lifecycle.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_routing_control.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_stress.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_token_receiver.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_tokens_cli.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_update_cli.py +0 -0
- {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_work_modes_tokens.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openreflex"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.3"
|
|
8
8
|
description = "Ambient project muscle memory for AI coding agents: learns and reuses project Reflexes automatically"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Claude Code presentation integration.
|
|
2
|
+
|
|
3
|
+
OpenReflex owns a user-level status line, never a project-level statusLine entry.
|
|
4
|
+
Legacy OpenReflex project entries are migrated away without touching user-owned settings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
LEGACY_STATUSLINE_COMMAND = "openreflex statusline"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def statusline_command() -> str:
|
|
17
|
+
"""Return the runtime-owned statusline command.
|
|
18
|
+
|
|
19
|
+
Plugin-managed runtimes pass OPENREFLEX_RUNTIME_COMMAND so the status line cannot
|
|
20
|
+
resolve an unrelated or stale global `openreflex` executable from PATH.
|
|
21
|
+
"""
|
|
22
|
+
runtime = os.environ.get("OPENREFLEX_RUNTIME_COMMAND", "").strip()
|
|
23
|
+
return f"{runtime} statusline" if runtime else LEGACY_STATUSLINE_COMMAND
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def claude_settings_path() -> Path:
|
|
27
|
+
"""Return Claude Code's user-level settings path.
|
|
28
|
+
|
|
29
|
+
CLAUDE_CONFIG_DIR is respected so alternate Claude profiles and isolated tests do
|
|
30
|
+
not fall back to the real user home directory.
|
|
31
|
+
"""
|
|
32
|
+
configured = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
33
|
+
root = Path(configured).expanduser() if configured else Path.home() / ".claude"
|
|
34
|
+
return root / "settings.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ours(command: object) -> bool:
|
|
38
|
+
if not isinstance(command, str):
|
|
39
|
+
return False
|
|
40
|
+
value = command.replace("\\", "/").lower()
|
|
41
|
+
return command == LEGACY_STATUSLINE_COMMAND or (
|
|
42
|
+
".openreflex/runtime/" in value and value.rstrip().endswith(" statusline")
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _owned_project_hook(entry: object) -> bool:
|
|
47
|
+
"""True only for hook entries installed by OpenReflex into project settings."""
|
|
48
|
+
value = json.dumps(entry, default=str).replace("\\\\", "/").lower()
|
|
49
|
+
return (
|
|
50
|
+
"openreflex hook claude-code" in value
|
|
51
|
+
or ("runtime/launcher.cjs" in value and " hook claude-code " in value)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load(path: Path) -> dict | None:
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return None
|
|
62
|
+
return value if isinstance(value, dict) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write(path: Path, data: dict) -> None:
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
temp = path.with_suffix(path.suffix + ".tmp")
|
|
68
|
+
temp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
69
|
+
os.replace(temp, path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _remove_owned_statusline(path: Path, *, delete_empty: bool = False) -> bool:
|
|
73
|
+
data = _load(path)
|
|
74
|
+
if data is None:
|
|
75
|
+
return False
|
|
76
|
+
current = data.get("statusLine")
|
|
77
|
+
if not isinstance(current, dict) or not _ours(current.get("command")):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
data.pop("statusLine", None)
|
|
81
|
+
if delete_empty and not data:
|
|
82
|
+
try:
|
|
83
|
+
path.unlink()
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
path.parent.rmdir()
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
else:
|
|
91
|
+
_write(path, data)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def remove_project_integration(project: Path) -> bool:
|
|
96
|
+
"""Remove legacy OpenReflex-owned Claude project config, preserving user config.
|
|
97
|
+
|
|
98
|
+
The Claude plugin is user-scoped and must not require repository-local Claude
|
|
99
|
+
settings. Older OpenReflex releases could leave a project statusLine or hook entries
|
|
100
|
+
behind; remove only those entries and delete the file/directory when it becomes empty.
|
|
101
|
+
"""
|
|
102
|
+
path = project / ".claude" / "settings.json"
|
|
103
|
+
data = _load(path)
|
|
104
|
+
if data is None or not path.exists():
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
changed = False
|
|
108
|
+
current = data.get("statusLine")
|
|
109
|
+
if isinstance(current, dict) and _ours(current.get("command")):
|
|
110
|
+
data.pop("statusLine", None)
|
|
111
|
+
changed = True
|
|
112
|
+
|
|
113
|
+
hooks = data.get("hooks")
|
|
114
|
+
if isinstance(hooks, dict):
|
|
115
|
+
cleaned = {}
|
|
116
|
+
for event, entries in hooks.items():
|
|
117
|
+
if not isinstance(entries, list):
|
|
118
|
+
cleaned[event] = entries
|
|
119
|
+
continue
|
|
120
|
+
kept = [entry for entry in entries if not _owned_project_hook(entry)]
|
|
121
|
+
if kept:
|
|
122
|
+
cleaned[event] = kept
|
|
123
|
+
if len(kept) != len(entries):
|
|
124
|
+
changed = True
|
|
125
|
+
if cleaned:
|
|
126
|
+
data["hooks"] = cleaned
|
|
127
|
+
elif "hooks" in data:
|
|
128
|
+
data.pop("hooks", None)
|
|
129
|
+
|
|
130
|
+
if not changed:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
if not data:
|
|
134
|
+
try:
|
|
135
|
+
path.unlink()
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
pass
|
|
138
|
+
try:
|
|
139
|
+
path.parent.rmdir()
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
else:
|
|
143
|
+
_write(path, data)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def remove_project_statusline(project: Path) -> bool:
|
|
148
|
+
"""Backward-compatible cleanup entry point for legacy callers."""
|
|
149
|
+
return remove_project_integration(project)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def configure_statusline(project: Path | None = None) -> str:
|
|
153
|
+
"""Configure the OpenReflex status line in Claude's user settings.
|
|
154
|
+
|
|
155
|
+
A project argument is accepted because session hooks know the active project. It is
|
|
156
|
+
used only to migrate a legacy OpenReflex-owned project statusLine entry; the desired
|
|
157
|
+
status line is always written to the user scope.
|
|
158
|
+
"""
|
|
159
|
+
if project is not None:
|
|
160
|
+
remove_project_integration(project)
|
|
161
|
+
|
|
162
|
+
path = claude_settings_path()
|
|
163
|
+
data = _load(path)
|
|
164
|
+
if data is None:
|
|
165
|
+
return "preserved-invalid"
|
|
166
|
+
|
|
167
|
+
existing = data.get("statusLine")
|
|
168
|
+
desired = statusline_command()
|
|
169
|
+
if isinstance(existing, dict):
|
|
170
|
+
current = existing.get("command")
|
|
171
|
+
if current == desired:
|
|
172
|
+
return "already-configured"
|
|
173
|
+
if not _ours(current):
|
|
174
|
+
return "preserved-existing"
|
|
175
|
+
data["statusLine"] = {**existing, "command": desired, "padding": 0, "refreshInterval": 2}
|
|
176
|
+
_write(path, data)
|
|
177
|
+
return "updated-runtime"
|
|
178
|
+
|
|
179
|
+
data["statusLine"] = {"type": "command", "command": desired, "padding": 0, "refreshInterval": 2}
|
|
180
|
+
_write(path, data)
|
|
181
|
+
return "configured"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def remove_statusline() -> bool:
|
|
185
|
+
"""Remove only the OpenReflex-owned user-level status line."""
|
|
186
|
+
return _remove_owned_statusline(claude_settings_path())
|
|
@@ -18,7 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Callable, TypeVar
|
|
19
19
|
|
|
20
20
|
from . import __version__, cli
|
|
21
|
-
from .claude_ui import configure_statusline,
|
|
21
|
+
from .claude_ui import configure_statusline, remove_project_integration
|
|
22
22
|
from .project import approval, approve, log_error, project_root
|
|
23
23
|
from .project_map import enrich_snapshot
|
|
24
24
|
from .project_memory import ensure_background_refresh, load_snapshot, read_state, run_worker, update_state
|
|
@@ -564,8 +564,10 @@ def _onboard(argv: list[str]) -> int:
|
|
|
564
564
|
print(" memory enabled · local")
|
|
565
565
|
if status == "preserved-existing":
|
|
566
566
|
print(" statusline existing user status line preserved")
|
|
567
|
+
elif status == "preserved-invalid":
|
|
568
|
+
print(" statusline user settings unreadable; preserved unchanged")
|
|
567
569
|
else:
|
|
568
|
-
print(" statusline managed OpenReflex runtime")
|
|
570
|
+
print(" statusline managed OpenReflex runtime · user scope")
|
|
569
571
|
print("")
|
|
570
572
|
print(dashboard(project))
|
|
571
573
|
return 0
|
|
@@ -694,14 +696,16 @@ def _augment_install(argv: list[str], result: int) -> None:
|
|
|
694
696
|
print(f" statusline OpenReflex v{__version__} enabled")
|
|
695
697
|
elif status == "preserved-existing":
|
|
696
698
|
print(" statusline existing user status line preserved")
|
|
699
|
+
elif status == "preserved-invalid":
|
|
700
|
+
print(" statusline user settings unreadable; preserved unchanged")
|
|
697
701
|
|
|
698
702
|
|
|
699
703
|
def _augment_uninstall(argv: list[str], result: int) -> None:
|
|
700
704
|
if result != 0 or len(argv) < 2 or argv[1] != "claude-code" or "--dry-run" in argv:
|
|
701
705
|
return
|
|
702
706
|
project = project_root(_project_arg(argv))
|
|
703
|
-
if
|
|
704
|
-
print("
|
|
707
|
+
if remove_project_integration(project):
|
|
708
|
+
print(" project legacy OpenReflex Claude config removed")
|
|
705
709
|
|
|
706
710
|
|
|
707
711
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -39,6 +39,7 @@ src/openreflex.egg-info/requires.txt
|
|
|
39
39
|
src/openreflex.egg-info/top_level.txt
|
|
40
40
|
src/openreflex/integrations/opencode.ts
|
|
41
41
|
src/openreflex/policies/default.toml
|
|
42
|
+
tests/test_claude_ui.py
|
|
42
43
|
tests/test_completion_recap_semantics.py
|
|
43
44
|
tests/test_engine.py
|
|
44
45
|
tests/test_execution_state.py
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from openreflex import __version__
|
|
4
|
+
from openreflex.claude_ui import (
|
|
5
|
+
configure_statusline,
|
|
6
|
+
remove_project_integration,
|
|
7
|
+
remove_project_statusline,
|
|
8
|
+
remove_statusline,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _user_settings(tmp_path, monkeypatch):
|
|
13
|
+
config = tmp_path / "claude-user"
|
|
14
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config))
|
|
15
|
+
return config / "settings.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_statusline_is_user_scoped_and_does_not_create_project_settings(tmp_path, monkeypatch):
|
|
19
|
+
settings = _user_settings(tmp_path, monkeypatch)
|
|
20
|
+
project = tmp_path / "repo"
|
|
21
|
+
project.mkdir()
|
|
22
|
+
runtime = tmp_path / ".openreflex" / "runtime" / f"v{__version__}" / "bin" / "openreflex"
|
|
23
|
+
monkeypatch.setenv("OPENREFLEX_RUNTIME_COMMAND", f'"{runtime}"')
|
|
24
|
+
|
|
25
|
+
assert configure_statusline(project) == "configured"
|
|
26
|
+
|
|
27
|
+
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
28
|
+
assert data["statusLine"]["command"].endswith(" statusline")
|
|
29
|
+
assert f"/runtime/v{__version__}/" in data["statusLine"]["command"].replace("\\", "/")
|
|
30
|
+
assert not (project / ".claude" / "settings.json").exists()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_configure_migrates_legacy_project_statusline_without_touching_other_settings(tmp_path, monkeypatch):
|
|
34
|
+
user_settings = _user_settings(tmp_path, monkeypatch)
|
|
35
|
+
project = tmp_path / "repo"
|
|
36
|
+
project_settings = project / ".claude" / "settings.json"
|
|
37
|
+
project_settings.parent.mkdir(parents=True)
|
|
38
|
+
project_settings.write_text(json.dumps({
|
|
39
|
+
"permissions": {"allow": ["Bash(git status)"]},
|
|
40
|
+
"statusLine": {"type": "command", "command": "openreflex statusline"},
|
|
41
|
+
}), encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
assert configure_statusline(project) == "configured"
|
|
44
|
+
|
|
45
|
+
migrated = json.loads(project_settings.read_text(encoding="utf-8"))
|
|
46
|
+
assert migrated == {"permissions": {"allow": ["Bash(git status)"]}}
|
|
47
|
+
assert "statusLine" in json.loads(user_settings.read_text(encoding="utf-8"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_configure_removes_statusline_only_project_file_entirely(tmp_path, monkeypatch):
|
|
51
|
+
_user_settings(tmp_path, monkeypatch)
|
|
52
|
+
project = tmp_path / "repo"
|
|
53
|
+
project_settings = project / ".claude" / "settings.json"
|
|
54
|
+
project_settings.parent.mkdir(parents=True)
|
|
55
|
+
project_settings.write_text(json.dumps({
|
|
56
|
+
"statusLine": {"type": "command", "command": "openreflex statusline"},
|
|
57
|
+
}), encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
configure_statusline(project)
|
|
60
|
+
|
|
61
|
+
assert not project_settings.exists()
|
|
62
|
+
assert not project_settings.parent.exists()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_existing_user_statusline_is_preserved(tmp_path, monkeypatch):
|
|
66
|
+
settings = _user_settings(tmp_path, monkeypatch)
|
|
67
|
+
settings.parent.mkdir(parents=True)
|
|
68
|
+
original = {
|
|
69
|
+
"theme": "dark",
|
|
70
|
+
"statusLine": {"type": "command", "command": "~/.claude/my-statusline.sh", "padding": 1},
|
|
71
|
+
}
|
|
72
|
+
settings.write_text(json.dumps(original), encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
assert configure_statusline(tmp_path / "repo") == "preserved-existing"
|
|
75
|
+
assert json.loads(settings.read_text(encoding="utf-8")) == original
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_invalid_user_settings_are_never_overwritten(tmp_path, monkeypatch):
|
|
79
|
+
settings = _user_settings(tmp_path, monkeypatch)
|
|
80
|
+
settings.parent.mkdir(parents=True)
|
|
81
|
+
settings.write_text("{broken", encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
assert configure_statusline(tmp_path / "repo") == "preserved-invalid"
|
|
84
|
+
assert settings.read_text(encoding="utf-8") == "{broken"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_project_cleanup_never_removes_user_statusline(tmp_path, monkeypatch):
|
|
88
|
+
settings = _user_settings(tmp_path, monkeypatch)
|
|
89
|
+
settings.parent.mkdir(parents=True)
|
|
90
|
+
settings.write_text(json.dumps({
|
|
91
|
+
"statusLine": {"type": "command", "command": "openreflex statusline"},
|
|
92
|
+
}), encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
project = tmp_path / "repo"
|
|
95
|
+
project_settings = project / ".claude" / "settings.json"
|
|
96
|
+
project_settings.parent.mkdir(parents=True)
|
|
97
|
+
project_settings.write_text(json.dumps({
|
|
98
|
+
"statusLine": {"type": "command", "command": "openreflex statusline"},
|
|
99
|
+
}), encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
assert remove_project_statusline(project) is True
|
|
102
|
+
assert not project_settings.exists()
|
|
103
|
+
assert settings.exists()
|
|
104
|
+
assert remove_statusline() is True
|
|
105
|
+
assert json.loads(settings.read_text(encoding="utf-8")) == {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_project_cleanup_removes_legacy_openreflex_hooks_and_preserves_user_hooks(tmp_path, monkeypatch):
|
|
109
|
+
_user_settings(tmp_path, monkeypatch)
|
|
110
|
+
project = tmp_path / "repo"
|
|
111
|
+
project_settings = project / ".claude" / "settings.json"
|
|
112
|
+
project_settings.parent.mkdir(parents=True)
|
|
113
|
+
user_hook = {"hooks": [{"type": "command", "command": "echo user-owned"}]}
|
|
114
|
+
openreflex_hook = {
|
|
115
|
+
"hooks": [{
|
|
116
|
+
"type": "command",
|
|
117
|
+
"command": 'openreflex hook claude-code Stop --plugin-root "${CLAUDE_PLUGIN_ROOT}"',
|
|
118
|
+
"timeout": 15,
|
|
119
|
+
}]
|
|
120
|
+
}
|
|
121
|
+
project_settings.write_text(json.dumps({
|
|
122
|
+
"permissions": {"allow": ["Bash(git status)"]},
|
|
123
|
+
"hooks": {"Stop": [user_hook, openreflex_hook]},
|
|
124
|
+
}), encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
assert remove_project_integration(project) is True
|
|
127
|
+
|
|
128
|
+
data = json.loads(project_settings.read_text(encoding="utf-8"))
|
|
129
|
+
assert data["permissions"] == {"allow": ["Bash(git status)"]}
|
|
130
|
+
assert data["hooks"]["Stop"] == [user_hook]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_project_cleanup_deletes_openreflex_only_hook_file(tmp_path, monkeypatch):
|
|
134
|
+
_user_settings(tmp_path, monkeypatch)
|
|
135
|
+
project = tmp_path / "repo"
|
|
136
|
+
project_settings = project / ".claude" / "settings.json"
|
|
137
|
+
project_settings.parent.mkdir(parents=True)
|
|
138
|
+
project_settings.write_text(json.dumps({
|
|
139
|
+
"hooks": {
|
|
140
|
+
"SessionStart": [{
|
|
141
|
+
"hooks": [{
|
|
142
|
+
"type": "command",
|
|
143
|
+
"command": 'openreflex hook claude-code SessionStart --plugin-root "${CLAUDE_PLUGIN_ROOT}"',
|
|
144
|
+
"timeout": 10,
|
|
145
|
+
}]
|
|
146
|
+
}]
|
|
147
|
+
}
|
|
148
|
+
}), encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
assert remove_project_integration(project) is True
|
|
151
|
+
assert not project_settings.exists()
|
|
152
|
+
assert not project_settings.parent.exists()
|
|
@@ -2,7 +2,7 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
4
|
from openreflex import __version__, entrypoint
|
|
5
|
-
from openreflex.claude_ui import configure_statusline, remove_statusline
|
|
5
|
+
from openreflex.claude_ui import claude_settings_path, configure_statusline, remove_statusline
|
|
6
6
|
from openreflex.entrypoint import (
|
|
7
7
|
_prompt_state,
|
|
8
8
|
_record_hook_runtime,
|
|
@@ -202,20 +202,19 @@ def test_dashboard_is_readable_without_colour(project):
|
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
def test_claude_statusline_is_added_only_when_user_has_none(project):
|
|
205
|
-
settings =
|
|
206
|
-
settings.parent.mkdir(parents=True)
|
|
207
|
-
settings.write_text('{"hooks": {}}\n', encoding="utf-8")
|
|
205
|
+
settings = claude_settings_path()
|
|
208
206
|
|
|
209
207
|
assert configure_statusline(project) == "configured"
|
|
210
208
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
211
209
|
assert data["statusLine"]["command"] == "openreflex statusline"
|
|
212
210
|
assert data["statusLine"]["refreshInterval"] == 2
|
|
213
|
-
assert
|
|
211
|
+
assert not (project / ".claude" / "settings.json").exists()
|
|
212
|
+
assert remove_statusline() is True
|
|
214
213
|
assert "statusLine" not in json.loads(settings.read_text(encoding="utf-8"))
|
|
215
214
|
|
|
216
215
|
|
|
217
216
|
def test_managed_statusline_replaces_only_openreflex_owned_runtime(monkeypatch, project):
|
|
218
|
-
settings =
|
|
217
|
+
settings = claude_settings_path()
|
|
219
218
|
settings.parent.mkdir(parents=True)
|
|
220
219
|
settings.write_text(json.dumps({
|
|
221
220
|
"statusLine": {"type": "command", "command": "openreflex statusline", "refreshInterval": 2}
|
|
@@ -227,7 +226,8 @@ def test_managed_statusline_replaces_only_openreflex_owned_runtime(monkeypatch,
|
|
|
227
226
|
assert configure_statusline(project) == "updated-runtime"
|
|
228
227
|
current = json.loads(settings.read_text(encoding="utf-8"))["statusLine"]["command"]
|
|
229
228
|
assert current == managed + " statusline"
|
|
230
|
-
assert
|
|
229
|
+
assert not (project / ".claude" / "settings.json").exists()
|
|
230
|
+
assert remove_statusline() is True
|
|
231
231
|
|
|
232
232
|
|
|
233
233
|
def test_onboard_enables_project_and_uses_plugin_managed_runtime(monkeypatch, project, tmp_path, capsys):
|
|
@@ -246,8 +246,9 @@ def test_onboard_enables_project_and_uses_plugin_managed_runtime(monkeypatch, pr
|
|
|
246
246
|
assert state["runtime_source"] == "plugin-managed"
|
|
247
247
|
assert state["hook_runtime_version"] == __version__
|
|
248
248
|
assert state["plugin_version"] == __version__
|
|
249
|
-
settings = json.loads((
|
|
249
|
+
settings = json.loads(claude_settings_path().read_text(encoding="utf-8"))
|
|
250
250
|
assert settings["statusLine"]["command"] == managed + " statusline"
|
|
251
|
+
assert not (project / ".claude" / "settings.json").exists()
|
|
251
252
|
output = capsys.readouterr().out
|
|
252
253
|
assert "OPENREFLEX / READY" in output
|
|
253
254
|
assert "project memory enabled" in output
|
|
@@ -255,15 +256,14 @@ def test_onboard_enables_project_and_uses_plugin_managed_runtime(monkeypatch, pr
|
|
|
255
256
|
|
|
256
257
|
|
|
257
258
|
def test_existing_user_statusline_is_never_replaced_or_removed(project):
|
|
258
|
-
settings =
|
|
259
|
+
settings = claude_settings_path()
|
|
259
260
|
settings.parent.mkdir(parents=True)
|
|
260
261
|
original = {"statusLine": {"type": "command", "command": "~/my-status.sh"}, "hooks": {}}
|
|
261
262
|
settings.write_text(json.dumps(original), encoding="utf-8")
|
|
262
263
|
|
|
263
264
|
assert configure_statusline(project) == "preserved-existing"
|
|
264
265
|
assert json.loads(settings.read_text(encoding="utf-8"))["statusLine"] == original["statusLine"]
|
|
265
|
-
assert remove_statusline(
|
|
266
|
-
|
|
266
|
+
assert remove_statusline() is False
|
|
267
267
|
|
|
268
268
|
def test_tui_survives_a_non_utf8_console(monkeypatch, project):
|
|
269
269
|
"""The dashboard's OpenReflex glyph (U+21BA) must not crash on a legacy Windows console codepage."""
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Claude Code presentation integration.
|
|
2
|
-
|
|
3
|
-
OpenReflex only installs its status line when the project has no existing custom
|
|
4
|
-
statusLine. A user-owned status line is never replaced.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
LEGACY_STATUSLINE_COMMAND = "openreflex statusline"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def statusline_command() -> str:
|
|
17
|
-
"""Return the runtime-owned statusline command.
|
|
18
|
-
|
|
19
|
-
Plugin-managed runtimes pass OPENREFLEX_RUNTIME_COMMAND so the status line cannot
|
|
20
|
-
resolve an unrelated or stale global `openreflex` executable from PATH.
|
|
21
|
-
"""
|
|
22
|
-
runtime = os.environ.get("OPENREFLEX_RUNTIME_COMMAND", "").strip()
|
|
23
|
-
return f"{runtime} statusline" if runtime else LEGACY_STATUSLINE_COMMAND
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _ours(command: object) -> bool:
|
|
27
|
-
if not isinstance(command, str):
|
|
28
|
-
return False
|
|
29
|
-
value = command.replace("\\", "/").lower()
|
|
30
|
-
return command == LEGACY_STATUSLINE_COMMAND or (
|
|
31
|
-
".openreflex/runtime/" in value and value.rstrip().endswith(" statusline")
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _load(path: Path) -> dict:
|
|
36
|
-
try:
|
|
37
|
-
value = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
-
return value if isinstance(value, dict) else {}
|
|
39
|
-
except (OSError, ValueError):
|
|
40
|
-
return {}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _write(path: Path, data: dict) -> None:
|
|
44
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
-
temp = path.with_suffix(path.suffix + ".tmp")
|
|
46
|
-
temp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
47
|
-
os.replace(temp, path)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def configure_statusline(project: Path) -> str:
|
|
51
|
-
path = project / ".claude" / "settings.json"
|
|
52
|
-
data = _load(path)
|
|
53
|
-
existing = data.get("statusLine")
|
|
54
|
-
desired = statusline_command()
|
|
55
|
-
if isinstance(existing, dict):
|
|
56
|
-
current = existing.get("command")
|
|
57
|
-
if current == desired:
|
|
58
|
-
return "already-configured"
|
|
59
|
-
if not _ours(current):
|
|
60
|
-
return "preserved-existing"
|
|
61
|
-
data["statusLine"] = {**existing, "command": desired, "padding": 0, "refreshInterval": 2}
|
|
62
|
-
_write(path, data)
|
|
63
|
-
return "updated-runtime"
|
|
64
|
-
data["statusLine"] = {"type": "command", "command": desired, "padding": 0, "refreshInterval": 2}
|
|
65
|
-
_write(path, data)
|
|
66
|
-
return "configured"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def remove_statusline(project: Path) -> bool:
|
|
70
|
-
path = project / ".claude" / "settings.json"
|
|
71
|
-
data = _load(path)
|
|
72
|
-
current = data.get("statusLine")
|
|
73
|
-
if not isinstance(current, dict) or not _ours(current.get("command")):
|
|
74
|
-
return False
|
|
75
|
-
data.pop("statusLine", None)
|
|
76
|
-
_write(path, data)
|
|
77
|
-
return True
|
|
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
|