claude-dev-env 8.96.8 → 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.
- package/bin/install-pstack-plugin.mjs +31 -4
- package/bin/install-pstack-plugin.test.mjs +23 -0
- package/bin/install.prompt-context.test.mjs +2 -1
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
- package/hooks/plugin_session_baseline.py +190 -0
- package/hooks/session/plugin_change_warning.py +90 -0
- package/hooks/session/plugin_session_baseline_writer.py +53 -0
- package/hooks/session/test_plugin_change_warning.py +216 -0
- package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
- package/hooks/test_context_budget_hooks.py +2 -0
- package/hooks/test_plugin_session_baseline.py +98 -0
- package/package.json +1 -1
|
@@ -318,6 +318,23 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
318
318
|
return { host, executable, status: 'installed', warning: null };
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Build the notice that tells the user to restart each session the install reached.
|
|
323
|
+
*
|
|
324
|
+
* A running Claude Code or Codex session keeps the plugin files it loaded at
|
|
325
|
+
* start, so a plugin installed or updated under it stays unavailable there
|
|
326
|
+
* until the session restarts.
|
|
327
|
+
*
|
|
328
|
+
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins the install wrote.
|
|
329
|
+
* @param {ReadonlyArray<string>} allInstalledHosts The hosts the install reached.
|
|
330
|
+
* @returns {string} The restart notice.
|
|
331
|
+
*/
|
|
332
|
+
export function restartNotice(allSpecs, allInstalledHosts) {
|
|
333
|
+
const pluginLabels = allSpecs.map(eachSpec => eachSpec.label).join(', ');
|
|
334
|
+
return `Restart each open ${allInstalledHosts.join(' and ')} session to load ${pluginLabels}. `
|
|
335
|
+
+ 'A running session keeps the plugin version it loaded at start.';
|
|
336
|
+
}
|
|
337
|
+
|
|
321
338
|
/**
|
|
322
339
|
* Install a set of plugins from one marketplace into each host's own home.
|
|
323
340
|
*
|
|
@@ -334,7 +351,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
334
351
|
* `cmd.exe`'s command-not-found exit code on Windows, and both read as
|
|
335
352
|
* skipped. When a host cannot replace a plugin folder because something holds
|
|
336
353
|
* it, the warning names each process that holds it, so the user knows what to
|
|
337
|
-
* close before running the install again.
|
|
354
|
+
* close before running the install again. When any host installs, the step
|
|
355
|
+
* prints one restart notice, because an open session keeps the plugin
|
|
356
|
+
* version it loaded at start.
|
|
338
357
|
*
|
|
339
358
|
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
340
359
|
* @param {object} [options] Install targets.
|
|
@@ -342,8 +361,8 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
342
361
|
* @param {string} [options.codexHome] The Codex home to install into.
|
|
343
362
|
* @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
|
|
344
363
|
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
345
|
-
* @param {object} [dependencies] Seams for the command runner
|
|
346
|
-
* @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
|
|
364
|
+
* @param {object} [dependencies] Seams for the command runner, the folder locker search, and the line writer.
|
|
365
|
+
* @returns {{status: string, hosts: object[], warning: string|null, restartNotice: string|null}} The outcome per host.
|
|
347
366
|
*/
|
|
348
367
|
export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
|
|
349
368
|
const environment = options.environment ?? process.env;
|
|
@@ -365,7 +384,15 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
|
|
|
365
384
|
const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
|
|
366
385
|
const status = failedCount > 0 ? 'failed' : (installedCount > 0 ? 'installed' : 'skipped');
|
|
367
386
|
const warnings = hostOutcomes.map(outcome => outcome.warning).filter(Boolean);
|
|
368
|
-
|
|
387
|
+
const allInstalledHosts = hostOutcomes
|
|
388
|
+
.filter(outcome => outcome.status === 'installed')
|
|
389
|
+
.map(outcome => outcome.host);
|
|
390
|
+
const notice = allInstalledHosts.length > 0 ? restartNotice(allSpecs, allInstalledHosts) : null;
|
|
391
|
+
if (notice) {
|
|
392
|
+
const writeLine = dependencies.writeLine ?? (line => console.log(line));
|
|
393
|
+
writeLine(notice);
|
|
394
|
+
}
|
|
395
|
+
return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null, restartNotice: notice };
|
|
369
396
|
}
|
|
370
397
|
|
|
371
398
|
/**
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeOrphanedPluginVersions,
|
|
18
18
|
removeStaleMarketplaceClone,
|
|
19
19
|
removeRetiredMarketplacePlugins,
|
|
20
|
+
restartNotice,
|
|
20
21
|
shouldInstallMarketplacePlugin,
|
|
21
22
|
} from './install-pstack-plugin.mjs';
|
|
22
23
|
|
|
@@ -500,3 +501,25 @@ test('should delete a half-made marketplace clone before the shared marketplace
|
|
|
500
501
|
test('should report no removal when no stale marketplace clone exists', () => {
|
|
501
502
|
assert.equal(removeStaleMarketplaceClone(join(tmpdir(), 'cde-no-such-clone')), false);
|
|
502
503
|
});
|
|
504
|
+
|
|
505
|
+
test('an install prints one restart notice that names each plugin and host', () => {
|
|
506
|
+
const allLoggedLines = [];
|
|
507
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
508
|
+
...recordingRunner(),
|
|
509
|
+
writeLine: line => allLoggedLines.push(line),
|
|
510
|
+
});
|
|
511
|
+
assert.deepEqual(allLoggedLines, [restartNotice([PSTACK_PLUGIN_SPEC], ['claude', 'codex'])]);
|
|
512
|
+
assert.equal(outcome.restartNotice, allLoggedLines[0]);
|
|
513
|
+
assert.match(allLoggedLines[0], /Restart each open claude and codex session to load Pstack\./);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('a run that installs nothing prints no restart notice', () => {
|
|
517
|
+
const allLoggedLines = [];
|
|
518
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
519
|
+
...recordingRunner(() => ({ status: null, error: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) })),
|
|
520
|
+
writeLine: line => allLoggedLines.push(line),
|
|
521
|
+
});
|
|
522
|
+
assert.equal(outcome.status, 'skipped');
|
|
523
|
+
assert.deepEqual(allLoggedLines, []);
|
|
524
|
+
assert.equal(outcome.restartNotice, null);
|
|
525
|
+
});
|
|
@@ -35,11 +35,12 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
|
|
|
35
35
|
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
|
|
36
36
|
|
|
37
37
|
const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
|
|
38
|
-
assert.equal(userPromptHooks.length,
|
|
38
|
+
assert.equal(userPromptHooks.length, 5);
|
|
39
39
|
assert.equal(userPromptHooks[0].command, userCommand);
|
|
40
40
|
assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
|
|
41
41
|
assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
|
|
42
42
|
assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
|
|
43
|
+
assert.match(userPromptHooks[4].command, /session\/plugin_change_warning\.py$/);
|
|
43
44
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
44
45
|
|
|
45
46
|
const firstMerge = JSON.stringify(settings);
|
|
@@ -10,6 +10,8 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
10
10
|
- `session/working_style_prompt.py` injects the session's working style at session start.
|
|
11
11
|
- `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
|
|
12
12
|
- `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
|
|
13
|
+
- `session/plugin_session_baseline_writer.py` records each plugin's `version` and `installPath` from `installed_plugins.json` to a per-session file in the system temp folder. It writes on `startup`, or when the session has no baseline yet, and leaves an existing baseline alone on `resume`, `clear`, and `compact`.
|
|
14
|
+
- `session/plugin_change_warning.py` compares `installed_plugins.json` with that baseline before each prompt. It names each plugin whose version or install path changed, or that the baseline lacks, once per session, and tells the user to restart Claude Code.
|
|
13
15
|
- `session/artifact_template_pointer.py` routes an agent that starts an artifact page by scope. A status or progress report prefers the status flowchart from `docs/templates/artifact-page/status-flowchart.html`, unless the person asks for another shape. One flow or one comparison is an inline SVG flowchart with minimal text. A multi-part plan or evidence is an html-plan page from `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
|
|
14
16
|
- `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
|
|
15
17
|
|
|
@@ -18,6 +20,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
18
20
|
- `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
|
|
19
21
|
- `session/compaction_recall_prompt.py` runs on `SessionStart` matcher `compact` at `10` seconds in `hooks.json`. The installer also writes it to the Codex `hooks.json`, the only `SessionStart` hook Codex gets.
|
|
20
22
|
- `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
23
|
+
- `session/plugin_session_baseline_writer.py` runs on `SessionStart`, matcher empty, at `10` seconds, so it sees every source. `session/plugin_change_warning.py` runs on `UserPromptSubmit`, matcher empty, at `10` seconds in `hooks.json`.
|
|
21
24
|
- `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
|
|
22
25
|
- `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
|
|
23
26
|
|
|
@@ -33,6 +36,7 @@ Preconditions:
|
|
|
33
36
|
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
34
37
|
- **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
|
|
35
38
|
- **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
|
|
39
|
+
- **Plugin restart warning.** Input is a SessionStart event, an `installed_plugins.json` change, and a prompt. Run `python -m pytest packages/claude-dev-env/hooks/session/test_plugin_session_baseline_writer.py packages/claude-dev-env/hooks/session/test_plugin_change_warning.py packages/claude-dev-env/hooks/test_plugin_session_baseline.py -q`. The tests observe one warning for a changed version, a changed install path, and a plugin new since start, silence on a `lastUpdated` refresh and on a second prompt, and a warning that still fires after a `compact` SessionStart.
|
|
36
40
|
- **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
|
|
37
41
|
- **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
|
|
38
42
|
|
package/hooks/hooks.json
CHANGED
|
@@ -313,6 +313,11 @@
|
|
|
313
313
|
"type": "command",
|
|
314
314
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
|
|
315
315
|
"timeout": 10
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"type": "command",
|
|
319
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_change_warning.py",
|
|
320
|
+
"timeout": 10
|
|
316
321
|
}
|
|
317
322
|
]
|
|
318
323
|
}
|
|
@@ -356,6 +361,11 @@
|
|
|
356
361
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
|
|
357
362
|
"timeout": 10
|
|
358
363
|
},
|
|
364
|
+
{
|
|
365
|
+
"type": "command",
|
|
366
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_session_baseline_writer.py",
|
|
367
|
+
"timeout": 10
|
|
368
|
+
},
|
|
359
369
|
{
|
|
360
370
|
"type": "command",
|
|
361
371
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Constants for the plugin session baseline and the plugin change warning hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BASELINE_PLUGINS_KEY",
|
|
7
|
+
"BASELINE_RECORD_FILE_TEMPLATE",
|
|
8
|
+
"BASELINE_RECORD_TEMPORARY_PREFIX",
|
|
9
|
+
"BASELINE_TEXT_ENCODING",
|
|
10
|
+
"BASELINE_WARNED_KEY",
|
|
11
|
+
"CLAUDE_CONFIG_DIR_ENV_VAR",
|
|
12
|
+
"DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
|
|
13
|
+
"INSTALLED_PLUGINS_FILE_NAME",
|
|
14
|
+
"INSTALLED_PLUGINS_RECORD_KEY",
|
|
15
|
+
"INSTALL_PATH_FIELD",
|
|
16
|
+
"PLUGINS_DIRECTORY_NAME",
|
|
17
|
+
"PLUGIN_CHANGE_WARNING_TEMPLATE",
|
|
18
|
+
"PLUGIN_ID_SEPARATOR",
|
|
19
|
+
"SESSION_ID_FIELD",
|
|
20
|
+
"SESSION_SOURCE_FIELD",
|
|
21
|
+
"STARTUP_SOURCE",
|
|
22
|
+
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
23
|
+
"VERSION_FIELD",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
|
|
27
|
+
DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude"
|
|
28
|
+
PLUGINS_DIRECTORY_NAME = "plugins"
|
|
29
|
+
INSTALLED_PLUGINS_FILE_NAME = "installed_plugins.json"
|
|
30
|
+
INSTALLED_PLUGINS_RECORD_KEY = "plugins"
|
|
31
|
+
VERSION_FIELD = "version"
|
|
32
|
+
INSTALL_PATH_FIELD = "installPath"
|
|
33
|
+
|
|
34
|
+
SESSION_ID_FIELD = "session_id"
|
|
35
|
+
SESSION_SOURCE_FIELD = "source"
|
|
36
|
+
STARTUP_SOURCE = "startup"
|
|
37
|
+
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
38
|
+
|
|
39
|
+
BASELINE_RECORD_FILE_TEMPLATE = "plugin-session-baseline-{session_file_id}.json"
|
|
40
|
+
BASELINE_RECORD_TEMPORARY_PREFIX = "plugin-session-baseline-"
|
|
41
|
+
BASELINE_TEXT_ENCODING = "utf-8"
|
|
42
|
+
BASELINE_PLUGINS_KEY = "plugins"
|
|
43
|
+
BASELINE_WARNED_KEY = "warned"
|
|
44
|
+
|
|
45
|
+
PLUGIN_ID_SEPARATOR = ", "
|
|
46
|
+
PLUGIN_CHANGE_WARNING_TEMPLATE = (
|
|
47
|
+
"Plugin files changed after this session started: {plugin_ids}."
|
|
48
|
+
" This session keeps the plugin versions it loaded at start, so their new skills,"
|
|
49
|
+
" commands, and hooks stay unavailable here. Restart Claude Code to load them."
|
|
50
|
+
)
|
|
@@ -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
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Behavior tests for the plugin session baseline record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import plugin_session_baseline
|
|
11
|
+
|
|
12
|
+
PLUGIN_ID = "pstack@pstack-claude"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
17
|
+
monkeypatch.setattr(
|
|
18
|
+
plugin_session_baseline.tempfile, "gettempdir", lambda: str(tmp_path)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_installed_plugins_path_should_follow_the_config_directory(
|
|
23
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
24
|
+
) -> None:
|
|
25
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
|
|
26
|
+
assert plugin_session_baseline.installed_plugins_path() == (
|
|
27
|
+
tmp_path / "plugins" / "installed_plugins.json"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_baseline_record_path_should_name_the_session_in_the_temp_folder(
|
|
32
|
+
tmp_path: Path,
|
|
33
|
+
) -> None:
|
|
34
|
+
assert plugin_session_baseline.baseline_record_path("a/b c") == (
|
|
35
|
+
tmp_path / "plugin-session-baseline-abc.json"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_current_plugin_snapshot_should_keep_only_version_and_install_path(
|
|
40
|
+
tmp_path: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
record_path = tmp_path / "installed_plugins.json"
|
|
43
|
+
record_path.write_text(
|
|
44
|
+
json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"plugins": {
|
|
47
|
+
PLUGIN_ID: [
|
|
48
|
+
{
|
|
49
|
+
"scope": "user",
|
|
50
|
+
"installPath": "/cache/pstack/1.0.0",
|
|
51
|
+
"version": "1.0.0",
|
|
52
|
+
"lastUpdated": "2026-10-21T21:26:12.000Z",
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
),
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
assert plugin_session_baseline.current_plugin_snapshot(record_path) == {
|
|
61
|
+
PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/pstack/1.0.0"}]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_current_plugin_snapshot_should_read_a_malformed_record_as_empty(
|
|
66
|
+
tmp_path: Path,
|
|
67
|
+
) -> None:
|
|
68
|
+
record_path = tmp_path / "installed_plugins.json"
|
|
69
|
+
record_path.write_text("{not json", encoding="utf-8")
|
|
70
|
+
assert plugin_session_baseline.current_plugin_snapshot(record_path) == {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_read_baseline_record_should_return_what_the_writer_stored() -> None:
|
|
74
|
+
snapshot = {PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/pstack/1.0.0"}]}
|
|
75
|
+
plugin_session_baseline.write_baseline_record("session", snapshot, [PLUGIN_ID])
|
|
76
|
+
assert plugin_session_baseline.read_baseline_record("session") == {
|
|
77
|
+
"plugins": snapshot,
|
|
78
|
+
"warned": [PLUGIN_ID],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_all_changed_plugin_ids_should_name_changed_and_new_plugins_only() -> None:
|
|
83
|
+
unchanged_entries = [{"version": "1.0.0", "installPath": "/cache/a/1.0.0"}]
|
|
84
|
+
baseline_snapshot = {
|
|
85
|
+
"a@m": unchanged_entries,
|
|
86
|
+
"b@m": [{"version": "1.0.0", "installPath": "/cache/b/1.0.0"}],
|
|
87
|
+
}
|
|
88
|
+
current_snapshot = {
|
|
89
|
+
"a@m": unchanged_entries,
|
|
90
|
+
"b@m": [{"version": "2.0.0", "installPath": "/cache/b/2.0.0"}],
|
|
91
|
+
"c@m": [{"version": "1.0.0", "installPath": "/cache/c/1.0.0"}],
|
|
92
|
+
}
|
|
93
|
+
assert plugin_session_baseline.all_changed_plugin_ids(
|
|
94
|
+
baseline_snapshot, current_snapshot
|
|
95
|
+
) == [
|
|
96
|
+
"b@m",
|
|
97
|
+
"c@m",
|
|
98
|
+
]
|