add two new hooks and widen the rg hook

This commit is contained in:
Josh Thomas 2025-07-04 10:03:22 -05:00
parent 746b53e88f
commit c0f95e339a
7 changed files with 357 additions and 3 deletions

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python
from __future__ import annotations
import json
import re
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from enum import Enum
class Shell(str, Enum):
SH = "sh"
BASH = "bash"
ZSH = "zsh"
KSH = "ksh"
FISH = "fish"
COMMAND = "command"
@property
def as_ext(self) -> str:
return f".{self.value}"
@property
def shell_name(self) -> str:
if self == Shell.COMMAND:
return "bash"
return self.value
@dataclass
class ShellInfo:
file_path: str
filename: str
expected_shell: str | None
@classmethod
def from_path(cls, file_path: str) -> ShellInfo:
"""Create ShellInfo from a file path."""
filename = file_path.split("/")[-1] if "/" in file_path else file_path
# Determine expected shell based on extension
expected_shell = None
for shell in Shell:
if filename.endswith(shell.as_ext):
expected_shell = shell.shell_name
break
return cls(
file_path=file_path, filename=filename, expected_shell=expected_shell
)
def is_likely_shell_script(file_path: str) -> bool:
for shell in Shell:
if file_path.endswith(shell.as_ext):
return True
filename = file_path.split("/")[-1] if "/" in file_path else file_path
if "." not in filename or filename.startswith("."):
return True
return False
def check_shebang(content: str, file_path: str) -> list[str]:
issues = []
shell_info = ShellInfo.from_path(file_path)
if shell_info.expected_shell is None:
if content.strip().startswith("#!/bin/bash") or re.search(
r"^#!/bin/bash", content, re.MULTILINE
):
issues.append(
"Use '#!/usr/bin/env bash' instead of '#!/bin/bash' for better portability"
)
else:
shebang_pattern = rf"^#!\s*/bin/{shell_info.expected_shell}\b"
if re.search(shebang_pattern, content, re.MULTILINE):
issues.append(
f"Use '#!/usr/bin/env {shell_info.expected_shell}' instead of '#!/bin/{shell_info.expected_shell}' for better portability"
)
return issues
def main(argv: Sequence[str] | None = None) -> int:
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
return 1
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if not is_likely_shell_script(file_path):
return 0
issues = []
if tool_name == "Write":
content = tool_input.get("content", "")
issues = check_shebang(content, file_path)
elif tool_name == "Edit":
new_string = tool_input.get("new_string", "")
issues = check_shebang(new_string, file_path)
elif tool_name == "MultiEdit":
edits = tool_input.get("edits", [])
for edit in edits:
new_string = edit.get("new_string", "")
issues.extend(check_shebang(new_string, file_path))
if issues:
seen = set()
unique_issues = []
for issue in issues:
if issue not in seen:
seen.add(issue)
unique_issues.append(issue)
for message in unique_issues:
print(f"• {message}", file=sys.stderr)
# Exit code 2 blocks tool call and shows stderr to Claude
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,115 @@
#!/usr/bin/env python
from __future__ import annotations
import json
import re
import sys
from collections.abc import Sequence as ABCSequence
from dataclasses import dataclass
from enum import Enum
@dataclass
class ImportRule:
pattern: str
old_usage: str
new_usage: str
class DeprecatedImport(Enum):
OPTIONAL = ImportRule(
pattern=r"from\s+typing\s+import\s+.*\bOptional\b",
old_usage="Optional[X]",
new_usage="X | None",
)
DICT = ImportRule(
pattern=r"from\s+typing\s+import\s+.*\bDict\b",
old_usage="Dict",
new_usage="dict",
)
TUPLE = ImportRule(
pattern=r"from\s+typing\s+import\s+.*\bTuple\b",
old_usage="Tuple",
new_usage="tuple",
)
SEQUENCE = ImportRule(
pattern=r"from\s+typing\s+import\s+.*\bSequence\b",
old_usage="typing.Sequence",
new_usage="collections.abc.Sequence",
)
def format_message(self) -> str:
return f"Use '{self.value.new_usage}' instead of '{self.value.old_usage}' (from typing import {self.name.title()})"
@dataclass
class FileInfo:
file_path: str
filename: str
@classmethod
def from_path(cls, file_path: str) -> FileInfo:
filename = file_path.split("/")[-1] if "/" in file_path else file_path
return cls(file_path=file_path, filename=filename)
def is_python_file(file_path: str) -> bool:
return file_path.endswith(".py") or file_path.endswith(".pyi")
def check_deprecated_imports(content: str) -> list[str]:
issues = []
for deprecated in DeprecatedImport:
if re.search(deprecated.value.pattern, content, re.MULTILINE):
issues.append(deprecated.format_message())
return issues
def main(argv: ABCSequence[str] | None = None) -> int:
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
return 1
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if not is_python_file(file_path):
return 0
issues = []
if tool_name == "Write":
content = tool_input.get("content", "")
issues = check_deprecated_imports(content)
elif tool_name == "Edit":
new_string = tool_input.get("new_string", "")
issues = check_deprecated_imports(new_string)
elif tool_name == "MultiEdit":
edits = tool_input.get("edits", [])
for edit in edits:
new_string = edit.get("new_string", "")
issues.extend(check_deprecated_imports(new_string))
if issues:
seen = set()
unique_issues = []
for issue in issues:
if issue not in seen:
seen.add(issue)
unique_issues.append(issue)
for message in unique_issues:
print(f"{message}", file=sys.stderr)
# Exit code 2 blocks tool call and shows stderr to Claude
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -8,12 +8,12 @@ from collections.abc import Sequence
VALIDATION_RULES = [
(
r"\bgrep\b(?!.*\|)",
r"\bgrep\b",
"Use 'rg' (ripgrep) instead of 'grep' for better performance and features",
),
(
r"\bfind\s+\S+\s+-name\b",
"Use 'rg --files | rg pattern' or 'rg --files -g pattern' instead of 'find -name' for better performance",
r"\bfind\s+",
"Use 'rg --files' with appropriate filters instead of 'find' for better performance",
),
]

View file

@ -38,6 +38,24 @@
"command": "$HOME/.claude/hooks/jina_reader_check.py"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/bash_shebang_check.py"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/python_deprecated_imports.py"
}
]
}
]
}

7
.config/rustic/backup.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
set -euo pipefail
rustic backup
rustic forget
rustic repoinfo

View file

@ -0,0 +1 @@
bgNvM33ciEmBfhPfrWQyLKBXCQUDeG7LBEZCyK6gRKru6fEr

View file

@ -0,0 +1,80 @@
[repository]
repository = "opendal:s3"
password-file = "/home/josh/.config/rustic/rustic-password"
[repository.options]
endpoint = "https://0d52c99870b49aa52ccc7d5feca16714.r2.cloudflarestorage.com"
bucket = "homedir"
region = "auto"
batch_max_operations = "700"
enable_exact_buf_write = "true"
root = "/backups"
[backup]
label = "homedir"
tags = ["home", "laptop"]
git-ignore = false
exclude-if-present = [".nobackup", "CACHEDIR.TAG"]
one-file-system = false
globs = []
iglobs = [
# System caches
"/home/josh/.cache/**",
"/home/josh/.local/share/Trash/**",
"/home/josh/snap/**",
# Browser caches
"/home/josh/.config/BraveSoftware/*/*Cache/**",
"/home/josh/.config/chromium/*/*Cache/**",
"/home/josh/.config/google-chrome/*/*Cache/**",
"/home/josh/.config/vivaldi/*/*Cache/**",
"/home/josh/.config/vivaldi-snapshot/*/*Cache/**",
"/home/josh/.mozilla/firefox/*/*Cache/**",
# Package caches
"/home/josh/.cargo/registry/**",
"/home/josh/.cargo/git/db/**",
"/home/josh/.cargo/bin/**",
"/home/josh/.go/pkg/mod/**",
"/home/josh/.npm/**",
"/home/josh/.pnpm-store/**",
"/home/josh/.yarn/cache/**",
# Build outputs & dependencies
"**/node_modules/**",
"**/target/**",
"**/dist/**",
"**/build/**",
"**/__pycache__/**",
"**/*.pyc",
"**/.venv/**",
"**/venv/**",
# Large media/gaming
"/home/josh/.local/share/Steam/**",
"/home/josh/videos/**",
"/home/josh/music/**",
# Temp files
"**/*.tmp",
"**/*.temp",
"**/*.swp",
"**/*.swo",
"**/core",
"**/core.*",
# Downloads (usually duplicates)
"/home/josh/downloads/**",
"/home/josh/Downloads/**",
]
[[backup.snapshots]]
sources = ["/home/josh"]
[forget]
keep-daily = 7
keep-weekly = 4
keep-monthly = 6
keep-yearly = 2
prune = true