claude-dev-env 1.25.0 → 1.25.2

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/CLAUDE.md CHANGED
@@ -1,39 +1,26 @@
1
1
  # Claude Development Assistant
2
2
 
3
- ## Code Rules (NON-NEGOTIABLE)
3
+ ## Code Rules
4
4
  @~/.claude/docs/CODE_RULES.md
5
5
 
6
6
  ## Core Philosophy
7
7
 
8
8
  **TDD IS NON-NEGOTIABLE.** Build it right, build it simple. Maintainable > Clever.
9
9
 
10
- ## Working with Claude
11
-
12
- ### Expectations
10
+ ## Expectations for Claude
13
11
 
14
12
  1. **ALWAYS FOLLOW TDD** - No production code without failing test
15
13
  2. **MANDATORY SELF-CHECK before proposing** - See protocol below
16
14
  3. Assess refactoring after every green
17
15
 
18
- ### Mandatory Self-Check Protocol
19
-
20
16
  **BEFORE proposing plans/implementation:**
21
17
 
22
- ☐ Project rules review (e.g. Tasklings `tasklings-preferences` when in that repo path)
23
18
  ☐ "Is this KISS?" (simplest? unnecessary complexity?)
24
19
  ☐ "Over-engineering?" (multiple files? premature abstractions?)
25
20
  ☐ Test infrastructure? (ONE file, functions, YAGNI)
26
21
  ☐ Tests add value? (no existence checks, no constant tests)
27
-
28
- ## Pre-PR Submission Checklist
29
-
30
- **Run `/check-pr` OR verify:**
31
- - ☐ KISS / preferences (multiple requirements.txt? over-engineered?)
32
- - ☐ KISS (simplest? one file? functions not classes?)
33
- - ☐ Files (proper modules, correct dirs, no empty __init__.py)
34
- - ☐ Quality (no dupes, types complete, no Any/any)
35
- - ☐ Tests (no existence checks, no constant value tests)
36
- - ☐ Self-checked before proposing?
22
+ ☐ Files (proper modules, correct dirs, no empty __init__.py)
23
+ Quality (DRY, types complete, no Any/any)
37
24
 
38
25
  ## Additional Non-overlapping Rules
39
26
 
@@ -47,4 +34,4 @@ When compacting, always preserve:
47
34
  - Active task and current goal
48
35
  - Full list of modified files
49
36
  - Any failing test names or error messages
50
- - Current git branch and PR state
37
+ - Current git branch and PR state
@@ -7,6 +7,13 @@ import sys
7
7
  from pathlib import Path
8
8
 
9
9
  CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
10
+ GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
11
+ GH_REDIRECT_ACTIVE_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
12
+
13
+
14
+ def gh_redirect_is_active() -> bool:
15
+ env_var_value = os.environ.get(GH_REDIRECT_ACTIVE_ENV_VAR, "").strip().lower()
16
+ return env_var_value in GH_REDIRECT_ACTIVE_TRUTHY_VALUES
10
17
 
11
18
  # Projects where git reset --hard is explicitly allowed by the user.
12
19
  # Add your own project paths here, e.g.:
@@ -116,10 +123,11 @@ def main() -> None:
116
123
 
117
124
  command = tool_input.get("command", "")
118
125
 
119
- redirected_gh_description = find_redirected_gh_pattern(command)
120
- if redirected_gh_description is not None:
121
- print(json.dumps(_build_silent_gh_deny_response(redirected_gh_description)))
122
- sys.exit(0)
126
+ if gh_redirect_is_active():
127
+ redirected_gh_description = find_redirected_gh_pattern(command)
128
+ if redirected_gh_description is not None:
129
+ print(json.dumps(_build_silent_gh_deny_response(redirected_gh_description)))
130
+ sys.exit(0)
123
131
 
124
132
  matched_description = find_destructive_pattern(command)
125
133
 
@@ -18,6 +18,15 @@ SKIP_PATTERNS = {
18
18
  'conftest', 'fixture', 'mock', 'stub'
19
19
  }
20
20
  SKIP_EXTENSIONS = {'.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.txt'}
21
+ DOTCLAUDE_PATH_SEGMENTS = frozenset({".claude"})
22
+
23
+
24
+ def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
25
+ normalized_path = file_path_string.replace("\\", "/")
26
+ for each_segment in normalized_path.split("/"):
27
+ if each_segment and each_segment in DOTCLAUDE_PATH_SEGMENTS:
28
+ return True
29
+ return False
21
30
 
22
31
 
23
32
  def _freshness_seconds() -> int:
@@ -221,6 +230,9 @@ def main() -> None:
221
230
  if not file_path:
222
231
  sys.exit(0)
223
232
 
233
+ if _is_inside_dotclaude_segment(file_path):
234
+ sys.exit(0)
235
+
224
236
  path = Path(file_path)
