@torka/claude-qol 0.1.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/.claude-plugin/plugin.json +6 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/commands/optimize-auto-approve-hook.md +406 -0
- package/examples/settings.local.example.json +65 -0
- package/hooks/auto_approve_safe.py +261 -0
- package/hooks/auto_approve_safe.rules.json +135 -0
- package/install.js +187 -0
- package/package.json +34 -0
- package/scripts/context-monitor.py +175 -0
- package/uninstall.js +167 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(git status:*)",
|
|
5
|
+
"Bash(git diff:*)",
|
|
6
|
+
"Bash(git log:*)",
|
|
7
|
+
"Bash(git branch:*)",
|
|
8
|
+
"Bash(git fetch:*)",
|
|
9
|
+
"Bash(gh pr:*)",
|
|
10
|
+
"Bash(gh repo:*)",
|
|
11
|
+
"Bash(npm test:*)",
|
|
12
|
+
"Bash(npm run:*)",
|
|
13
|
+
"WebSearch"
|
|
14
|
+
],
|
|
15
|
+
"deny": [],
|
|
16
|
+
"ask": []
|
|
17
|
+
},
|
|
18
|
+
"hooks": {
|
|
19
|
+
"PreToolUse": [
|
|
20
|
+
{
|
|
21
|
+
"matcher": "Bash|Read|Grep|Glob|Write|Edit|MultiEdit",
|
|
22
|
+
"hooks": [
|
|
23
|
+
{
|
|
24
|
+
"type": "command",
|
|
25
|
+
"command": "python3 .claude/scripts/auto_approve_safe.py"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"PostToolUse": [
|
|
31
|
+
{
|
|
32
|
+
"matcher": "Edit|MultiEdit",
|
|
33
|
+
"hooks": [
|
|
34
|
+
{
|
|
35
|
+
"type": "command",
|
|
36
|
+
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.js || \"$CLAUDE_TOOL_FILE_PATH\" == *.ts || \"$CLAUDE_TOOL_FILE_PATH\" == *.jsx || \"$CLAUDE_TOOL_FILE_PATH\" == *.tsx ]]; then npx eslint \"$CLAUDE_TOOL_FILE_PATH\" --fix 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then pylint \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.rb ]]; then rubocop \"$CLAUDE_TOOL_FILE_PATH\" --auto-correct 2>/dev/null || true; fi"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"type": "command",
|
|
40
|
+
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.js || \"$CLAUDE_TOOL_FILE_PATH\" == *.ts || \"$CLAUDE_TOOL_FILE_PATH\" == *.jsx || \"$CLAUDE_TOOL_FILE_PATH\" == *.tsx || \"$CLAUDE_TOOL_FILE_PATH\" == *.json || \"$CLAUDE_TOOL_FILE_PATH\" == *.css || \"$CLAUDE_TOOL_FILE_PATH\" == *.html ]]; then npx prettier --write \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then black \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.go ]]; then gofmt -w \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.rs ]]; then rustfmt \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.php ]]; then php-cs-fixer fix \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"Stop": [
|
|
46
|
+
{
|
|
47
|
+
"matcher": "*",
|
|
48
|
+
"hooks": [
|
|
49
|
+
{
|
|
50
|
+
"type": "command",
|
|
51
|
+
"command": "if command -v osascript >/dev/null 2>&1; then osascript -e 'display notification \"Tool: Operation completed\" with title \"Claude Code\"'; elif command -v notify-send >/dev/null 2>&1; then notify-send 'Claude Code' \"Tool: $CLAUDE_TOOL_NAME completed\"; fi"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "command",
|
|
55
|
+
"command": "afplay /System/Library/Sounds/Glass.aiff"
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"statusLine": {
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": "python3 .claude/scripts/context-monitor.py"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code Hook: Auto-approve safe tool usage for solo dev workflows.
|
|
4
|
+
|
|
5
|
+
Handles PreToolUse events to:
|
|
6
|
+
- Auto-allow known-safe commands (read-only, tests, linting)
|
|
7
|
+
- Deny obviously dangerous commands
|
|
8
|
+
- Defer everything else to normal permission system ("ask")
|
|
9
|
+
|
|
10
|
+
Install:
|
|
11
|
+
1. mkdir -p ~/.claude/hooks && chmod 700 ~/.claude/hooks
|
|
12
|
+
2. Save this file to ~/.claude/hooks/auto_approve_safe.py
|
|
13
|
+
3. chmod +x ~/.claude/hooks/auto_approve_safe.py
|
|
14
|
+
4. Add hook config to ~/.claude/settings.json
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Max-autonomy default:
|
|
25
|
+
# - Allow reads/searches
|
|
26
|
+
# - Allow edits/writes except for sensitive paths
|
|
27
|
+
# - Allow bash commands only if they match allowlist (supports simple compound commands)
|
|
28
|
+
#
|
|
29
|
+
# Debugging:
|
|
30
|
+
# Set this to True temporarily to log every decision to a local jsonl file.
|
|
31
|
+
ENABLE_DECISION_LOG = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_rules() -> dict:
|
|
35
|
+
"""Load rules from global and project-specific config files."""
|
|
36
|
+
rules = {"allow_patterns": [], "deny_patterns": [], "sensitive_paths": []}
|
|
37
|
+
|
|
38
|
+
# # Load global rules
|
|
39
|
+
# global_rules_path = Path.home() / ".claude" / "hooks" / "auto_approve_safe.rules.json"
|
|
40
|
+
# if global_rules_path.exists():
|
|
41
|
+
# try:
|
|
42
|
+
# with open(global_rules_path) as f:
|
|
43
|
+
# global_rules = json.load(f)
|
|
44
|
+
# for key in rules:
|
|
45
|
+
# rules[key].extend(global_rules.get(key, []))
|
|
46
|
+
# except (json.JSONDecodeError, IOError) as e:
|
|
47
|
+
# print(f"Warning: Could not load global rules: {e}", file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
# Load project-specific rules (merge with global)
|
|
50
|
+
project_rules_path = Path.cwd() / ".claude" / "scripts" / "auto_approve_safe.rules.json"
|
|
51
|
+
if project_rules_path.exists():
|
|
52
|
+
try:
|
|
53
|
+
with open(project_rules_path) as f:
|
|
54
|
+
project_rules = json.load(f)
|
|
55
|
+
for key in rules:
|
|
56
|
+
rules[key].extend(project_rules.get(key, []))
|
|
57
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
58
|
+
print(f"Warning: Could not load project rules: {e}", file=sys.stderr)
|
|
59
|
+
|
|
60
|
+
return rules
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def matches_any_pattern(text: str, patterns: list[str]) -> bool:
|
|
64
|
+
"""Check if text matches any of the given regex patterns."""
|
|
65
|
+
for pattern in patterns:
|
|
66
|
+
try:
|
|
67
|
+
if re.search(pattern, text, re.IGNORECASE):
|
|
68
|
+
return True
|
|
69
|
+
except re.error:
|
|
70
|
+
continue
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_sensitive_path(file_path: str, sensitive_patterns: list[str]) -> bool:
|
|
75
|
+
"""Check if file path matches sensitive path patterns."""
|
|
76
|
+
if not file_path:
|
|
77
|
+
return False
|
|
78
|
+
return matches_any_pattern(file_path, sensitive_patterns)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_compound_shell_command(command: str) -> list[str]:
|
|
82
|
+
"""Split a shell command on simple compound operators (heuristic, not a full parser)."""
|
|
83
|
+
command = (command or "").strip()
|
|
84
|
+
if not command:
|
|
85
|
+
return []
|
|
86
|
+
# Common patterns produced by agents: `cd x && pnpm test`, `cmd1; cmd2`
|
|
87
|
+
return [p.strip() for p in re.split(r"\s*(?:&&|;)\s*", command) if p.strip()]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_shell_file_read_command(command: str) -> bool:
|
|
91
|
+
"""Detect common shell file-read commands that could exfiltrate secrets."""
|
|
92
|
+
return bool(re.search(r"^\s*(cat|head|tail|less)\b", command or "", re.IGNORECASE))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def summarize_tool_input(tool_name: str, tool_input: dict) -> dict:
|
|
96
|
+
"""Small, reviewable summary for decision logs."""
|
|
97
|
+
if tool_name == "Bash":
|
|
98
|
+
return {"command": tool_input.get("command", "")}
|
|
99
|
+
if tool_name in ("Read", "Write", "Edit", "MultiEdit"):
|
|
100
|
+
return {"file_path": tool_input.get("file_path", "")}
|
|
101
|
+
return {"tool_input_keys": list((tool_input or {}).keys())}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def log_decision(tool_name: str, tool_input: dict, decision: str, reason: str) -> None:
|
|
105
|
+
"""Append a decision record to a jsonl file when debugging is enabled."""
|
|
106
|
+
if not ENABLE_DECISION_LOG:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
log_path = Path.cwd() / ".claude" / "auto_approve_safe.decisions.jsonl"
|
|
110
|
+
record = {
|
|
111
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
112
|
+
"cwd": str(Path.cwd()),
|
|
113
|
+
"tool_name": tool_name,
|
|
114
|
+
"decision": decision,
|
|
115
|
+
"reason": reason,
|
|
116
|
+
"input": summarize_tool_input(tool_name, tool_input or {}),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
122
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
# Never break tool execution because logging failed.
|
|
125
|
+
print(f"Warning: Could not write decision log: {e}", file=sys.stderr)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_decision(tool_name: str, tool_input: dict, rules: dict) -> tuple[str, str]:
|
|
129
|
+
"""
|
|
130
|
+
Determine permission decision for a tool call.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
tuple: (decision, reason)
|
|
134
|
+
decision: "allow", "deny", or "ask"
|
|
135
|
+
reason: Human-readable explanation
|
|
136
|
+
|
|
137
|
+
Notes on integration with Claude Code:
|
|
138
|
+
- "allow" short-circuits Claude Code prompts
|
|
139
|
+
- "deny" blocks the tool
|
|
140
|
+
- "ask" defers to Claude Code's built-in permission system
|
|
141
|
+
|
|
142
|
+
This file is tuned for maximum autonomy by default, while:
|
|
143
|
+
- denying obvious dangerous commands
|
|
144
|
+
- blocking edits to sensitive paths
|
|
145
|
+
- prompting for reads of sensitive paths
|
|
146
|
+
"""
|
|
147
|
+
tool_input = tool_input or {}
|
|
148
|
+
|
|
149
|
+
# Handle Bash commands
|
|
150
|
+
if tool_name == "Bash":
|
|
151
|
+
command = (tool_input.get("command", "") or "").strip()
|
|
152
|
+
if not command:
|
|
153
|
+
return "ask", "Empty command"
|
|
154
|
+
|
|
155
|
+
segments = split_compound_shell_command(command)
|
|
156
|
+
if not segments:
|
|
157
|
+
return "ask", "Empty command"
|
|
158
|
+
|
|
159
|
+
# Deny wins if any segment matches a deny pattern.
|
|
160
|
+
for seg in segments:
|
|
161
|
+
if matches_any_pattern(seg, rules["deny_patterns"]):
|
|
162
|
+
return "deny", "Command matches dangerous pattern"
|
|
163
|
+
|
|
164
|
+
# If a segment looks like it could read a file, apply sensitive path checks.
|
|
165
|
+
# (Prevents silently allowing: `cat .env`, `head ~/.ssh/id_rsa`, etc.)
|
|
166
|
+
for seg in segments:
|
|
167
|
+
if is_shell_file_read_command(seg) and matches_any_pattern(seg, rules["sensitive_paths"]):
|
|
168
|
+
return "ask", "Bash command may read sensitive data"
|
|
169
|
+
|
|
170
|
+
# Max autonomy, but still require an allowlist match per segment.
|
|
171
|
+
# Add common "glue" patterns that agents use.
|
|
172
|
+
glue_allow_patterns = [
|
|
173
|
+
r"^cd\s+\S+(\s+.*)?$",
|
|
174
|
+
r"^pushd\s+\S+(\s+.*)?$",
|
|
175
|
+
r"^popd$",
|
|
176
|
+
r"^export\s+[A-Za-z_][A-Za-z0-9_]*=.*$",
|
|
177
|
+
r"^(true|false)$",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
for seg in segments:
|
|
181
|
+
if matches_any_pattern(seg, rules["allow_patterns"]):
|
|
182
|
+
continue
|
|
183
|
+
if matches_any_pattern(seg, glue_allow_patterns):
|
|
184
|
+
continue
|
|
185
|
+
return "ask", f"Command not in allowlist: {seg}"
|
|
186
|
+
|
|
187
|
+
return "allow", "Matches safe allowlist"
|
|
188
|
+
|
|
189
|
+
# Handle Read tool - check for sensitive files
|
|
190
|
+
if tool_name == "Read":
|
|
191
|
+
file_path = tool_input.get("file_path", "")
|
|
192
|
+
if check_sensitive_path(file_path, rules["sensitive_paths"]):
|
|
193
|
+
return "ask", "File may contain sensitive data"
|
|
194
|
+
return "allow", "Read operations are generally safe"
|
|
195
|
+
|
|
196
|
+
# Handle Grep/Glob - generally safe read-only operations
|
|
197
|
+
if tool_name in ("Grep", "Glob"):
|
|
198
|
+
return "allow", "Search operations are read-only"
|
|
199
|
+
|
|
200
|
+
# Handle Write/Edit - max autonomy by default; still protect sensitive paths.
|
|
201
|
+
if tool_name in ("Write", "Edit", "MultiEdit"):
|
|
202
|
+
file_path = tool_input.get("file_path", "")
|
|
203
|
+
if check_sensitive_path(file_path, rules["sensitive_paths"]):
|
|
204
|
+
return "deny", "Cannot modify sensitive files"
|
|
205
|
+
return "allow", "Write operations are generally safe"
|
|
206
|
+
|
|
207
|
+
# Default: defer to normal permission system
|
|
208
|
+
return "ask", "Unknown tool, deferring to permission system"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def output_decision(decision: str, reason: str) -> None:
|
|
212
|
+
"""Output the hook decision in Claude Code's expected format."""
|
|
213
|
+
output = {
|
|
214
|
+
"hookSpecificOutput": {
|
|
215
|
+
"hookEventName": "PreToolUse",
|
|
216
|
+
"permissionDecision": decision,
|
|
217
|
+
"permissionDecisionReason": reason
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
print(json.dumps(output))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main():
|
|
224
|
+
"""Main entry point for the hook."""
|
|
225
|
+
try:
|
|
226
|
+
# Read input from stdin
|
|
227
|
+
input_data = sys.stdin.read()
|
|
228
|
+
if not input_data.strip():
|
|
229
|
+
output_decision("ask", "No input received")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
data = json.loads(input_data)
|
|
233
|
+
|
|
234
|
+
# Extract tool information
|
|
235
|
+
tool_name = data.get("tool_name", "")
|
|
236
|
+
tool_input = data.get("tool_input", {})
|
|
237
|
+
|
|
238
|
+
# Load rules
|
|
239
|
+
rules = load_rules()
|
|
240
|
+
|
|
241
|
+
# Make decision
|
|
242
|
+
decision, reason = make_decision(tool_name, tool_input, rules)
|
|
243
|
+
|
|
244
|
+
# Optional debug log
|
|
245
|
+
log_decision(tool_name, tool_input, decision, reason)
|
|
246
|
+
|
|
247
|
+
# Output result
|
|
248
|
+
output_decision(decision, reason)
|
|
249
|
+
|
|
250
|
+
except json.JSONDecodeError as e:
|
|
251
|
+
print(f"Error parsing input JSON: {e}", file=sys.stderr)
|
|
252
|
+
output_decision("ask", "Failed to parse input")
|
|
253
|
+
except Exception as e:
|
|
254
|
+
print(f"Hook error: {e}", file=sys.stderr)
|
|
255
|
+
output_decision("ask", f"Hook error: {e}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
|
260
|
+
|
|
261
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
{
|
|
2
|
+
"allow_patterns": [
|
|
3
|
+
"^pwd$",
|
|
4
|
+
"^whoami$",
|
|
5
|
+
"^date$",
|
|
6
|
+
"^uname(\\s+-a)?$",
|
|
7
|
+
"^which\\s+\\S+$",
|
|
8
|
+
"^echo\\s+",
|
|
9
|
+
|
|
10
|
+
"^ls(\\s+.*)?$",
|
|
11
|
+
"^cat\\s+",
|
|
12
|
+
"^head\\s+",
|
|
13
|
+
"^tail\\s+",
|
|
14
|
+
"^wc\\s+",
|
|
15
|
+
"^less\\s+",
|
|
16
|
+
"^file\\s+",
|
|
17
|
+
"^stat\\s+",
|
|
18
|
+
"^du\\s+",
|
|
19
|
+
"^df\\s+",
|
|
20
|
+
"^tree(\\s+.*)?$",
|
|
21
|
+
|
|
22
|
+
"^python(3)?\\s+--version$",
|
|
23
|
+
"^node\\s+--version$",
|
|
24
|
+
"^npm\\s+--version$",
|
|
25
|
+
"^pnpm\\s+--version$",
|
|
26
|
+
"^yarn\\s+--version$",
|
|
27
|
+
"^uv\\s+--version$",
|
|
28
|
+
|
|
29
|
+
"^git\\s+(status|diff|log|show|branch|remote|stash\\s+list)(\\s+.*)?$",
|
|
30
|
+
|
|
31
|
+
"^pnpm\\s+(test|run\\s+(test|lint|typecheck|type-check|check|build|dev|start)|install|i|add|remove)(\\s+.*)?$",
|
|
32
|
+
"^npm\\s+(test|run\\s+(test|lint|typecheck|type-check|check|build|dev|start)|install|i|ci)(\\s+.*)?$",
|
|
33
|
+
"^yarn\\s+(test|lint|typecheck|type-check|check|build|dev|start|install|add|remove)(\\s+.*)?$",
|
|
34
|
+
"^npx\\s+(tsc|eslint|prettier|vitest|jest)(\\s+.*)?$",
|
|
35
|
+
"^npx\\s+tsx\\s+[^|;&]+$",
|
|
36
|
+
|
|
37
|
+
"^pytest(\\s+.*)?$",
|
|
38
|
+
"^python(3)?\\s+-m\\s+pytest(\\s+.*)?$",
|
|
39
|
+
"^uv\\s+run\\s+(pytest|python|ruff|mypy)(\\s+.*)?$",
|
|
40
|
+
"^ruff\\s+(check|format)(\\s+.*)?$",
|
|
41
|
+
"^mypy(\\s+.*)?$",
|
|
42
|
+
"^black\\s+--check(\\s+.*)?$",
|
|
43
|
+
"^isort\\s+--check(\\s+.*)?$",
|
|
44
|
+
"^pip\\s+(list|show|freeze)$",
|
|
45
|
+
"^uv\\s+(pip\\s+list|pip\\s+show|sync|lock)(\\s+.*)?$",
|
|
46
|
+
|
|
47
|
+
"^cargo\\s+(check|test|clippy|fmt\\s+--check|build)(\\s+.*)?$",
|
|
48
|
+
"^go\\s+(test|vet|fmt|build)(\\s+.*)?$",
|
|
49
|
+
|
|
50
|
+
"^jq\\s+",
|
|
51
|
+
"^grep\\s+",
|
|
52
|
+
"^rg\\s+",
|
|
53
|
+
"^find\\s+",
|
|
54
|
+
"^fd\\s+",
|
|
55
|
+
"^ag\\s+",
|
|
56
|
+
"^awk\\s+",
|
|
57
|
+
"^sed\\s+-n\\s+",
|
|
58
|
+
"^sort(\\s+.*)?$",
|
|
59
|
+
"^uniq(\\s+.*)?$",
|
|
60
|
+
"^cut\\s+",
|
|
61
|
+
"^tr\\s+",
|
|
62
|
+
"^diff\\s+",
|
|
63
|
+
"^comm\\s+",
|
|
64
|
+
|
|
65
|
+
"^curl\\s+.*--head",
|
|
66
|
+
"^curl\\s+-I\\s+",
|
|
67
|
+
"^ping\\s+-c\\s+\\d+\\s+",
|
|
68
|
+
"^dig\\s+",
|
|
69
|
+
"^nslookup\\s+",
|
|
70
|
+
"^host\\s+",
|
|
71
|
+
|
|
72
|
+
"^mkdir(\\s+.*)?$",
|
|
73
|
+
"^touch\\s+",
|
|
74
|
+
"^cp\\s+",
|
|
75
|
+
"^mv\\s+",
|
|
76
|
+
|
|
77
|
+
"^git\\s+(add|commit|checkout|fetch|pull|push|worktree|merge|rebase|stash\\s+(push|pop|drop|apply)|tag|switch|restore)(\\s+.*)?$",
|
|
78
|
+
|
|
79
|
+
"^gh\\s+(pr|issue|repo|release|workflow|run|api)(\\s+.*)?$",
|
|
80
|
+
|
|
81
|
+
"^chmod\\s+[0-6][0-7][0-7]\\s+"
|
|
82
|
+
],
|
|
83
|
+
|
|
84
|
+
"deny_patterns": [
|
|
85
|
+
"^sudo\\b",
|
|
86
|
+
"^doas\\b",
|
|
87
|
+
"\\brm\\s+.*(-r|-rf|-fr|--recursive)",
|
|
88
|
+
"\\brm\\s+-[^\\s]*r",
|
|
89
|
+
"^rm\\s+/",
|
|
90
|
+
"\\bmkfs\\.",
|
|
91
|
+
"\\bdd\\b.*\\bof=",
|
|
92
|
+
"\\bshutdown\\b",
|
|
93
|
+
"\\breboot\\b",
|
|
94
|
+
"\\bsystemctl\\s+(start|stop|restart|enable|disable)",
|
|
95
|
+
"\\bchmod\\s+777",
|
|
96
|
+
"\\bchown\\s+.*:.*\\s+/",
|
|
97
|
+
">\\s*/etc/",
|
|
98
|
+
">\\s*~/\\.",
|
|
99
|
+
"\\bcurl\\b.*\\|.*\\b(bash|sh|zsh)\\b",
|
|
100
|
+
"\\bwget\\b.*\\|.*\\b(bash|sh|zsh)\\b",
|
|
101
|
+
"\\beval\\s+.*\\$\\(",
|
|
102
|
+
":(){ :|:& };:",
|
|
103
|
+
"\\bfork\\s*bomb",
|
|
104
|
+
"\\bkill\\s+-9\\s+-1",
|
|
105
|
+
"\\bpkill\\s+-9",
|
|
106
|
+
"\\bkillall\\b"
|
|
107
|
+
],
|
|
108
|
+
|
|
109
|
+
"sensitive_paths": [
|
|
110
|
+
"\\.env$",
|
|
111
|
+
"\\.env\\.",
|
|
112
|
+
"\\.pem$",
|
|
113
|
+
"\\.key$",
|
|
114
|
+
"\\.crt$",
|
|
115
|
+
"\\.p12$",
|
|
116
|
+
"\\.pfx$",
|
|
117
|
+
"id_rsa",
|
|
118
|
+
"id_ed25519",
|
|
119
|
+
"id_ecdsa",
|
|
120
|
+
"\\.ssh/",
|
|
121
|
+
"\\.gnupg/",
|
|
122
|
+
"\\.git/config$",
|
|
123
|
+
"\\.gitconfig$",
|
|
124
|
+
"credentials",
|
|
125
|
+
"\\.aws/",
|
|
126
|
+
"\\.gcloud/",
|
|
127
|
+
"\\.azure/",
|
|
128
|
+
"\\.npmrc$",
|
|
129
|
+
"\\.pypirc$",
|
|
130
|
+
"\\.netrc$",
|
|
131
|
+
"\\bsecrets?\\b",
|
|
132
|
+
"\\bpassw",
|
|
133
|
+
"\\btoken"
|
|
134
|
+
]
|
|
135
|
+
}
|
package/install.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @torka/claude-qol - Post-install script
|
|
4
|
+
* Copies QoL files to the appropriate .claude directory
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// ANSI colors for output
|
|
12
|
+
const colors = {
|
|
13
|
+
green: '\x1b[32m',
|
|
14
|
+
yellow: '\x1b[33m',
|
|
15
|
+
blue: '\x1b[34m',
|
|
16
|
+
red: '\x1b[31m',
|
|
17
|
+
reset: '\x1b[0m',
|
|
18
|
+
bold: '\x1b[1m',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function log(message, color = 'reset') {
|
|
22
|
+
console.log(`${colors[color]}${message}${colors.reset}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function logSuccess(message) {
|
|
26
|
+
log(` ✓ ${message}`, 'green');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function logSkip(message) {
|
|
30
|
+
log(` ○ ${message}`, 'yellow');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function logError(message) {
|
|
34
|
+
log(` ✗ ${message}`, 'red');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Determine the target .claude directory based on installation context
|
|
39
|
+
*/
|
|
40
|
+
function getTargetBase() {
|
|
41
|
+
// Check if this is a global installation
|
|
42
|
+
const isGlobal = process.env.npm_config_global === 'true';
|
|
43
|
+
|
|
44
|
+
if (isGlobal) {
|
|
45
|
+
// Global install: use ~/.claude
|
|
46
|
+
return path.join(os.homedir(), '.claude');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Local install: find the project root (where package.json lives)
|
|
50
|
+
// Start from INIT_CWD (where npm was run) or current working directory
|
|
51
|
+
let projectRoot = process.env.INIT_CWD || process.cwd();
|
|
52
|
+
|
|
53
|
+
// Walk up to find package.json (the actual project, not this package)
|
|
54
|
+
while (projectRoot !== path.dirname(projectRoot)) {
|
|
55
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
56
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
59
|
+
if (pkg.name !== '@torka/claude-qol') {
|
|
60
|
+
return path.join(projectRoot, '.claude');
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// Continue walking up
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
projectRoot = path.dirname(projectRoot);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fallback to INIT_CWD
|
|
70
|
+
return path.join(process.env.INIT_CWD || process.cwd(), '.claude');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Recursively copy directory contents
|
|
75
|
+
*/
|
|
76
|
+
function copyDirRecursive(src, dest, stats) {
|
|
77
|
+
if (!fs.existsSync(src)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Create destination directory if it doesn't exist
|
|
82
|
+
if (!fs.existsSync(dest)) {
|
|
83
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
87
|
+
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const srcPath = path.join(src, entry.name);
|
|
90
|
+
const destPath = path.join(dest, entry.name);
|
|
91
|
+
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
copyDirRecursive(srcPath, destPath, stats);
|
|
94
|
+
} else {
|
|
95
|
+
if (fs.existsSync(destPath)) {
|
|
96
|
+
stats.skipped.push(destPath);
|
|
97
|
+
logSkip(`Skipped (exists): ${path.relative(stats.targetBase, destPath)}`);
|
|
98
|
+
} else {
|
|
99
|
+
fs.copyFileSync(srcPath, destPath);
|
|
100
|
+
stats.copied.push(destPath);
|
|
101
|
+
logSuccess(`Copied: ${path.relative(stats.targetBase, destPath)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Main installation function
|
|
109
|
+
*/
|
|
110
|
+
function install() {
|
|
111
|
+
const packageDir = __dirname;
|
|
112
|
+
const targetBase = getTargetBase();
|
|
113
|
+
const isGlobal = process.env.npm_config_global === 'true';
|
|
114
|
+
|
|
115
|
+
log('\n' + colors.bold + '📦 @torka/claude-qol - Installing...' + colors.reset);
|
|
116
|
+
log(` Target: ${targetBase}`, 'blue');
|
|
117
|
+
log(` Mode: ${isGlobal ? 'Global' : 'Project-level'}\n`, 'blue');
|
|
118
|
+
|
|
119
|
+
// Create target .claude directory if it doesn't exist
|
|
120
|
+
if (!fs.existsSync(targetBase)) {
|
|
121
|
+
fs.mkdirSync(targetBase, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stats = {
|
|
125
|
+
copied: [],
|
|
126
|
+
skipped: [],
|
|
127
|
+
targetBase,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Define what to copy and where
|
|
131
|
+
const mappings = [
|
|
132
|
+
{ src: 'hooks', dest: 'scripts' }, // Hooks go to scripts directory
|
|
133
|
+
{ src: 'scripts', dest: 'scripts' },
|
|
134
|
+
{ src: 'commands', dest: 'commands' },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const { src, dest } of mappings) {
|
|
138
|
+
const srcPath = path.join(packageDir, src);
|
|
139
|
+
const destPath = path.join(targetBase, dest);
|
|
140
|
+
|
|
141
|
+
if (fs.existsSync(srcPath)) {
|
|
142
|
+
log(`\n${colors.bold}${src}/${colors.reset}`);
|
|
143
|
+
copyDirRecursive(srcPath, destPath, stats);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Summary
|
|
148
|
+
log('\n' + colors.bold + '📊 Installation Summary' + colors.reset);
|
|
149
|
+
log(` Files copied: ${stats.copied.length}`, 'green');
|
|
150
|
+
log(` Files skipped (already exist): ${stats.skipped.length}`, 'yellow');
|
|
151
|
+
|
|
152
|
+
// Post-install instructions
|
|
153
|
+
log('\n' + colors.bold + '📝 Configuration Required' + colors.reset);
|
|
154
|
+
log(' Add to your .claude/settings.local.json:\n');
|
|
155
|
+
|
|
156
|
+
log(colors.yellow + ' Auto-approve hook (recommended):' + colors.reset);
|
|
157
|
+
log(' {');
|
|
158
|
+
log(' "hooks": {');
|
|
159
|
+
log(' "PreToolUse": [{');
|
|
160
|
+
log(' "matcher": "Bash|Read|Grep|Glob|Write|Edit|MultiEdit",');
|
|
161
|
+
log(' "hooks": [{');
|
|
162
|
+
log(' "type": "command",');
|
|
163
|
+
log(' "command": "python3 .claude/scripts/auto_approve_safe.py"');
|
|
164
|
+
log(' }]');
|
|
165
|
+
log(' }]');
|
|
166
|
+
log(' }');
|
|
167
|
+
log(' }\n');
|
|
168
|
+
|
|
169
|
+
log(colors.yellow + ' Status line (optional):' + colors.reset);
|
|
170
|
+
log(' {');
|
|
171
|
+
log(' "statusLine": {');
|
|
172
|
+
log(' "type": "command",');
|
|
173
|
+
log(' "command": "python3 .claude/scripts/context-monitor.py"');
|
|
174
|
+
log(' }');
|
|
175
|
+
log(' }\n');
|
|
176
|
+
|
|
177
|
+
log(' See examples/settings.local.example.json for a complete example.\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Run installation
|
|
181
|
+
try {
|
|
182
|
+
install();
|
|
183
|
+
} catch (error) {
|
|
184
|
+
logError(`Installation failed: ${error.message}`);
|
|
185
|
+
// Don't exit with error code - allow npm install to complete
|
|
186
|
+
console.error(error);
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@torka/claude-qol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code quality-of-life improvements: auto-approve hooks, context monitoring, and status line enhancements",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"developer-tools",
|
|
8
|
+
"automation",
|
|
9
|
+
"hooks",
|
|
10
|
+
"context-monitor",
|
|
11
|
+
"auto-approve",
|
|
12
|
+
"claude",
|
|
13
|
+
"anthropic"
|
|
14
|
+
],
|
|
15
|
+
"author": "Varun Torka",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "node install.js",
|
|
19
|
+
"preuninstall": "node uninstall.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"hooks",
|
|
23
|
+
"scripts",
|
|
24
|
+
"commands",
|
|
25
|
+
"examples",
|
|
26
|
+
".claude-plugin",
|
|
27
|
+
"install.js",
|
|
28
|
+
"uninstall.js",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=16.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|