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.
Files changed (71) hide show
  1. {openreflex-0.9.2/src/openreflex.egg-info → openreflex-0.9.3}/PKG-INFO +1 -1
  2. {openreflex-0.9.2 → openreflex-0.9.3}/pyproject.toml +1 -1
  3. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/__init__.py +1 -1
  4. openreflex-0.9.3/src/openreflex/claude_ui.py +186 -0
  5. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/entrypoint.py +8 -4
  6. {openreflex-0.9.2 → openreflex-0.9.3/src/openreflex.egg-info}/PKG-INFO +1 -1
  7. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/SOURCES.txt +1 -0
  8. openreflex-0.9.3/tests/test_claude_ui.py +152 -0
  9. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_tui_memory.py +11 -11
  10. openreflex-0.9.2/src/openreflex/claude_ui.py +0 -77
  11. {openreflex-0.9.2 → openreflex-0.9.3}/LICENSE +0 -0
  12. {openreflex-0.9.2 → openreflex-0.9.3}/README.md +0 -0
  13. {openreflex-0.9.2 → openreflex-0.9.3}/setup.cfg +0 -0
  14. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/__main__.py +0 -0
  15. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/benchmark.py +0 -0
  16. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/cli.py +0 -0
  17. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/control.py +0 -0
  18. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/decision.py +0 -0
  19. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/detect.py +0 -0
  20. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/engine.py +0 -0
  21. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/execution_state.py +0 -0
  22. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/hooks.py +0 -0
  23. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/install.py +0 -0
  24. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/integrations/opencode.ts +0 -0
  25. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/learning.py +0 -0
  26. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/mcp_contracts.py +0 -0
  27. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/mcp_server.py +0 -0
  28. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/metrics.py +0 -0
  29. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/models.py +0 -0
  30. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/policies/default.toml +0 -0
  31. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/policy.py +0 -0
  32. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/privacy.py +0 -0
  33. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/project.py +0 -0
  34. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/project_map.py +0 -0
  35. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/project_memory.py +0 -0
  36. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/reflex_index.py +0 -0
  37. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/reflexes.py +0 -0
  38. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/routing.py +0 -0
  39. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/store.py +0 -0
  40. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/tui.py +0 -0
  41. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/updater.py +0 -0
  42. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex/usage.py +0 -0
  43. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/dependency_links.txt +0 -0
  44. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/entry_points.txt +0 -0
  45. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/requires.txt +0 -0
  46. {openreflex-0.9.2 → openreflex-0.9.3}/src/openreflex.egg-info/top_level.txt +0 -0
  47. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_completion_recap_semantics.py +0 -0
  48. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_engine.py +0 -0
  49. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_execution_state.py +0 -0
  50. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_hooks.py +0 -0
  51. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_install_cli.py +0 -0
  52. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_live_eval.py +0 -0
  53. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_mcp_metrics_benchmark.py +0 -0
  54. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_outcome_closure.py +0 -0
  55. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_policy_recap.py +0 -0
  56. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_primer_background.py +0 -0
  57. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_map.py +0 -0
  58. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_memory.py +0 -0
  59. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_reflexes.py +0 -0
  60. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_project_scope.py +0 -0
  61. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_quality_hardening.py +0 -0
  62. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_reflex_index.py +0 -0
  63. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_release_consistency.py +0 -0
  64. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_release_pipeline.py +0 -0
  65. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_reliability_lifecycle.py +0 -0
  66. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_routing_control.py +0 -0
  67. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_stress.py +0 -0
  68. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_token_receiver.py +0 -0
  69. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_tokens_cli.py +0 -0
  70. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_update_cli.py +0 -0
  71. {openreflex-0.9.2 → openreflex-0.9.3}/tests/test_work_modes_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreflex
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: Ambient project muscle memory for AI coding agents: learns and reuses project Reflexes automatically
5
5
  Author: vishnu-77
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "openreflex"
7
- version = "0.9.2"
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"
@@ -1,3 +1,3 @@
1
1
  """Ambient, local execution intelligence."""
2
2
 
3
- __version__ = "0.9.2"
3
+ __version__ = "0.9.3"
@@ -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, remove_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 remove_statusline(project):
704
- print(" statusline OpenReflex status line removed")
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreflex
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: Ambient project muscle memory for AI coding agents: learns and reuses project Reflexes automatically
5
5
  Author: vishnu-77
6
6
  License-Expression: MIT
@@ -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 = project / ".claude" / "settings.json"
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 remove_statusline(project) is True
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 = project / ".claude" / "settings.json"
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 remove_statusline(project) is True
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((project / ".claude" / "settings.json").read_text(encoding="utf-8"))
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 = project / ".claude" / "settings.json"
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(project) is False
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