225
237
  ext = path.suffix.lower()
226
238
 
@@ -1,6 +1,7 @@
1
1
  """Tests for destructive-command-blocker hook."""
2
2
 
3
3
  import json
4
+ import os
4
5
  import subprocess
5
6
  import sys
6
7
  from pathlib import Path
@@ -8,15 +9,26 @@ from pathlib import Path
8
9
 
9
10
  SCRIPT_PATH = Path(__file__).parent / "destructive-command-blocker.py"
10
11
  GH_GATE_USER_FACING_PREFIX = "[gh-gate]"
11
-
12
-
13
- def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
12
+ GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
13
+ GH_REDIRECT_ACTIVE_VALUE = "1"
14
+
15
+
16
+ def _run_hook(
17
+ payload: dict,
18
+ gh_redirect_active: bool = True,
19
+ ) -> subprocess.CompletedProcess[str]:
20
+ child_environment = os.environ.copy()
21
+ if gh_redirect_active:
22
+ child_environment[GH_REDIRECT_ACTIVE_ENV_VAR] = GH_REDIRECT_ACTIVE_VALUE
23
+ else:
24
+ child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
14
25
  return subprocess.run(
15
26
  [sys.executable, str(SCRIPT_PATH)],
16
27
  input=json.dumps(payload),
17
28
  text=True,
18
29
  capture_output=True,
19
30
  check=False,
31
+ env=child_environment,
20
32
  )
21
33
 
22
34
 
@@ -106,3 +118,50 @@ def test_ignores_non_bash_tool() -> None:
106
118
  result = _run_hook(payload)
107
119
  assert result.stdout.strip() == ""
108
120
  assert result.returncode == 0
121
+
122
+
123
+ def test_gh_issue_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
124
+ payload = _make_bash_payload('gh issue comment 83 --body "hello"')
125
+
126
+ result = _run_hook(payload, gh_redirect_active=False)
127
+
128
+ assert result.stdout.strip() == ""
129
+ assert result.returncode == 0
130
+
131
+
132
+ def test_gh_pr_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
133
+ payload = _make_bash_payload('gh pr comment 42 --body "ok"')
134
+
135
+ result = _run_hook(payload, gh_redirect_active=False)
136
+
137
+ assert result.stdout.strip() == ""
138
+ assert result.returncode == 0
139
+
140
+
141
+ def test_gh_pr_review_is_allowed_when_redirect_env_var_is_unset() -> None:
142
+ payload = _make_bash_payload("gh pr review 42 --approve")
143
+
144
+ result = _run_hook(payload, gh_redirect_active=False)
145
+
146
+ assert result.stdout.strip() == ""
147
+ assert result.returncode == 0
148
+
149
+
150
+ def test_gh_api_post_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
151
+ payload = _make_bash_payload(
152
+ "gh api /repos/owner/name/issues/1/comments -X POST -f body=hello"
153
+ )
154
+
155
+ result = _run_hook(payload, gh_redirect_active=False)
156
+
157
+ assert result.stdout.strip() == ""
158
+ assert result.returncode == 0
159
+
160
+
161
+ def test_rm_rf_still_asks_when_redirect_env_var_is_unset() -> None:
162
+ payload = _make_bash_payload("rm -rf /tmp/somewhere")
163
+
164
+ result = _run_hook(payload, gh_redirect_active=False)
165
+
166
+ response = json.loads(result.stdout)
167
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
@@ -247,3 +247,55 @@ def test_directory_skip_components_excludes_pluralized_conftest() -> None:
247
247
  actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
248
248
 
249
249
  assert "conftests" not in actual_directory_skip_components
