claude-dev-env 8.35.1 → 8.35.3

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.mjs CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  } from './resolve-package-managed-directory.mjs';
77
77
  import {
78
78
  ensureDirectoryPointer,
79
+ POINTER_ACTION_CREATED,
79
80
  } from './publish-directory-pointer.mjs';
80
81
  import {
81
82
  parseInstallTargetSelectionFromArgv,
@@ -872,7 +873,11 @@ function owningManagedRoot(installedFilePath) {
872
873
  * so those two paths are pointers. A real directory from an older install at a
873
874
  * lookup path is merged into the agents home before the pointer is written.
874
875
  *
875
- * @returns {string[]} The lookup paths this run published.
876
+ * A pointer that already aimed at its target is left out of the result, so a
877
+ * rollback that removes this run's written paths keeps the pointer a prior
878
+ * install published.
879
+ *
880
+ * @returns {string[]} The lookup paths this run created.
876
881
  */
877
882
  function publishManagedLookupPointers() {
878
883
  const pointerPairs = [
@@ -897,10 +902,10 @@ function publishManagedLookupPointers() {
897
902
  INSTALL_ROOT_RESOLUTION.hooksInstallDirectory,
898
903
  ],
899
904
  ];
900
- for (const [pointerPath, targetPath] of pointerPairs) {
901
- ensureDirectoryPointer(pointerPath, targetPath);
902
- }
903
- return pointerPairs.map(([pointerPath]) => pointerPath);
905
+ return pointerPairs
906
+ .map(([pointerPath, targetPath]) => ensureDirectoryPointer(pointerPath, targetPath))
907
+ .filter((pointerResult) => pointerResult.action === POINTER_ACTION_CREATED)
908
+ .map((pointerResult) => pointerResult.pointerPath);
904
909
  }
905
910
 
906
911
  /**
@@ -6,9 +6,11 @@ import { test } from 'node:test';
6
6
  import { strict as assert } from 'node:assert';
7
7
  import {
8
8
  existsSync,
9
+ lstatSync,
9
10
  mkdtempSync,
10
11
  mkdirSync,
11
12
  readFileSync,
13
+ realpathSync,
12
14
  rmSync,
13
15
  writeFileSync,
14
16
  } from 'node:fs';
@@ -501,3 +503,44 @@ test('successful install after prior install leaves one manifest and no fault',
501
503
  rmSync(homeDirectory, { recursive: true, force: true });
502
504
  }
503
505
  });
506
+
507
+ test('installer fault keeps the lookup pointers a prior install published', () => {
508
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-pointers-'));
509
+ try {
510
+ writeFileSync(join(homeDirectory, '.gitconfig'), '');
511
+ const homeOnlyEnvironment = { CLAUDE_CONFIG_DIR: '' };
512
+ const okRun = runInstaller(homeDirectory, ['--only', 'core'], {
513
+ environment: homeOnlyEnvironment,
514
+ });
515
+ assert.equal(okRun.status, 0, okRun.stdout + okRun.stderr);
516
+ const allPointerTargets = [
517
+ ...['skills', 'agents', 'hooks', 'scripts'].map((name) => ({
518
+ pointerPath: join(homeDirectory, '.claude', name),
519
+ targetPath: realpathSync(join(homeDirectory, '.agents', name)),
520
+ })),
521
+ {
522
+ pointerPath: join(homeDirectory, '.codex', 'hooks'),
523
+ targetPath: realpathSync(join(homeDirectory, '.agents', 'hooks')),
524
+ },
525
+ ];
526
+
527
+ for (const faultPhase of [FAULT_PHASES.AFTER_FILE_STAGING, FAULT_PHASES.AFTER_MANIFEST_WRITE]) {
528
+ const failedRun = runInstaller(homeDirectory, ['--only', 'core'], {
529
+ faultPhase,
530
+ environment: homeOnlyEnvironment,
531
+ });
532
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
533
+ assert.match(`${failedRun.stdout}${failedRun.stderr}`, /prior installation restored/);
534
+ for (const { pointerPath, targetPath } of allPointerTargets) {
535
+ assert.equal(
536
+ lstatSync(pointerPath, { throwIfNoEntry: false })?.isSymbolicLink(),
537
+ true,
538
+ `${pointerPath} survives a fault at ${faultPhase}`,
539
+ );
540
+ assert.equal(realpathSync(pointerPath), targetPath);
541
+ }
542
+ }
543
+ } finally {
544
+ rmSync(homeDirectory, { recursive: true, force: true });
545
+ }
546
+ });
@@ -13,7 +13,6 @@ Project root is discovered via CLAUDE_PROJECT_ROOT env var or git rev-parse.
13
13
  """
14
14
 
15
15
  import hashlib
16
- import importlib
17
16
  import json
18
17
  import os
19
18
  import platform
@@ -21,13 +20,9 @@ import stat
21
20
  import subprocess
22
21
  import sys
23
22
  from pathlib import Path
24
- from types import ModuleType
25
23
 
26
24
  _hooks_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
25
 
28
- _notification_utils_directory = os.path.join(_hooks_directory, "notification")
29
- sys.path.insert(0, _notification_utils_directory)
30
-
31
26
  _validators_directory = os.path.join(_hooks_directory, "validators")
32
27
  if _validators_directory not in sys.path:
33
28
  sys.path.insert(0, _validators_directory)
@@ -59,14 +54,6 @@ from json_file_reader import read_json_object # noqa: E402
59
54
 
60
55
  from hooks_constants.subprocess_window import hidden_window_creation_flags
61
56
 
62
-
63
- def load_notification_utils() -> ModuleType | None:
64
- try:
65
- return importlib.import_module("notification_utils")
66
- except ImportError:
67
- return None
68
-
69
-
70
57
  IS_WINDOWS = platform.system() == "Windows"
71
58
 
72
59
  GIT_COMMAND_TIMEOUT_SECONDS = 5
@@ -530,25 +517,6 @@ def format_error_summary(all_error_lines: list[str]) -> str:
530
517
  return error_summary
531
518
 
532
519
 
533
- def send_block_notification(error_summary: str) -> None:
534
- notification_module = load_notification_utils()
535
- if notification_module is None:
536
- return
537
-
538
- notification_title = "Mypy Type Errors"
539
- notification_body = f"Write blocked: {error_summary[:200]}"
540
-
541
- try:
542
- if notification_module.is_wsl():
543
- notification_module.notify_wsl(notification_title, notification_body)
544
- elif platform.system() == "Linux":
545
- notification_module.notify_linux()
546
- elif platform.system() == "Windows":
547
- notification_module.notify_windows(notification_title, notification_body)
548
- except (AttributeError, OSError):
549
- pass
550
-
551
-
552
520
  def build_block_response(error_summary: str) -> dict[str, str | dict[str, str]]:
553
521
  return {
554
522
  "decision": "block",
@@ -615,7 +583,6 @@ def main() -> None:
615
583
  sys.exit(0)
616
584
 
617
585
  error_summary = format_error_summary(all_error_lines)
618
- send_block_notification(error_summary)
619
586
  block_response = build_block_response(error_summary)
620
587
  log_hook_block(
621
588
  calling_hook_name="mypy_validator.py",
@@ -0,0 +1,77 @@
1
+ """The mypy_validator hook reports a block through its reply and nothing else.
2
+
3
+ An install from before the package dropped ``hooks/notification`` keeps
4
+ ``notification_utils.py`` on disk. This test puts a recording stand-in for
5
+ that module on the import path and checks a blocked write never reads it.
6
+ """
7
+
8
+ import importlib.util
9
+ import io
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+ import pytest
16
+
17
+ HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
18
+ TYPE_ERROR_MODULE_SOURCE = 'value: int = "not an integer"\n'
19
+
20
+
21
+ def _load_validator() -> ModuleType:
22
+ spec = importlib.util.spec_from_file_location("mypy_validator_toast_under_test", HOOK_PATH)
23
+ assert spec is not None and spec.loader is not None
24
+ module = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ def _build_recording_notification_module(all_touched_names: list[str]) -> ModuleType:
30
+ class RecordingNotificationModule(ModuleType):
31
+ def __getattr__(self, attribute_name: str) -> object:
32
+ if not attribute_name.startswith("__"):
33
+ all_touched_names.append(attribute_name)
34
+ return lambda *_arguments, **_options: False
35
+
36
+ return RecordingNotificationModule("notification_utils")
37
+
38
+
39
+ def test_type_error_block_opens_no_desktop_toast(
40
+ tmp_path_factory: pytest.TempPathFactory,
41
+ monkeypatch: pytest.MonkeyPatch,
42
+ capsys: pytest.CaptureFixture[str],
43
+ ) -> None:
44
+ """A blocked write reports through the hook reply and leaves the desktop alone.
45
+
46
+ ::
47
+
48
+ mypy typed_module.py -> error: Incompatible types
49
+ ok: {"decision": "block"} on stdout, notification_utils untouched
50
+ flag: notify_windows() opens a toast window at the bottom of the screen
51
+ """
52
+ validator = _load_validator()
53
+ project_root = tmp_path_factory.mktemp("blocked_project")
54
+ target_file = project_root / "typed_module.py"
55
+ target_file.write_text(TYPE_ERROR_MODULE_SOURCE, encoding="utf-8")
56
+ all_touched_names: list[str] = []
57
+ monkeypatch.setitem(
58
+ sys.modules,
59
+ "notification_utils",
60
+ _build_recording_notification_module(all_touched_names),
61
+ )
62
+ monkeypatch.setattr(validator, "discover_project_root", lambda _target: project_root)
63
+ monkeypatch.setattr(
64
+ validator,
65
+ "run_mypy",
66
+ lambda _target, _root: (1, f"{target_file}:1: error: Incompatible types\n"),
67
+ )
68
+ monkeypatch.setattr(validator, "log_hook_block", lambda **_details: None)
69
+ monkeypatch.setattr(
70
+ sys, "stdin", io.StringIO(json.dumps({"tool_input": {"file_path": str(target_file)}}))
71
+ )
72
+
73
+ with pytest.raises(SystemExit):
74
+ validator.main()
75
+
76
+ assert json.loads(capsys.readouterr().out)["decision"] == "block"
77
+ assert all_touched_names == []
@@ -4,15 +4,12 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import importlib
8
7
  import json
9
8
  import os
10
- import platform
11
9
  import subprocess
12
10
  import sys
13
11
  from collections.abc import Mapping
14
12
  from pathlib import Path
15
- from types import ModuleType
16
13
 
17
14
  _subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
18
15
  if _subprocess_window_hooks_directory not in sys.path:
@@ -20,11 +17,6 @@ if _subprocess_window_hooks_directory not in sys.path:
20
17
 
21
18
  from hooks_constants.subprocess_window import hidden_window_creation_flags
22
19
 
23
- NOTIFICATION_UTILS_DIRECTORY = os.path.join(
24
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
25
- )
26
- sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
27
-
28
20
  PYTHON_EXTENSIONS = frozenset({".py"})
29
21
  JS_EXTENSIONS = frozenset({".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"})
30
22
  JSON_EXTENSIONS = frozenset({".json"})
@@ -57,32 +49,6 @@ PRETTIER_CONFIG_NAMES = frozenset(
57
49
  )
58
50
 
59
51
 
60
- def load_notification_utils() -> ModuleType | None:
61
- try:
62
- return importlib.import_module("notification_utils")
63
- except ImportError:
64
- return None
65
-
66
-
67
- def send_format_notification(file_path: str, formatter_name: str) -> None:
68
- notification_module = load_notification_utils()
69
- if notification_module is None:
70
- return
71
-
72
- notification_title = "Auto-Formatter"
73
- notification_body = f"{formatter_name} formatted: {Path(file_path).name}"
74
-
75
- try:
76
- if notification_module.is_wsl():
77
- notification_module.notify_wsl(notification_title, notification_body)
78
- elif platform.system() == "Linux":
79
- notification_module.notify_linux()
80
- elif platform.system() == "Windows":
81
- notification_module.notify_windows(notification_title, notification_body)
82
- except (AttributeError, OSError):
83
- pass
84
-
85
-
86
52
  def has_prettier_config(file_path: str) -> bool:
87
53
  each_ancestor = Path(file_path).resolve().parent
88
54
  while True:
@@ -249,16 +215,9 @@ def _run_python_format_commands(file_path: str) -> None:
249
215
  if did_timeout:
250
216
  return
251
217
  if completed_process is not None and completed_process.returncode == 0:
252
- send_format_notification(file_path, formatter_name_for_command(each_command))
253
218
  return
254
219
 
255
220
 
256
- def formatter_name_for_command(command: list[str]) -> str:
257
- if command[0] == sys.executable:
258
- return command[2]
259
- return command[0]
260
-
261
-
262
221
  def _run_prettier(file_path: str) -> None:
263
222
  prettier_command = [
264
223
  NPX_EXECUTABLE,
@@ -267,11 +226,7 @@ def _run_prettier(file_path: str) -> None:
267
226
  "--write",
268
227
  os.path.realpath(file_path),
269
228
  ]
270
- completed_process, _did_timeout = _run_command(
271
- prettier_command, file_path, JS_FORMAT_TIMEOUT_SECONDS
272
- )
273
- if completed_process is not None and completed_process.returncode == 0:
274
- send_format_notification(file_path, PRETTIER_FORMATTER_NAME)
229
+ _run_command(prettier_command, file_path, JS_FORMAT_TIMEOUT_SECONDS)
275
230
 
276
231
 
277
232
  def run_eligible_formatter(file_path: str) -> None:
@@ -0,0 +1,67 @@
1
+ """The auto_formatter hook leaves the desktop alone after a format.
2
+
3
+ An install from before the package dropped ``hooks/notification`` keeps
4
+ ``notification_utils.py`` on disk. These tests put a recording stand-in for
5
+ that module on the import path and check the hook never reads it.
6
+ """
7
+
8
+ import importlib.util
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+
14
+ import pytest
15
+
16
+ HOOK_SCRIPT_PATH = Path(__file__).resolve().parent / "auto_formatter.py"
17
+
18
+
19
+ def _load_auto_formatter_module() -> ModuleType:
20
+ module_spec = importlib.util.spec_from_file_location(
21
+ "auto_formatter_toast_under_test", HOOK_SCRIPT_PATH
22
+ )
23
+ assert module_spec is not None and module_spec.loader is not None
24
+ auto_formatter_module = importlib.util.module_from_spec(module_spec)
25
+ module_spec.loader.exec_module(auto_formatter_module)
26
+ return auto_formatter_module
27
+
28
+
29
+ def _build_recording_notification_module(all_touched_names: list[str]) -> ModuleType:
30
+ class RecordingNotificationModule(ModuleType):
31
+ def __getattr__(self, attribute_name: str) -> object:
32
+ if not attribute_name.startswith("__"):
33
+ all_touched_names.append(attribute_name)
34
+ return lambda *_arguments, **_options: False
35
+
36
+ return RecordingNotificationModule("notification_utils")
37
+
38
+
39
+ def test_successful_format_opens_no_desktop_toast(monkeypatch: pytest.MonkeyPatch) -> None:
40
+ """A stale notification_utils on the import path stays untouched after a format.
41
+
42
+ ::
43
+
44
+ ~/.claude/hooks/notification/notification_utils.py left behind by an old install
45
+ ruff format module.py -> exit 0
46
+ ok: nothing reads notification_utils
47
+ flag: notify_windows() opens a toast window at the bottom of the screen
48
+ """
49
+ auto_formatter_module = _load_auto_formatter_module()
50
+ all_touched_names: list[str] = []
51
+ monkeypatch.setitem(
52
+ sys.modules,
53
+ "notification_utils",
54
+ _build_recording_notification_module(all_touched_names),
55
+ )
56
+
57
+ def report_formatter_success(
58
+ command: list[str], _file_path: str, _timeout_seconds: int
59
+ ) -> tuple[subprocess.CompletedProcess[str], bool]:
60
+ return subprocess.CompletedProcess(command, 0, "", ""), False
61
+
62
+ monkeypatch.setattr(auto_formatter_module, "_run_command", report_formatter_success)
63
+
64
+ auto_formatter_module.run_eligible_formatter("formatted_module.py")
65
+ auto_formatter_module._run_prettier("formatted_module.js")
66
+
67
+ assert all_touched_names == []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.35.1",
3
+ "version": "8.35.3",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {