claude-dev-env 8.96.7 → 8.97.0

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.
@@ -0,0 +1,190 @@
1
+ """Per-session baseline of the installed plugins, read against the live plugin record.
2
+
3
+ The SessionStart baseline writer records each plugin's ``version`` and
4
+ ``installPath`` from ``installed_plugins.json`` into one JSON file per session
5
+ in the system temp folder. The UserPromptSubmit warning hook reads the same
6
+ file back, compares it with the live record, and lists the plugin ids it has
7
+ already warned about under ``warned``::
8
+
9
+ plugin-session-baseline-<session id>.json
10
+ {"plugins": {"pstack@pstack-claude": [{"version": "1.0.0",
11
+ "installPath": "/cache/pstack/1.0.0"}]}, "warned": []}
12
+
13
+ Fields such as ``lastUpdated`` stay out of the snapshot, so a refresh that
14
+ changes nothing a session loads reads as unchanged.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from atomic_file_writer import write_text_atomically
25
+ from hooks_constants.atomic_file_writer_constants import ATOMIC_WRITE_TEMPORARY_SUFFIX
26
+ from hooks_constants.plugin_session_baseline_constants import (
27
+ BASELINE_PLUGINS_KEY,
28
+ BASELINE_RECORD_FILE_TEMPLATE,
29
+ BASELINE_RECORD_TEMPORARY_PREFIX,
30
+ BASELINE_TEXT_ENCODING,
31
+ BASELINE_WARNED_KEY,
32
+ CLAUDE_CONFIG_DIR_ENV_VAR,
33
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
34
+ INSTALLED_PLUGINS_FILE_NAME,
35
+ INSTALLED_PLUGINS_RECORD_KEY,
36
+ INSTALL_PATH_FIELD,
37
+ PLUGINS_DIRECTORY_NAME,
38
+ VERSION_FIELD,
39
+ )
40
+ from hooks_constants.session_file_id import session_file_id
41
+ from json_file_reader import read_json_object
42
+
43
+ __all__ = [
44
+ "all_changed_plugin_ids",
45
+ "baseline_record_path",
46
+ "current_plugin_snapshot",
47
+ "installed_plugins_path",
48
+ "read_baseline_record",
49
+ "write_baseline_record",
50
+ ]
51
+
52
+ PluginSnapshot = dict[str, list[dict[str, str]]]
53
+
54
+
55
+ def installed_plugins_path() -> Path:
56
+ """Return the installed plugin record under the active Claude config directory."""
57
+ configured_directory = os.environ.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
58
+ config_directory = (
59
+ Path(configured_directory)
60
+ if configured_directory
61
+ else Path.home() / DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME
62
+ )
63
+ return config_directory / PLUGINS_DIRECTORY_NAME / INSTALLED_PLUGINS_FILE_NAME
64
+
65
+
66
+ def _loaded_fields(each_entry: object) -> dict[str, str] | None:
67
+ if not isinstance(each_entry, dict):
68
+ return None
69
+ version = each_entry.get(VERSION_FIELD)
70
+ install_path = each_entry.get(INSTALL_PATH_FIELD)
71
+ if not isinstance(version, str) or not isinstance(install_path, str):
72
+ return None
73
+ return {VERSION_FIELD: version, INSTALL_PATH_FIELD: install_path}
74
+
75
+
76
+ def _sorted_entries(all_entries: list[dict[str, str]]) -> list[dict[str, str]]:
77
+ return sorted(
78
+ all_entries,
79
+ key=lambda each_entry: (
80
+ each_entry[INSTALL_PATH_FIELD],
81
+ each_entry[VERSION_FIELD],
82
+ ),
83
+ )
84
+
85
+
86
+ def current_plugin_snapshot(record_path: Path) -> PluginSnapshot:
87
+ """Return each plugin id with the version and install path of each of its installs.
88
+
89
+ A missing, unreadable, or malformed record reads as no plugins.
90
+
91
+ Args:
92
+ record_path: The ``installed_plugins.json`` path.
93
+ """
94
+ installed_record = read_json_object(record_path, BASELINE_TEXT_ENCODING)
95
+ if installed_record is None:
96
+ return {}
97
+ all_plugins = installed_record.get(INSTALLED_PLUGINS_RECORD_KEY)
98
+ if not isinstance(all_plugins, dict):
99
+ return {}
100
+ snapshot: PluginSnapshot = {}
101
+ for each_plugin_id, each_plugin_entries in all_plugins.items():
102
+ if not isinstance(each_plugin_entries, list):
103
+ continue
104
+ all_loaded_entries = [
105
+ loaded_entry
106
+ for loaded_entry in (
107
+ _loaded_fields(each_entry) for each_entry in each_plugin_entries
108
+ )
109
+ if loaded_entry is not None
110
+ ]
111
+ snapshot[str(each_plugin_id)] = _sorted_entries(all_loaded_entries)
112
+ return snapshot
113
+
114
+
115
+ def baseline_record_path(session_id: object) -> Path:
116
+ """Return the baseline file path for one session.
117
+
118
+ Args:
119
+ session_id: The ``session_id`` field of the hook payload.
120
+ """
121
+ file_name = BASELINE_RECORD_FILE_TEMPLATE.format(
122
+ session_file_id=session_file_id(session_id)
123
+ )
124
+ return Path(tempfile.gettempdir()) / file_name
125
+
126
+
127
+ def read_baseline_record(session_id: object) -> dict[str, object] | None:
128
+ """Return the session's baseline record, or None when none is readable.
129
+
130
+ Args:
131
+ session_id: The ``session_id`` field of the hook payload.
132
+ """
133
+ baseline_record = read_json_object(
134
+ baseline_record_path(session_id), BASELINE_TEXT_ENCODING
135
+ )
136
+ if baseline_record is None:
137
+ return None
138
+ if not isinstance(baseline_record.get(BASELINE_PLUGINS_KEY), dict):
139
+ return None
140
+ if not isinstance(baseline_record.get(BASELINE_WARNED_KEY), list):
141
+ return None
142
+ return baseline_record
143
+
144
+
145
+ def write_baseline_record(
146
+ session_id: object, snapshot: PluginSnapshot, all_warned_plugin_ids: list[str]
147
+ ) -> None:
148
+ """Replace the session's baseline record.
149
+
150
+ A write that fails leaves the file as it was, so the hook never fails the session.
151
+
152
+ Args:
153
+ session_id: The ``session_id`` field of the hook payload.
154
+ snapshot: The plugin snapshot the session loaded at start.
155
+ all_warned_plugin_ids: The plugin ids already named in a warning.
156
+ """
157
+ serialized_record = json.dumps(
158
+ {
159
+ BASELINE_PLUGINS_KEY: snapshot,
160
+ BASELINE_WARNED_KEY: sorted(all_warned_plugin_ids),
161
+ }
162
+ )
163
+ try:
164
+ write_text_atomically(
165
+ baseline_record_path(session_id),
166
+ serialized_record,
167
+ BASELINE_TEXT_ENCODING,
168
+ BASELINE_RECORD_TEMPORARY_PREFIX,
169
+ ATOMIC_WRITE_TEMPORARY_SUFFIX,
170
+ False,
171
+ )
172
+ except (OSError, UnicodeError):
173
+ return
174
+
175
+
176
+ def all_changed_plugin_ids(
177
+ baseline_snapshot: object, current_snapshot: PluginSnapshot
178
+ ) -> list[str]:
179
+ """Return each plugin id whose installs differ from the baseline, or that the baseline lacks.
180
+
181
+ Args:
182
+ baseline_snapshot: The ``plugins`` value of the baseline record.
183
+ current_snapshot: The live snapshot from ``current_plugin_snapshot``.
184
+ """
185
+ known_snapshot = baseline_snapshot if isinstance(baseline_snapshot, dict) else {}
186
+ return sorted(
187
+ each_plugin_id
188
+ for each_plugin_id, each_plugin_entries in current_snapshot.items()
189
+ if known_snapshot.get(each_plugin_id) != each_plugin_entries
190
+ )
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook: tell the user to restart when a plugin changed after start.
3
+
4
+ A running Claude Code session keeps the plugin files it loaded at start. After
5
+ an install or update, ``installed_plugins.json`` names a new ``version`` or
6
+ ``installPath``, or a plugin the session never loaded, and its new skills and
7
+ commands stay unavailable until a restart. The hook compares the live record
8
+ with the baseline that ``plugin_session_baseline_writer.py`` wrote at
9
+ SessionStart and names each changed plugin once per session, in a
10
+ ``systemMessage`` for the user and in ``additionalContext`` for the agent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
20
+ if _hooks_directory not in sys.path:
21
+ sys.path.insert(0, _hooks_directory)
22
+
23
+ from hooks_constants.plugin_session_baseline_constants import (
24
+ BASELINE_PLUGINS_KEY,
25
+ BASELINE_WARNED_KEY,
26
+ PLUGIN_CHANGE_WARNING_TEMPLATE,
27
+ PLUGIN_ID_SEPARATOR,
28
+ SESSION_ID_FIELD,
29
+ USER_PROMPT_SUBMIT_EVENT_NAME,
30
+ )
31
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
32
+ from plugin_session_baseline import (
33
+ all_changed_plugin_ids,
34
+ current_plugin_snapshot,
35
+ installed_plugins_path,
36
+ read_baseline_record,
37
+ write_baseline_record,
38
+ )
39
+
40
+
41
+ def plugin_change_output(all_plugin_ids: list[str]) -> dict[str, object]:
42
+ """Return the hook output that names each changed plugin and asks for a restart.
43
+
44
+ Args:
45
+ all_plugin_ids: The plugin ids to name, in order.
46
+ """
47
+ warning_text = PLUGIN_CHANGE_WARNING_TEMPLATE.format(
48
+ plugin_ids=PLUGIN_ID_SEPARATOR.join(all_plugin_ids)
49
+ )
50
+ return {
51
+ "systemMessage": warning_text,
52
+ "hookSpecificOutput": {
53
+ "hookEventName": USER_PROMPT_SUBMIT_EVENT_NAME,
54
+ "additionalContext": warning_text,
55
+ },
56
+ }
57
+
58
+
59
+ def main() -> None:
60
+ """Warn once per plugin when the live plugin record differs from the session baseline."""
61
+ hook_payload = read_hook_input_dictionary_from_stdin()
62
+ if hook_payload is None:
63
+ return
64
+ session_id = hook_payload.get(SESSION_ID_FIELD)
65
+ if not isinstance(session_id, str) or not session_id:
66
+ return
67
+ baseline_record = read_baseline_record(session_id)
68
+ if baseline_record is None:
69
+ return
70
+ baseline_snapshot = baseline_record[BASELINE_PLUGINS_KEY]
71
+ all_warned_plugin_ids = [
72
+ str(each_plugin_id) for each_plugin_id in baseline_record[BASELINE_WARNED_KEY]
73
+ ]
74
+ all_new_plugin_ids = [
75
+ each_plugin_id
76
+ for each_plugin_id in all_changed_plugin_ids(
77
+ baseline_snapshot, current_plugin_snapshot(installed_plugins_path())
78
+ )
79
+ if each_plugin_id not in all_warned_plugin_ids
80
+ ]
81
+ if not all_new_plugin_ids:
82
+ return
83
+ write_baseline_record(
84
+ session_id, baseline_snapshot, [*all_warned_plugin_ids, *all_new_plugin_ids]
85
+ )
86
+ sys.stdout.write(json.dumps(plugin_change_output(all_new_plugin_ids)) + "\n")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook: record the plugin versions this session loads at start.
3
+
4
+ On ``startup``, and on any source when the session has no baseline yet, the
5
+ hook writes each plugin's ``version`` and ``installPath`` from
6
+ ``installed_plugins.json`` to the session's baseline file. On ``resume``,
7
+ ``clear``, and ``compact`` it leaves an existing baseline alone, because the
8
+ session keeps the plugin files it loaded at its first start. The
9
+ UserPromptSubmit hook ``plugin_change_warning.py`` compares against this file.
10
+ The hook prints nothing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
19
+ if _hooks_directory not in sys.path:
20
+ sys.path.insert(0, _hooks_directory)
21
+
22
+ from hooks_constants.plugin_session_baseline_constants import (
23
+ SESSION_ID_FIELD,
24
+ SESSION_SOURCE_FIELD,
25
+ STARTUP_SOURCE,
26
+ )
27
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
28
+ from plugin_session_baseline import (
29
+ current_plugin_snapshot,
30
+ installed_plugins_path,
31
+ read_baseline_record,
32
+ write_baseline_record,
33
+ )
34
+
35
+
36
+ def main() -> None:
37
+ """Write the session's plugin baseline on startup or when none exists yet."""
38
+ hook_payload = read_hook_input_dictionary_from_stdin()
39
+ if hook_payload is None:
40
+ return
41
+ session_id = hook_payload.get(SESSION_ID_FIELD)
42
+ if not isinstance(session_id, str) or not session_id:
43
+ return
44
+ is_startup = hook_payload.get(SESSION_SOURCE_FIELD) == STARTUP_SOURCE
45
+ if not is_startup and read_baseline_record(session_id) is not None:
46
+ return
47
+ write_baseline_record(
48
+ session_id, current_plugin_snapshot(installed_plugins_path()), []
49
+ )
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,216 @@
1
+ """Tests for plugin_change_warning: the UserPromptSubmit plugin restart warning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+
13
+ _SESSION_DIR = Path(__file__).resolve().parent
14
+ _HOOKS_ROOT = _SESSION_DIR.parent
15
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
16
+ if each_sys_path_entry not in sys.path:
17
+ sys.path.insert(0, each_sys_path_entry)
18
+
19
+ import plugin_change_warning
20
+ import plugin_session_baseline
21
+ import plugin_session_baseline_writer
22
+
23
+ SESSION_ID = "session-two"
24
+ DEEPSTACK_ID = "deepstack@deepstack"
25
+ PSTACK_ID = "pstack@pstack-claude"
26
+
27
+
28
+ def _plugin_entry(version: str, install_path: str, last_updated: str) -> dict[str, str]:
29
+ return {
30
+ "scope": "user",
31
+ "installPath": install_path,
32
+ "version": version,
33
+ "installedAt": "2026-10-21T19:00:00.000Z",
34
+ "lastUpdated": last_updated,
35
+ }
36
+
37
+
38
+ def write_installed_plugins(
39
+ config_directory: Path, all_plugins: dict[str, list[dict[str, str]]]
40
+ ) -> None:
41
+ plugins_directory = config_directory / "plugins"
42
+ plugins_directory.mkdir(parents=True, exist_ok=True)
43
+ (plugins_directory / "installed_plugins.json").write_text(
44
+ json.dumps({"version": 2, "plugins": all_plugins}), encoding="utf-8"
45
+ )
46
+
47
+
48
+ def _run_main(main_function: object, payload: dict[str, str]) -> str:
49
+ payload_bytes = json.dumps(payload).encode("utf-8")
50
+ captured_stdout = io.StringIO()
51
+ with (
52
+ patch("sys.stdin", io.TextIOWrapper(io.BytesIO(payload_bytes))),
53
+ patch("sys.stdout", captured_stdout),
54
+ ):
55
+ main_function()
56
+ return captured_stdout.getvalue()
57
+
58
+
59
+ def start_session(source: str) -> None:
60
+ _run_main(
61
+ plugin_session_baseline_writer.main,
62
+ {"session_id": SESSION_ID, "hook_event_name": "SessionStart", "source": source},
63
+ )
64
+
65
+
66
+ def submit_prompt() -> dict[str, object] | None:
67
+ hook_output = _run_main(
68
+ plugin_change_warning.main,
69
+ {
70
+ "session_id": SESSION_ID,
71
+ "hook_event_name": "UserPromptSubmit",
72
+ "prompt": "run /deepstack-mode",
73
+ },
74
+ )
75
+ return json.loads(hook_output) if hook_output else None
76
+
77
+
78
+ @pytest.fixture
79
+ def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
80
+ claude_config_directory = tmp_path / "config"
81
+ state_directory = tmp_path / "state"
82
+ state_directory.mkdir()
83
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config_directory))
84
+ monkeypatch.setattr(
85
+ plugin_session_baseline.tempfile, "gettempdir", lambda: str(state_directory)
86
+ )
87
+ return claude_config_directory
88
+
89
+
90
+ def _warning_text(hook_output: dict[str, object] | None) -> str:
91
+ assert hook_output is not None
92
+ system_message = hook_output["systemMessage"]
93
+ assert isinstance(system_message, str)
94
+ hook_specific_output = hook_output["hookSpecificOutput"]
95
+ assert isinstance(hook_specific_output, dict)
96
+ assert hook_specific_output["hookEventName"] == "UserPromptSubmit"
97
+ assert hook_specific_output["additionalContext"] == system_message
98
+ return system_message
99
+
100
+
101
+ def test_a_changed_version_should_warn_once(config_directory: Path) -> None:
102
+ write_installed_plugins(
103
+ config_directory,
104
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
105
+ )
106
+ start_session("startup")
107
+ write_installed_plugins(
108
+ config_directory,
109
+ {PSTACK_ID: [_plugin_entry("1.1.0", "/cache/pstack/1.1.0", "b")]},
110
+ )
111
+ warning_text = _warning_text(submit_prompt())
112
+ assert PSTACK_ID in warning_text
113
+ assert "Restart Claude Code" in warning_text
114
+ assert submit_prompt() is None
115
+
116
+
117
+ def test_a_plugin_new_since_start_should_warn(config_directory: Path) -> None:
118
+ write_installed_plugins(
119
+ config_directory,
120
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
121
+ )
122
+ start_session("startup")
123
+ write_installed_plugins(
124
+ config_directory,
125
+ {
126
+ PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
127
+ DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "b")],
128
+ },
129
+ )
130
+ warning_text = _warning_text(submit_prompt())
131
+ assert DEEPSTACK_ID in warning_text
132
+ assert PSTACK_ID not in warning_text
133
+
134
+
135
+ def test_a_changed_install_path_should_warn(config_directory: Path) -> None:
136
+ write_installed_plugins(
137
+ config_directory,
138
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/old", "a")]},
139
+ )
140
+ start_session("startup")
141
+ write_installed_plugins(
142
+ config_directory,
143
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/new", "a")]},
144
+ )
145
+ assert PSTACK_ID in _warning_text(submit_prompt())
146
+
147
+
148
+ def test_a_bare_last_updated_refresh_should_stay_quiet(config_directory: Path) -> None:
149
+ write_installed_plugins(
150
+ config_directory,
151
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
152
+ )
153
+ start_session("startup")
154
+ write_installed_plugins(
155
+ config_directory,
156
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "later")]},
157
+ )
158
+ assert submit_prompt() is None
159
+
160
+
161
+ def test_an_install_then_compact_then_prompt_should_still_warn(
162
+ config_directory: Path,
163
+ ) -> None:
164
+ write_installed_plugins(
165
+ config_directory,
166
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
167
+ )
168
+ start_session("startup")
169
+ write_installed_plugins(
170
+ config_directory,
171
+ {
172
+ PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
173
+ DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "b")],
174
+ },
175
+ )
176
+ start_session("compact")
177
+ assert DEEPSTACK_ID in _warning_text(submit_prompt())
178
+
179
+
180
+ def test_a_second_plugin_change_should_warn_only_for_that_plugin(
181
+ config_directory: Path,
182
+ ) -> None:
183
+ write_installed_plugins(
184
+ config_directory,
185
+ {
186
+ PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
187
+ DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "a")],
188
+ },
189
+ )
190
+ start_session("startup")
191
+ write_installed_plugins(
192
+ config_directory,
193
+ {
194
+ PSTACK_ID: [_plugin_entry("1.1.0", "/cache/pstack/1.1.0", "b")],
195
+ DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "a")],
196
+ },
197
+ )
198
+ assert PSTACK_ID in _warning_text(submit_prompt())
199
+ write_installed_plugins(
200
+ config_directory,
201
+ {
202
+ PSTACK_ID: [_plugin_entry("1.2.0", "/cache/pstack/1.2.0", "c")],
203
+ DEEPSTACK_ID: [_plugin_entry("3.1.0", "/cache/deepstack/3.1.0", "c")],
204
+ },
205
+ )
206
+ second_warning_text = _warning_text(submit_prompt())
207
+ assert DEEPSTACK_ID in second_warning_text
208
+ assert PSTACK_ID not in second_warning_text
209
+
210
+
211
+ def test_a_session_without_a_baseline_should_stay_quiet(config_directory: Path) -> None:
212
+ write_installed_plugins(
213
+ config_directory,
214
+ {PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
215
+ )
216
+ assert submit_prompt() is None
@@ -0,0 +1,136 @@
1
+ """Tests for plugin_session_baseline_writer: the SessionStart plugin baseline hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+
13
+ _SESSION_DIR = Path(__file__).resolve().parent
14
+ _HOOKS_ROOT = _SESSION_DIR.parent
15
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
16
+ if each_sys_path_entry not in sys.path:
17
+ sys.path.insert(0, each_sys_path_entry)
18
+
19
+ import plugin_session_baseline
20
+ import plugin_session_baseline_writer
21
+
22
+ SESSION_ID = "session-one"
23
+ PLUGIN_ID = "deepstack@deepstack"
24
+
25
+
26
+ def _plugin_entry(version: str, install_path: str, last_updated: str) -> dict[str, str]:
27
+ return {
28
+ "scope": "user",
29
+ "installPath": install_path,
30
+ "version": version,
31
+ "installedAt": "2026-10-21T19:00:00.000Z",
32
+ "lastUpdated": last_updated,
33
+ }
34
+
35
+
36
+ def write_installed_plugins(
37
+ config_directory: Path, all_plugins: dict[str, list[dict[str, str]]]
38
+ ) -> None:
39
+ plugins_directory = config_directory / "plugins"
40
+ plugins_directory.mkdir(parents=True, exist_ok=True)
41
+ (plugins_directory / "installed_plugins.json").write_text(
42
+ json.dumps({"version": 2, "plugins": all_plugins}), encoding="utf-8"
43
+ )
44
+
45
+
46
+ def run_writer(session_id: str, source: str) -> str:
47
+ payload_text = json.dumps(
48
+ {"session_id": session_id, "hook_event_name": "SessionStart", "source": source}
49
+ )
50
+ captured_stdout = io.StringIO()
51
+ with (
52
+ patch("sys.stdin", io.TextIOWrapper(io.BytesIO(payload_text.encode("utf-8")))),
53
+ patch("sys.stdout", captured_stdout),
54
+ ):
55
+ plugin_session_baseline_writer.main()
56
+ return captured_stdout.getvalue()
57
+
58
+
59
+ @pytest.fixture
60
+ def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
61
+ claude_config_directory = tmp_path / "config"
62
+ state_directory = tmp_path / "state"
63
+ state_directory.mkdir()
64
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config_directory))
65
+ monkeypatch.setattr(
66
+ plugin_session_baseline.tempfile, "gettempdir", lambda: str(state_directory)
67
+ )
68
+ return claude_config_directory
69
+
70
+
71
+ def test_startup_should_record_each_plugin_version_and_install_path(
72
+ config_directory: Path,
73
+ ) -> None:
74
+ write_installed_plugins(
75
+ config_directory,
76
+ {PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
77
+ )
78
+ assert run_writer(SESSION_ID, "startup") == ""
79
+ baseline_record = plugin_session_baseline.read_baseline_record(SESSION_ID)
80
+ assert baseline_record == {
81
+ "plugins": {
82
+ PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/deepstack/1.0.0"}]
83
+ },
84
+ "warned": [],
85
+ }
86
+
87
+
88
+ @pytest.mark.parametrize("source", ["resume", "clear", "compact"])
89
+ def test_a_later_source_should_leave_an_existing_baseline_alone(
90
+ config_directory: Path, source: str
91
+ ) -> None:
92
+ write_installed_plugins(
93
+ config_directory,
94
+ {PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
95
+ )
96
+ run_writer(SESSION_ID, "startup")
97
+ baseline_path = plugin_session_baseline.baseline_record_path(SESSION_ID)
98
+ baseline_text = baseline_path.read_text(encoding="utf-8")
99
+ write_installed_plugins(
100
+ config_directory,
101
+ {PLUGIN_ID: [_plugin_entry("2.0.0", "/cache/deepstack/2.0.0", "b")]},
102
+ )
103
+ run_writer(SESSION_ID, source)
104
+ assert baseline_path.read_text(encoding="utf-8") == baseline_text
105
+
106
+
107
+ def test_resume_without_a_baseline_should_write_one(config_directory: Path) -> None:
108
+ write_installed_plugins(
109
+ config_directory,
110
+ {PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
111
+ )
112
+ run_writer(SESSION_ID, "resume")
113
+ baseline_record = plugin_session_baseline.read_baseline_record(SESSION_ID)
114
+ assert baseline_record is not None
115
+ assert PLUGIN_ID in baseline_record["plugins"]
116
+
117
+
118
+ def test_a_payload_without_a_session_id_should_write_nothing(
119
+ config_directory: Path, tmp_path: Path
120
+ ) -> None:
121
+ write_installed_plugins(
122
+ config_directory,
123
+ {PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
124
+ )
125
+ run_writer("", "startup")
126
+ assert list((tmp_path / "state").iterdir()) == []
127
+
128
+
129
+ def test_a_missing_installed_plugins_file_should_record_an_empty_baseline(
130
+ config_directory: Path,
131
+ ) -> None:
132
+ run_writer(SESSION_ID, "startup")
133
+ assert plugin_session_baseline.read_baseline_record(SESSION_ID) == {
134
+ "plugins": {},
135
+ "warned": [],
136
+ }
@@ -29,6 +29,8 @@ UNMEASURED_REASON_BY_SCRIPT = {
29
29
  "routing/thread_spawn_pace_hook.py": "quotes a usage reading capped by its own constant",
30
30
  "session/issue_tracker_session_starter.py": "emits only for a repository the user's "
31
31
  "project registry names by absolute path",
32
+ "session/plugin_change_warning.py": "emits only after a plugin changes on disk mid-session, "
33
+ "once per plugin, naming plugin ids",
32
34
  }
33
35
 
34
36