250
+
251
+
252
+ def test_should_skip_silently_when_posix_path_has_dotclaude_segment(tmp_path: Path) -> None:
253
+ dotclaude_production_file = tmp_path / ".claude" / "agents" / "reviewer.py"
254
+
255
+ completed = _run_hook_with_payload(
256
+ _make_write_payload(dotclaude_production_file, "def review(): pass\n")
257
+ )
258
+
259
+ assert completed.returncode == 0
260
+ assert completed.stdout.strip() == ""
261
+
262
+
263
+ def test_should_skip_silently_when_windows_backslash_path_has_dotclaude_segment() -> None:
264
+ windows_style_path = "C:\\Users\\dev\\.claude\\agents\\reviewer.py"
265
+
266
+ completed = _run_hook_with_payload({
267
+ "tool_name": "Write",
268
+ "tool_input": {"file_path": windows_style_path, "content": "def review(): pass\n"},
269
+ })
270
+
271
+ assert completed.returncode == 0
272
+ assert completed.stdout.strip() == ""
273
+
274
+
275
+ def test_should_skip_silently_when_mixed_separator_path_has_dotclaude_segment() -> None:
276
+ mixed_separator_path = "C:/Users/dev\\.claude/agents\\reviewer.py"
277
+
278
+ completed = _run_hook_with_payload({
279
+ "tool_name": "Write",
280
+ "tool_input": {"file_path": mixed_separator_path, "content": "def review(): pass\n"},
281
+ })
282
+
283
+ assert completed.returncode == 0
284
+ assert completed.stdout.strip() == ""
285
+
286
+
287
+ def test_should_not_skip_when_dotclaude_is_only_a_filename_substring(tmp_path: Path) -> None:
288
+ sandbox = _sandbox(tmp_path)
289
+ substring_production_file = sandbox / "my.claude.helpers.py"
290
+ substring_production_file.write_text("def help(): pass\n")
291
+
292
+ completed = _run_hook_with_payload(_make_write_payload(substring_production_file))
293
+
294
+ assert _decision_from(completed) == "deny"
295
+
296
+
297
+ def test_is_inside_dotclaude_segment_helper_matches_only_exact_segments() -> None:
298
+ assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/home/user/.claude/agent.py") is True
299
+ assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("C:\\Users\\dev\\.claude\\agent.py") is True
300
+ assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/my.claude.helpers.py") is False
301
+ assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/app/service.py") is False
@@ -4,8 +4,8 @@ import os
4
4
  import platform
5
5
  import subprocess
6
6
 
7
- NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "claude-notifications")
8
- NTFY_BASE_URL = f"https://ntfy.sh/{NTFY_TOPIC}"
7
+ NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")
8
+ NTFY_BASE_URL = f"https://ntfy.sh/{NTFY_TOPIC}" if NTFY_TOPIC else ""
9
9
  WINDOWS_CHIMES_PATH = os.path.join(os.environ.get("SYSTEMROOT", r"C:\Windows"), "Media", "Windows Battery Critical.wav")
10
10
  LINUX_NOTIFICATION_SOUND = os.environ.get("NOTIFICATION_SOUND", "/usr/share/sounds/freedesktop/stereo/message.oga")
11
11
  LINUX_NOTIFICATION_TIMEOUT_MS = "3000"
@@ -138,6 +138,8 @@ def notify_windows(title: str, message: str) -> None:
138
138
 
139
139
 
140
140
  def notify_ntfy(title: str, message: str, priority: str = "high") -> None:
141
+ if not NTFY_TOPIC:
142
+ return
141
143
  try:
142
144
  subprocess.Popen(
143
145
  [
@@ -0,0 +1,46 @@
1
+ """Unit tests for notification_utils ntfy guard behavior."""
2
+
3
+ import importlib.util
4
+ import pathlib
5
+ import types
6
+ from unittest.mock import patch
7
+
8
+ HOOK_DIRECTORY = pathlib.Path(__file__).parent
9
+ MODULE_PATH = HOOK_DIRECTORY / "notification_utils.py"
10
+
11
+
12
+ def load_notification_utils_with_environment(
13
+ environment_overrides: dict[str, str],
14
+ ) -> types.ModuleType:
15
+ module_specification = importlib.util.spec_from_file_location(
16
+ "notification_utils_under_test",
17
+ MODULE_PATH,
18
+ )
19
+ assert module_specification is not None
20
+ assert module_specification.loader is not None
21
+ module_under_test = importlib.util.module_from_spec(module_specification)
22
+ with patch.dict("os.environ", environment_overrides, clear=False):
23
+ module_specification.loader.exec_module(module_under_test)
24
+ return module_under_test
25
+
26
+
27
+ def test_should_skip_curl_when_topic_environment_variable_is_unset() -> None:
28
+ environment_with_topic_removed = {"NTFY_TOPIC": ""}
29
+ module_under_test = load_notification_utils_with_environment(
30
+ environment_with_topic_removed
31
+ )
32
+ with patch("subprocess.Popen") as popen_spy:
33
+ module_under_test.notify_ntfy(title="Test", message="payload")
34
+ assert popen_spy.call_count == 0
35
+
36
+
37
+ def test_should_invoke_curl_when_topic_environment_variable_is_set() -> None:
38
+ environment_with_topic_set = {"NTFY_TOPIC": "private-topic-for-test"}
39
+ module_under_test = load_notification_utils_with_environment(
40
+ environment_with_topic_set
41
+ )
42
+ with patch("subprocess.Popen") as popen_spy:
43
+ module_under_test.notify_ntfy(title="Test", message="payload")
44
+ assert popen_spy.call_count == 1
45
+ curl_arguments = popen_spy.call_args.args[0]
46
+ assert "https://ntfy.sh/private-topic-for-test" in curl_arguments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.25.0",
3
+ "version": "1.25.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {