claudia-mentor 0.6.0 → 0.8.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/README.md +3 -1
- package/hooks/hooks.json +9 -26
- package/hooks/scripts/check-accessibility.py +1 -1
- package/hooks/scripts/check-css.py +179 -0
- package/hooks/scripts/check-deps.py +1 -1
- package/hooks/scripts/check-dockerfile.py +1 -1
- package/hooks/scripts/check-git-hygiene.py +1 -1
- package/hooks/scripts/check-license.py +1 -1
- package/hooks/scripts/check-practices.py +1 -1
- package/hooks/scripts/check-secrets.py +129 -0
- package/hooks/scripts/claudia-compact-tip.py +1 -1
- package/hooks/scripts/claudia-milestones.py +22 -20
- package/hooks/scripts/claudia-next-steps.py +23 -24
- package/hooks/scripts/claudia-prompt-coach.py +17 -1
- package/hooks/scripts/claudia-run-suggest.py +21 -21
- package/hooks/scripts/claudia-session-tips.py +1 -1
- package/hooks/scripts/claudia-stop-dispatch.py +77 -0
- package/hooks/scripts/claudia-teach.py +61 -26
- package/package.json +1 -1
- package/hooks/scripts/check-secrets.sh +0 -118
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Claudia: Proactive Mentor Plugin for Claude Code
|
|
2
2
|
|
|
3
|
+
[](https://github.com/reganomalley/claudia/actions/workflows/test.yml)
|
|
4
|
+
|
|
3
5
|
A Claude Code plugin that acts as your technology mentor, security advisor, and prompt coach. Claudia fills the gaps between writing code and making good technology decisions.
|
|
4
6
|
|
|
5
|
-
**10 knowledge domains. 14 hooks. 11 commands. Beginner-friendly.**
|
|
7
|
+
**10 knowledge domains. 14 hooks. 11 commands. 205+ tests. Beginner-friendly.**
|
|
6
8
|
|
|
7
9
|
## What Claudia Does
|
|
8
10
|
|
package/hooks/hooks.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Claudia mentor hooks: security, anti-patterns, dependencies, Docker, git hygiene, accessibility, license compliance, teaching moments, compaction tips, session tips, prompt coaching, run suggestions, next steps, and milestones",
|
|
2
|
+
"description": "Claudia mentor hooks: security, anti-patterns, CSS, dependencies, Docker, git hygiene, accessibility, license compliance, teaching moments, compaction tips, session tips, prompt coaching, run suggestions, next steps, and milestones",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "
|
|
9
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/check-secrets.py",
|
|
10
10
|
"timeout": 10
|
|
11
11
|
}
|
|
12
12
|
],
|
|
@@ -71,41 +71,24 @@
|
|
|
71
71
|
}
|
|
72
72
|
],
|
|
73
73
|
"matcher": "Edit|Write|MultiEdit"
|
|
74
|
-
}
|
|
75
|
-
],
|
|
76
|
-
"Stop": [
|
|
77
|
-
{
|
|
78
|
-
"hooks": [
|
|
79
|
-
{
|
|
80
|
-
"type": "command",
|
|
81
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-milestones.py",
|
|
82
|
-
"timeout": 10
|
|
83
|
-
}
|
|
84
|
-
]
|
|
85
74
|
},
|
|
86
75
|
{
|
|
87
76
|
"hooks": [
|
|
88
77
|
{
|
|
89
78
|
"type": "command",
|
|
90
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/
|
|
79
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/check-css.py",
|
|
91
80
|
"timeout": 10
|
|
92
81
|
}
|
|
93
|
-
]
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"type": "command",
|
|
99
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-next-steps.py",
|
|
100
|
-
"timeout": 10
|
|
101
|
-
}
|
|
102
|
-
]
|
|
103
|
-
},
|
|
82
|
+
],
|
|
83
|
+
"matcher": "Edit|Write|MultiEdit"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"Stop": [
|
|
104
87
|
{
|
|
105
88
|
"hooks": [
|
|
106
89
|
{
|
|
107
90
|
"type": "command",
|
|
108
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-
|
|
91
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-stop-dispatch.py",
|
|
109
92
|
"timeout": 10
|
|
110
93
|
}
|
|
111
94
|
]
|
|
@@ -159,7 +159,7 @@ def main():
|
|
|
159
159
|
if warnings:
|
|
160
160
|
save_state(session_id, shown)
|
|
161
161
|
message = "Claudia noticed some accessibility concerns:\n" + "\n".join(warnings)
|
|
162
|
-
output = json.dumps({"systemMessage": f"\033[38;5;
|
|
162
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
163
163
|
print(output)
|
|
164
164
|
|
|
165
165
|
sys.exit(0)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claudia: check-css.py
|
|
4
|
+
PreToolUse hook that warns on common CSS anti-patterns.
|
|
5
|
+
Advisory only (exit 0 with systemMessage), never blocks.
|
|
6
|
+
Session-aware dedup to avoid repeating warnings.
|
|
7
|
+
Only fires on CSS/SCSS/style files and inline styles.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
# CSS anti-patterns to detect
|
|
16
|
+
# Each: (pattern_regex, description, advice, pattern_id)
|
|
17
|
+
CSS_ANTI_PATTERNS = [
|
|
18
|
+
(
|
|
19
|
+
r'!important',
|
|
20
|
+
"!important usage detected",
|
|
21
|
+
"!important overrides all specificity and makes styles hard to maintain. Fix the specificity conflict instead.",
|
|
22
|
+
"important",
|
|
23
|
+
),
|
|
24
|
+
(
|
|
25
|
+
r'z-index:\s*(?:9{3,}|[1-9]\d{3,})',
|
|
26
|
+
"Extremely high z-index",
|
|
27
|
+
"z-index values like 9999 create an arms race. Use a z-index scale (10, 20, 30...) or CSS variables.",
|
|
28
|
+
"z_index_high",
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
r'(?:color|background(?:-color)?|border(?:-color)?)\s*:\s*#[0-9a-fA-F]{3,8}\s*[;\n]',
|
|
32
|
+
"Hardcoded color value",
|
|
33
|
+
"Use CSS custom properties (var(--color-name)) or design tokens instead of hardcoded hex values for maintainability.",
|
|
34
|
+
"hardcoded_color",
|
|
35
|
+
),
|
|
36
|
+
(
|
|
37
|
+
r'\*\s*\{[^}]*(?:margin|padding)\s*:\s*0',
|
|
38
|
+
"Universal selector reset",
|
|
39
|
+
"Resetting all margins/padding with * {} is expensive and can break components. Use a targeted reset or normalize.css.",
|
|
40
|
+
"universal_reset",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
r'(?:width|height)\s*:\s*\d+px\s*[;\n].*(?:width|height)\s*:\s*\d+px',
|
|
44
|
+
"Fixed pixel dimensions on layout",
|
|
45
|
+
"Fixed px widths can break responsiveness. Consider max-width, min-width, or relative units (%, rem, vw).",
|
|
46
|
+
"fixed_dimensions",
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
r'@import\s+(?:url\()?["\'](?!.*\.css)',
|
|
50
|
+
"@import in CSS",
|
|
51
|
+
"@import creates extra HTTP requests and blocks rendering. Use your bundler's import or <link> tags instead.",
|
|
52
|
+
"css_import",
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
r'float\s*:\s*(?:left|right)',
|
|
56
|
+
"Float-based layout",
|
|
57
|
+
"Floats for layout are legacy. Use flexbox or grid instead — they're easier and more predictable.",
|
|
58
|
+
"float_layout",
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
r'(?:top|left|right|bottom)\s*:\s*50%.*transform\s*:\s*translate',
|
|
62
|
+
"Manual centering with position + transform",
|
|
63
|
+
"Consider using flexbox (display: flex; align-items: center; justify-content: center) or grid (place-items: center) for cleaner centering.",
|
|
64
|
+
"manual_centering",
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# File extensions where CSS anti-patterns are relevant
|
|
69
|
+
CSS_EXTENSIONS = {'.css', '.scss', '.sass', '.less', '.styl', '.pcss'}
|
|
70
|
+
STYLE_FILE_PATTERNS = ['style', 'global', 'theme', 'tailwind']
|
|
71
|
+
|
|
72
|
+
# Files likely to have legitimate hardcoded colors
|
|
73
|
+
COLOR_EXCEPTION_PATTERNS = ['theme', 'tokens', 'variables', 'colors', 'palette', 'global.css', 'tailwind']
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_css_file(file_path):
|
|
77
|
+
"""Check if this is a CSS-like file or a file likely containing styles."""
|
|
78
|
+
_, ext = os.path.splitext(file_path)
|
|
79
|
+
if ext.lower() in CSS_EXTENSIONS:
|
|
80
|
+
return True
|
|
81
|
+
# Also check styled-components / CSS-in-JS in .tsx/.jsx/.vue/.svelte
|
|
82
|
+
if ext.lower() in {'.tsx', '.jsx', '.vue', '.svelte', '.astro'}:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_state_file(session_id):
|
|
88
|
+
return os.path.expanduser(f"~/.claude/claudia_css_state_{session_id}.json")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_state(session_id):
|
|
92
|
+
state_file = get_state_file(session_id)
|
|
93
|
+
if os.path.exists(state_file):
|
|
94
|
+
try:
|
|
95
|
+
with open(state_file) as f:
|
|
96
|
+
return set(json.load(f))
|
|
97
|
+
except (json.JSONDecodeError, IOError):
|
|
98
|
+
return set()
|
|
99
|
+
return set()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def save_state(session_id, shown):
|
|
103
|
+
state_file = get_state_file(session_id)
|
|
104
|
+
try:
|
|
105
|
+
os.makedirs(os.path.dirname(state_file), exist_ok=True)
|
|
106
|
+
with open(state_file, "w") as f:
|
|
107
|
+
json.dump(list(shown), f)
|
|
108
|
+
except IOError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_content(tool_name, tool_input):
|
|
113
|
+
if tool_name == "Write":
|
|
114
|
+
return tool_input.get("content", "")
|
|
115
|
+
elif tool_name == "Edit":
|
|
116
|
+
return tool_input.get("new_string", "")
|
|
117
|
+
elif tool_name == "MultiEdit":
|
|
118
|
+
edits = tool_input.get("edits", [])
|
|
119
|
+
return " ".join(e.get("new_string", "") for e in edits)
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
try:
|
|
125
|
+
input_data = json.loads(sys.stdin.read())
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
sys.exit(0)
|
|
128
|
+
|
|
129
|
+
session_id = input_data.get("session_id", "default")
|
|
130
|
+
tool_name = input_data.get("tool_name", "")
|
|
131
|
+
tool_input = input_data.get("tool_input", {})
|
|
132
|
+
file_path = tool_input.get("file_path", "")
|
|
133
|
+
|
|
134
|
+
if tool_name not in ("Edit", "Write", "MultiEdit"):
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
if not is_css_file(file_path):
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
content = extract_content(tool_name, tool_input)
|
|
141
|
+
if not content:
|
|
142
|
+
sys.exit(0)
|
|
143
|
+
|
|
144
|
+
# For non-CSS files, only check if content contains style-related code
|
|
145
|
+
_, ext = os.path.splitext(file_path)
|
|
146
|
+
if ext.lower() not in CSS_EXTENSIONS:
|
|
147
|
+
has_styles = bool(re.search(r'(?:styled|css`|className=|class=|<style)', content))
|
|
148
|
+
if not has_styles:
|
|
149
|
+
sys.exit(0)
|
|
150
|
+
|
|
151
|
+
# Is this a theme/token file where hardcoded colors are expected?
|
|
152
|
+
basename = os.path.basename(file_path).lower()
|
|
153
|
+
is_theme_file = any(p in basename for p in COLOR_EXCEPTION_PATTERNS)
|
|
154
|
+
|
|
155
|
+
shown = load_state(session_id)
|
|
156
|
+
warnings = []
|
|
157
|
+
|
|
158
|
+
for pattern, description, advice, pattern_id in CSS_ANTI_PATTERNS:
|
|
159
|
+
# Skip hardcoded color warning in theme/token files
|
|
160
|
+
if pattern_id == "hardcoded_color" and is_theme_file:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if re.search(pattern, content, re.DOTALL):
|
|
164
|
+
warning_key = f"{file_path}-{pattern_id}"
|
|
165
|
+
if warning_key not in shown:
|
|
166
|
+
shown.add(warning_key)
|
|
167
|
+
warnings.append(f"- {description}: {advice}")
|
|
168
|
+
|
|
169
|
+
if warnings:
|
|
170
|
+
save_state(session_id, shown)
|
|
171
|
+
message = "Claudia noticed some CSS patterns worth reviewing:\n" + "\n".join(warnings)
|
|
172
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
173
|
+
print(output)
|
|
174
|
+
|
|
175
|
+
sys.exit(0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
main()
|
|
@@ -109,7 +109,7 @@ def main():
|
|
|
109
109
|
if warnings:
|
|
110
110
|
save_state(session_id, shown)
|
|
111
111
|
message = "Claudia noticed some dependency concerns:\n" + "\n".join(warnings)
|
|
112
|
-
output = json.dumps({"systemMessage": f"\033[38;5;
|
|
112
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
113
113
|
print(output)
|
|
114
114
|
|
|
115
115
|
sys.exit(0)
|
|
@@ -166,7 +166,7 @@ def main():
|
|
|
166
166
|
if warnings:
|
|
167
167
|
save_state(session_id, shown)
|
|
168
168
|
message = "Claudia noticed some Dockerfile patterns worth reviewing:\n" + "\n".join(warnings)
|
|
169
|
-
output = json.dumps({"systemMessage": f"\033[38;5;
|
|
169
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
170
170
|
print(output)
|
|
171
171
|
|
|
172
172
|
sys.exit(0)
|
|
@@ -134,7 +134,7 @@ def main():
|
|
|
134
134
|
if warnings:
|
|
135
135
|
save_state(session_id, shown)
|
|
136
136
|
message = "Claudia noticed a git hygiene concern:\n" + "\n".join(warnings)
|
|
137
|
-
output = json.dumps({"systemMessage": f"\033[38;5;
|
|
137
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
138
138
|
print(output)
|
|
139
139
|
|
|
140
140
|
sys.exit(0)
|
|
@@ -156,7 +156,7 @@ def main():
|
|
|
156
156
|
if warnings:
|
|
157
157
|
save_state(session_id, shown)
|
|
158
158
|
message = "Claudia noticed some license concerns:\n" + "\n".join(warnings)
|
|
159
|
-
output = json.dumps({"systemMessage": f"\033[38;5;
|
|
159
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
160
160
|
print(output)
|
|
161
161
|
|
|
162
162
|
sys.exit(0)
|
|
@@ -159,7 +159,7 @@ def main():
|
|
|
159
159
|
save_state(session_id, shown)
|
|
160
160
|
# Advisory output via JSON on stdout (systemMessage)
|
|
161
161
|
message = "Claudia noticed some patterns worth reviewing:\n" + "\n".join(warnings)
|
|
162
|
-
output = json.dumps({"systemMessage": f"\033[38;5;
|
|
162
|
+
output = json.dumps({"systemMessage": f"\033[38;5;160m{message}\033[0m"})
|
|
163
163
|
print(output)
|
|
164
164
|
|
|
165
165
|
sys.exit(0)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claudia: check-secrets.py
|
|
4
|
+
PreToolUse hook that blocks hardcoded secrets in Edit/Write/MultiEdit operations.
|
|
5
|
+
Exit 2 = block, Exit 0 = allow.
|
|
6
|
+
Session-aware dedup to avoid re-blocking the same file+secret type.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
# Secret patterns to detect
|
|
15
|
+
# Each: (pattern_regex, description, secret_type)
|
|
16
|
+
SECRET_PATTERNS = [
|
|
17
|
+
(r'AKIA[0-9A-Z]{16}', "AWS Access Key ID detected", "aws_key"),
|
|
18
|
+
(r'[0-9a-zA-Z/+]{40}(?=.*AWS)', "AWS Secret Access Key detected", "aws_secret"),
|
|
19
|
+
(r'sk-[a-zA-Z0-9]{20,}', "OpenAI/Stripe-style secret key detected", "sk_key"),
|
|
20
|
+
(r'ghp_[a-zA-Z0-9]{36}', "GitHub personal access token detected", "github_pat"),
|
|
21
|
+
(r'gho_[a-zA-Z0-9]{36}', "GitHub OAuth token detected", "github_oauth"),
|
|
22
|
+
(r'glpat-[a-zA-Z0-9\-]{20,}', "GitLab personal access token detected", "gitlab_pat"),
|
|
23
|
+
(r'xox[bpras]-[a-zA-Z0-9\-]{10,}', "Slack token detected", "slack_token"),
|
|
24
|
+
(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----', "Private key detected", "private_key"),
|
|
25
|
+
(r'password\s*[=:]\s*["\'][^"\']{8,}["\']', "Hardcoded password detected", "password"),
|
|
26
|
+
(r'secret\s*[=:]\s*["\'][^"\']{8,}["\']', "Hardcoded secret detected", "secret"),
|
|
27
|
+
(r'api[_-]?key\s*[=:]\s*["\'][a-zA-Z0-9]{16,}["\']', "Hardcoded API key detected", "api_key"),
|
|
28
|
+
(r'mongodb(?:\+srv)?://[^/\s]+:[^@\s]+@', "MongoDB connection string with credentials", "mongo_uri"),
|
|
29
|
+
(r'postgres(?:ql)?://[^/\s]+:[^@\s]+@', "PostgreSQL connection string with credentials", "pg_uri"),
|
|
30
|
+
(r'mysql://[^/\s]+:[^@\s]+@', "MySQL connection string with credentials", "mysql_uri"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# File patterns to skip (test/example/fixture files)
|
|
34
|
+
SKIP_PATTERNS = ['test', 'spec', 'fixture', 'mock', '.example', '.sample', '.md']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_state_file(session_id):
|
|
38
|
+
return os.path.expanduser(f"~/.claude/claudia_secrets_state_{session_id}.json")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_state(session_id):
|
|
42
|
+
state_file = get_state_file(session_id)
|
|
43
|
+
if os.path.exists(state_file):
|
|
44
|
+
try:
|
|
45
|
+
with open(state_file) as f:
|
|
46
|
+
return set(json.load(f))
|
|
47
|
+
except (json.JSONDecodeError, IOError):
|
|
48
|
+
return set()
|
|
49
|
+
return set()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_state(session_id, shown):
|
|
53
|
+
state_file = get_state_file(session_id)
|
|
54
|
+
try:
|
|
55
|
+
os.makedirs(os.path.dirname(state_file), exist_ok=True)
|
|
56
|
+
with open(state_file, "w") as f:
|
|
57
|
+
json.dump(list(shown), f)
|
|
58
|
+
except IOError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_content(tool_name, tool_input):
|
|
63
|
+
if tool_name == "Write":
|
|
64
|
+
return tool_input.get("content", "")
|
|
65
|
+
elif tool_name == "Edit":
|
|
66
|
+
return tool_input.get("new_string", "")
|
|
67
|
+
elif tool_name == "MultiEdit":
|
|
68
|
+
edits = tool_input.get("edits", [])
|
|
69
|
+
return " ".join(e.get("new_string", "") for e in edits)
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main():
|
|
74
|
+
try:
|
|
75
|
+
input_data = json.loads(sys.stdin.read())
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
sys.exit(0)
|
|
78
|
+
|
|
79
|
+
tool_name = input_data.get("tool_name", "")
|
|
80
|
+
session_id = input_data.get("session_id", "default")
|
|
81
|
+
tool_input = input_data.get("tool_input", {})
|
|
82
|
+
file_path = tool_input.get("file_path", "")
|
|
83
|
+
|
|
84
|
+
if tool_name not in ("Edit", "Write", "MultiEdit"):
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
|
|
87
|
+
content = extract_content(tool_name, tool_input)
|
|
88
|
+
if not content:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
|
|
91
|
+
# Skip test files and example/fixture files
|
|
92
|
+
if any(skip in file_path for skip in SKIP_PATTERNS):
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
shown = load_state(session_id)
|
|
96
|
+
found = []
|
|
97
|
+
|
|
98
|
+
for pattern, description, secret_type in SECRET_PATTERNS:
|
|
99
|
+
if re.search(pattern, content):
|
|
100
|
+
warning_key = f"{file_path}-{secret_type}"
|
|
101
|
+
if warning_key not in shown:
|
|
102
|
+
shown.add(warning_key)
|
|
103
|
+
found.append(description)
|
|
104
|
+
|
|
105
|
+
if found:
|
|
106
|
+
save_state(session_id, shown)
|
|
107
|
+
|
|
108
|
+
C = "\033[38;5;160m"
|
|
109
|
+
R = "\033[0m"
|
|
110
|
+
if len(found) == 1:
|
|
111
|
+
print(f"{C}Claudia: {found[0]} in {file_path}.{R}", file=sys.stderr)
|
|
112
|
+
else:
|
|
113
|
+
print(f"{C}Claudia: Multiple secrets detected in {file_path}:{R}", file=sys.stderr)
|
|
114
|
+
for desc in found:
|
|
115
|
+
print(f"{C} - {desc}{R}", file=sys.stderr)
|
|
116
|
+
print("", file=sys.stderr)
|
|
117
|
+
print(f"{C}Secrets should never be hardcoded in source files. Use:", file=sys.stderr)
|
|
118
|
+
print(" - Environment variables (.env file, gitignored)", file=sys.stderr)
|
|
119
|
+
print(" - A secret manager (AWS SSM, Vault, Doppler)", file=sys.stderr)
|
|
120
|
+
print(f" - CI/CD secrets for pipelines{R}", file=sys.stderr)
|
|
121
|
+
print("", file=sys.stderr)
|
|
122
|
+
print(f"{C}If this is intentionally a test fixture or example, rename the file to include 'test', 'example', or 'fixture'.{R}", file=sys.stderr)
|
|
123
|
+
sys.exit(2)
|
|
124
|
+
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -85,7 +85,7 @@ def main():
|
|
|
85
85
|
save_state(session_id, state)
|
|
86
86
|
# Only systemMessage here -- additionalContext gets wiped by compaction
|
|
87
87
|
output = json.dumps({
|
|
88
|
-
"systemMessage": f"\033[38;5;
|
|
88
|
+
"systemMessage": f"\033[38;5;160m{tip}\033[0m",
|
|
89
89
|
})
|
|
90
90
|
print(output)
|
|
91
91
|
|
|
@@ -115,22 +115,15 @@ def count_new_files(message):
|
|
|
115
115
|
return len(files)
|
|
116
116
|
|
|
117
117
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
input_data = json.loads(sys.stdin.read())
|
|
121
|
-
except json.JSONDecodeError:
|
|
122
|
-
sys.exit(0)
|
|
123
|
-
|
|
118
|
+
def check(input_data, proactivity, experience):
|
|
119
|
+
"""Run milestones logic. Returns output dict or None."""
|
|
124
120
|
message = input_data.get("last_assistant_message", "")
|
|
125
121
|
|
|
126
122
|
if not message:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
experience = load_config()
|
|
123
|
+
return None
|
|
130
124
|
|
|
131
|
-
# Gate: beginner only
|
|
132
125
|
if experience != "beginner":
|
|
133
|
-
|
|
126
|
+
return None
|
|
134
127
|
|
|
135
128
|
state = load_state()
|
|
136
129
|
achieved = set(state.get("achieved", []))
|
|
@@ -162,19 +155,28 @@ def main():
|
|
|
162
155
|
if celebration:
|
|
163
156
|
state["achieved"] = list(achieved)
|
|
164
157
|
save_state(state)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
msg = f"Claudia: {celebration}"
|
|
168
|
-
output = json.dumps({
|
|
169
|
-
"additionalContext": msg,
|
|
170
|
-
"systemMessage": f"\033[38;5;209m{msg}\033[0m",
|
|
171
|
-
})
|
|
172
|
-
print(output)
|
|
158
|
+
msg = f"Claudia: {celebration}"
|
|
159
|
+
return {"additionalContext": msg, "systemMessage": f"\033[38;5;160m{msg}\033[0m"}
|
|
173
160
|
|
|
174
161
|
# Save state even without celebration (for file_count tracking)
|
|
175
|
-
|
|
162
|
+
if new_files > 0:
|
|
176
163
|
save_state(state)
|
|
177
164
|
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main():
|
|
169
|
+
try:
|
|
170
|
+
input_data = json.loads(sys.stdin.read())
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
sys.exit(0)
|
|
173
|
+
|
|
174
|
+
_, experience = load_user_config()
|
|
175
|
+
session_id = input_data.get("session_id", "default")
|
|
176
|
+
result = check(input_data, None, experience)
|
|
177
|
+
if result and stop_lock_acquire(session_id):
|
|
178
|
+
print(json.dumps(result))
|
|
179
|
+
|
|
178
180
|
sys.exit(0)
|
|
179
181
|
|
|
180
182
|
|
|
@@ -131,38 +131,29 @@ def load_config():
|
|
|
131
131
|
return experience
|
|
132
132
|
|
|
133
133
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
input_data = json.loads(sys.stdin.read())
|
|
137
|
-
except json.JSONDecodeError:
|
|
138
|
-
sys.exit(0)
|
|
139
|
-
|
|
134
|
+
def check(input_data, proactivity, experience):
|
|
135
|
+
"""Run next-steps logic. Returns output dict or None."""
|
|
140
136
|
session_id = input_data.get("session_id", "default")
|
|
141
137
|
message = input_data.get("last_assistant_message", "")
|
|
142
138
|
|
|
143
139
|
if not message:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
experience = load_config()
|
|
140
|
+
return None
|
|
147
141
|
|
|
148
|
-
# Gate: beginner only
|
|
149
142
|
if experience != "beginner":
|
|
150
|
-
|
|
143
|
+
return None
|
|
151
144
|
|
|
152
145
|
state = load_state(session_id)
|
|
153
146
|
|
|
154
|
-
# Max 3 suggestion moments per session
|
|
155
147
|
if state["count"] >= 3:
|
|
156
|
-
|
|
148
|
+
return None
|
|
157
149
|
|
|
158
|
-
# Check for completion signals
|
|
159
150
|
is_completion = any(
|
|
160
151
|
re.search(pattern, message, re.IGNORECASE)
|
|
161
152
|
for pattern in COMPLETION_PATTERNS
|
|
162
153
|
)
|
|
163
154
|
|
|
164
155
|
if not is_completion:
|
|
165
|
-
|
|
156
|
+
return None
|
|
166
157
|
|
|
167
158
|
# Find mentioned files to determine context
|
|
168
159
|
file_matches = re.findall(FILENAME_PATTERN, message)
|
|
@@ -175,13 +166,11 @@ def main():
|
|
|
175
166
|
filename = fname
|
|
176
167
|
break
|
|
177
168
|
|
|
178
|
-
# Get appropriate next steps
|
|
179
169
|
if ext and ext in NEXT_STEPS:
|
|
180
170
|
steps = NEXT_STEPS[ext]
|
|
181
171
|
else:
|
|
182
172
|
steps = NEXT_STEPS["default"]
|
|
183
173
|
|
|
184
|
-
# Format steps with filename if available
|
|
185
174
|
formatted = []
|
|
186
175
|
for step in steps[:3]:
|
|
187
176
|
if filename:
|
|
@@ -189,20 +178,30 @@ def main():
|
|
|
189
178
|
else:
|
|
190
179
|
formatted.append(step.replace(" {filename}", "").replace("{filename}", "the file"))
|
|
191
180
|
|
|
192
|
-
if not stop_lock_acquire(session_id):
|
|
193
|
-
sys.exit(0)
|
|
194
|
-
|
|
195
181
|
state["count"] += 1
|
|
196
182
|
save_state(session_id, state)
|
|
197
183
|
|
|
198
184
|
suggestion_text = "Claudia: What's next? Here are some ideas:\n" + "\n".join(
|
|
199
185
|
f" - {step}" for step in formatted
|
|
200
186
|
)
|
|
201
|
-
|
|
187
|
+
return {
|
|
202
188
|
"additionalContext": suggestion_text,
|
|
203
|
-
"systemMessage": f"\033[38;5;
|
|
204
|
-
}
|
|
205
|
-
|
|
189
|
+
"systemMessage": f"\033[38;5;160m{suggestion_text}\033[0m",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main():
|
|
194
|
+
try:
|
|
195
|
+
input_data = json.loads(sys.stdin.read())
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
sys.exit(0)
|
|
198
|
+
|
|
199
|
+
_, experience = load_user_config()
|
|
200
|
+
session_id = input_data.get("session_id", "default")
|
|
201
|
+
result = check(input_data, None, experience)
|
|
202
|
+
if result and stop_lock_acquire(session_id):
|
|
203
|
+
print(json.dumps(result))
|
|
204
|
+
|
|
206
205
|
sys.exit(0)
|
|
207
206
|
|
|
208
207
|
|
|
@@ -18,12 +18,19 @@ STUCK_PATTERNS = [
|
|
|
18
18
|
r'^(help|stuck|idk|i don\'?t know|confused|lost)\s*[.!?]*$',
|
|
19
19
|
r'^(what do i do|where do i start|how do i even|i\'?m stuck|i\'?m lost|i\'?m confused)\s*[.!?]*$',
|
|
20
20
|
r'^(i have no idea|no clue|what now|now what)\s*[.!?]*$',
|
|
21
|
+
r'^(i give up|this is impossible|nothing works|everything is broken)\s*[.!?]*$',
|
|
22
|
+
r'^(can you help|please help|help me)\s*[.!?]*$',
|
|
23
|
+
r'^(i don\'?t understand|i don\'?t get it|makes no sense)\s*[.!?]*$',
|
|
24
|
+
r'^(where am i|what happened|what went wrong)\s*[.!?]*$',
|
|
21
25
|
]
|
|
22
26
|
|
|
23
27
|
# Vague prompt patterns
|
|
24
28
|
VAGUE_PATTERNS = [
|
|
25
29
|
(r'^(fix it|fix this|make it work|help me|do it|just do it)\s*[.!?]*$', "no-context"),
|
|
26
30
|
(r'^(it\'?s? broken|doesn\'?t work|not working|it broke|broken)\s*[.!?]*$', "no-context"),
|
|
31
|
+
(r'^(change it|update it|redo it|do it again|try again)\s*[.!?]*$', "no-context"),
|
|
32
|
+
(r'^(it\'?s? wrong|that\'?s wrong|wrong|bad|no good)\s*[.!?]*$', "no-context"),
|
|
33
|
+
(r'^(make it better|improve it|clean it up)\s*[.!?]*$', "no-context"),
|
|
27
34
|
(r'^(what|why|how)\s*[.!?]*$', "too-short"),
|
|
28
35
|
(r'^(yes|no|ok|okay|sure|yeah|yep|nah|nope)\s*[.!?]*$', "single-word"),
|
|
29
36
|
]
|
|
@@ -153,12 +160,21 @@ def main():
|
|
|
153
160
|
)
|
|
154
161
|
user_msg = "Claudia noticed you might be frustrated. She's telling Claude to slow down and help."
|
|
155
162
|
|
|
163
|
+
# Check for repeated punctuation (frustration/emphasis)
|
|
164
|
+
if not coaching_note and re.search(r'[!?]{3,}', prompt):
|
|
165
|
+
coaching_note = (
|
|
166
|
+
"Claudia note: The user seems emphatic or frustrated. Take a step back — "
|
|
167
|
+
"summarize what you understand about their problem, confirm you're on the same page, "
|
|
168
|
+
"then propose ONE concrete next step."
|
|
169
|
+
)
|
|
170
|
+
user_msg = "Claudia is telling Claude to check in with you before continuing."
|
|
171
|
+
|
|
156
172
|
if coaching_note:
|
|
157
173
|
state["count"] += 1
|
|
158
174
|
save_state(session_id, state)
|
|
159
175
|
result = {"additionalContext": coaching_note}
|
|
160
176
|
if user_msg:
|
|
161
|
-
result["systemMessage"] = f"\033[38;5;
|
|
177
|
+
result["systemMessage"] = f"\033[38;5;160m{user_msg}\033[0m"
|
|
162
178
|
print(json.dumps(result))
|
|
163
179
|
|
|
164
180
|
sys.exit(0)
|
|
@@ -83,40 +83,29 @@ def load_config():
|
|
|
83
83
|
return load_user_config()
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
input_data = json.loads(sys.stdin.read())
|
|
89
|
-
except json.JSONDecodeError:
|
|
90
|
-
sys.exit(0)
|
|
91
|
-
|
|
86
|
+
def check(input_data, proactivity, experience):
|
|
87
|
+
"""Run run-suggest logic. Returns output dict or None."""
|
|
92
88
|
session_id = input_data.get("session_id", "default")
|
|
93
89
|
message = input_data.get("last_assistant_message", "")
|
|
94
90
|
|
|
95
91
|
if not message:
|
|
96
|
-
|
|
92
|
+
return None
|
|
97
93
|
|
|
98
|
-
proactivity, experience = load_config()
|
|
99
94
|
is_beginner = experience == "beginner"
|
|
100
|
-
|
|
101
|
-
# Gate: beginner OR high proactivity
|
|
102
95
|
if not is_beginner and proactivity != "high":
|
|
103
|
-
|
|
96
|
+
return None
|
|
104
97
|
|
|
105
98
|
state = load_state(session_id)
|
|
106
99
|
shown_types = set(state.get("shown_types", []))
|
|
107
100
|
|
|
108
101
|
# Check for package.json mentions
|
|
109
102
|
if "package.json" not in shown_types and re.search(PACKAGE_JSON_PATTERN, message, re.IGNORECASE):
|
|
110
|
-
if not stop_lock_acquire(session_id):
|
|
111
|
-
sys.exit(0)
|
|
112
103
|
shown_types.add("package.json")
|
|
113
104
|
state["shown_types"] = list(shown_types)
|
|
114
105
|
save_state(session_id, state)
|
|
115
106
|
suggestion = RUN_SUGGESTIONS["package.json"][1]
|
|
116
107
|
msg = f"Claudia: {suggestion}"
|
|
117
|
-
|
|
118
|
-
print(output)
|
|
119
|
-
sys.exit(0)
|
|
108
|
+
return {"additionalContext": msg, "systemMessage": f"\033[38;5;160m{msg}\033[0m"}
|
|
120
109
|
|
|
121
110
|
# Check for file creation patterns
|
|
122
111
|
for pattern in FILE_PATTERNS:
|
|
@@ -125,16 +114,27 @@ def main():
|
|
|
125
114
|
filename = match.group(1)
|
|
126
115
|
ext = match.group(2).lower()
|
|
127
116
|
if ext in RUN_SUGGESTIONS and ext not in shown_types:
|
|
128
|
-
if not stop_lock_acquire(session_id):
|
|
129
|
-
sys.exit(0)
|
|
130
117
|
shown_types.add(ext)
|
|
131
118
|
state["shown_types"] = list(shown_types)
|
|
132
119
|
save_state(session_id, state)
|
|
133
120
|
suggestion = RUN_SUGGESTIONS[ext][1].format(filename=filename)
|
|
134
121
|
msg = f"Claudia: {suggestion}"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
122
|
+
return {"additionalContext": msg, "systemMessage": f"\033[38;5;160m{msg}\033[0m"}
|
|
123
|
+
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main():
|
|
128
|
+
try:
|
|
129
|
+
input_data = json.loads(sys.stdin.read())
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
|
|
133
|
+
proactivity, experience = load_config()
|
|
134
|
+
session_id = input_data.get("session_id", "default")
|
|
135
|
+
result = check(input_data, proactivity, experience)
|
|
136
|
+
if result and stop_lock_acquire(session_id):
|
|
137
|
+
print(json.dumps(result))
|
|
138
138
|
|
|
139
139
|
sys.exit(0)
|
|
140
140
|
|
|
@@ -253,7 +253,7 @@ def main():
|
|
|
253
253
|
visible_parts.append(tip)
|
|
254
254
|
if visible_parts:
|
|
255
255
|
msg = " | ".join(visible_parts) if greeting and tip else visible_parts[0]
|
|
256
|
-
result["systemMessage"] = f"\033[38;5;
|
|
256
|
+
result["systemMessage"] = f"\033[38;5;160m{msg}\033[0m"
|
|
257
257
|
print(json.dumps(result))
|
|
258
258
|
|
|
259
259
|
sys.exit(0)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claudia: claudia-stop-dispatch.py
|
|
4
|
+
Single dispatcher for all Stop hooks. Runs milestones, run-suggest,
|
|
5
|
+
next-steps, and teach in one process instead of 4 subprocesses.
|
|
6
|
+
First hook with output wins.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
from claudia_config import load_user_config
|
|
16
|
+
|
|
17
|
+
# Import check() from each Stop hook module
|
|
18
|
+
import importlib
|
|
19
|
+
|
|
20
|
+
def _import_hook(name):
|
|
21
|
+
"""Import a hook module by filename (without .py)."""
|
|
22
|
+
return importlib.import_module(name.replace("-", "_").replace(".py", ""))
|
|
23
|
+
|
|
24
|
+
# Hook execution order (matches original hooks.json order)
|
|
25
|
+
HOOK_MODULES = [
|
|
26
|
+
"claudia-milestones",
|
|
27
|
+
"claudia-run-suggest",
|
|
28
|
+
"claudia-next-steps",
|
|
29
|
+
"claudia-teach",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stop_lock_acquire(session_id):
|
|
34
|
+
"""Try to acquire the per-turn Stop hook lock. Returns True if acquired."""
|
|
35
|
+
lock_file = os.path.expanduser(f"~/.claude/claudia_stop_lock_{session_id}.tmp")
|
|
36
|
+
now = time.time()
|
|
37
|
+
try:
|
|
38
|
+
if os.path.exists(lock_file):
|
|
39
|
+
with open(lock_file) as f:
|
|
40
|
+
ts = float(f.read().strip())
|
|
41
|
+
if now - ts < 2.0:
|
|
42
|
+
return False
|
|
43
|
+
os.makedirs(os.path.dirname(lock_file), exist_ok=True)
|
|
44
|
+
with open(lock_file, "w") as f:
|
|
45
|
+
f.write(str(now))
|
|
46
|
+
return True
|
|
47
|
+
except (IOError, ValueError):
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
try:
|
|
53
|
+
input_data = json.loads(sys.stdin.read())
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
session_id = input_data.get("session_id", "default")
|
|
58
|
+
proactivity, experience = load_user_config()
|
|
59
|
+
|
|
60
|
+
# Try each hook in order; first with output wins
|
|
61
|
+
for module_name in HOOK_MODULES:
|
|
62
|
+
try:
|
|
63
|
+
mod = _import_hook(module_name)
|
|
64
|
+
result = mod.check(input_data, proactivity, experience)
|
|
65
|
+
if result:
|
|
66
|
+
if stop_lock_acquire(session_id):
|
|
67
|
+
print(json.dumps(result))
|
|
68
|
+
sys.exit(0)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Don't let one broken hook kill the others
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
|
@@ -36,15 +36,28 @@ KEYWORDS = {
|
|
|
36
36
|
"Supabase": "an open-source Firebase alternative built on Postgres",
|
|
37
37
|
"PlanetScale": "a serverless MySQL platform with branching",
|
|
38
38
|
"Prisma": "a TypeScript ORM that generates type-safe database queries",
|
|
39
|
+
"Drizzle": "a lightweight TypeScript ORM with SQL-like syntax",
|
|
40
|
+
"Turso": "an edge-hosted SQLite database",
|
|
41
|
+
"Neon": "serverless Postgres with branching and autoscaling",
|
|
42
|
+
"Pinecone": "a vector database for AI/ML similarity search",
|
|
43
|
+
"DynamoDB": "AWS's serverless NoSQL database",
|
|
44
|
+
"Firestore": "Google's serverless document database (part of Firebase)",
|
|
39
45
|
},
|
|
40
46
|
"frameworks": {
|
|
41
47
|
"Next.js": "a React framework for building full-stack web apps",
|
|
42
48
|
"React": "a JavaScript library for building user interfaces",
|
|
43
49
|
"Vue": "a progressive JavaScript framework for building UIs",
|
|
44
50
|
"Svelte": "a compiler that turns components into efficient JavaScript",
|
|
51
|
+
"SvelteKit": "a full-stack framework built on Svelte",
|
|
45
52
|
"Astro": "a framework for building content-focused websites",
|
|
46
53
|
"Express": "a minimal Node.js web framework for building APIs",
|
|
47
54
|
"FastAPI": "a modern Python web framework for building APIs",
|
|
55
|
+
"Django": "a batteries-included Python web framework",
|
|
56
|
+
"Flask": "a lightweight Python web framework",
|
|
57
|
+
"Remix": "a full-stack React framework focused on web standards",
|
|
58
|
+
"Nuxt": "a full-stack Vue framework (like Next.js but for Vue)",
|
|
59
|
+
"Hono": "an ultrafast web framework that runs anywhere (Cloudflare, Deno, Bun)",
|
|
60
|
+
"tRPC": "end-to-end typesafe APIs without code generation",
|
|
48
61
|
},
|
|
49
62
|
"tools": {
|
|
50
63
|
"Docker": "a tool for packaging apps into containers that run anywhere",
|
|
@@ -53,6 +66,15 @@ KEYWORDS = {
|
|
|
53
66
|
"GitHub Actions": "CI/CD automation built into GitHub",
|
|
54
67
|
"Webpack": "a module bundler for JavaScript applications",
|
|
55
68
|
"Vite": "a fast build tool and dev server for modern web projects",
|
|
69
|
+
"Bun": "an all-in-one JavaScript runtime, bundler, and package manager",
|
|
70
|
+
"Deno": "a secure JavaScript/TypeScript runtime by Node's creator",
|
|
71
|
+
"pnpm": "a fast, disk-efficient package manager for Node.js",
|
|
72
|
+
"Turborepo": "a build system for JavaScript/TypeScript monorepos",
|
|
73
|
+
"ESLint": "a tool for finding and fixing problems in JavaScript code",
|
|
74
|
+
"Prettier": "an opinionated code formatter",
|
|
75
|
+
"Tailwind": "a utility-first CSS framework",
|
|
76
|
+
"Playwright": "a browser automation and testing framework",
|
|
77
|
+
"Vitest": "a fast unit testing framework powered by Vite",
|
|
56
78
|
},
|
|
57
79
|
"concepts": {
|
|
58
80
|
"API": "Application Programming Interface — how programs talk to each other",
|
|
@@ -64,6 +86,20 @@ KEYWORDS = {
|
|
|
64
86
|
"CI/CD": "Continuous Integration/Delivery — automating testing and deployment",
|
|
65
87
|
"SSR": "Server-Side Rendering — generating HTML on the server for each request",
|
|
66
88
|
"SSG": "Static Site Generation — pre-building HTML pages at build time",
|
|
89
|
+
"ISR": "Incremental Static Regeneration — rebuilding static pages on demand",
|
|
90
|
+
"ORM": "Object-Relational Mapping — lets you query databases using code instead of SQL",
|
|
91
|
+
"CORS": "Cross-Origin Resource Sharing — controls which sites can call your API",
|
|
92
|
+
"CSP": "Content Security Policy — tells browsers what resources your page can load",
|
|
93
|
+
"CSRF": "Cross-Site Request Forgery — an attack that tricks users into unwanted actions",
|
|
94
|
+
"XSS": "Cross-Site Scripting — an attack that injects malicious scripts into web pages",
|
|
95
|
+
"CDN": "Content Delivery Network — serves your files from servers close to users",
|
|
96
|
+
"DNS": "Domain Name System — translates domain names to IP addresses",
|
|
97
|
+
"TLS": "Transport Layer Security — encrypts data in transit (the S in HTTPS)",
|
|
98
|
+
"WASM": "WebAssembly — lets you run compiled code in the browser at near-native speed",
|
|
99
|
+
"Edge Functions": "serverless functions that run close to users at CDN edge locations",
|
|
100
|
+
"Middleware": "code that runs between a request and response, often for auth or logging",
|
|
101
|
+
"Monorepo": "a single repository containing multiple projects or packages",
|
|
102
|
+
"Microservices": "an architecture where an app is split into small, independent services",
|
|
67
103
|
},
|
|
68
104
|
}
|
|
69
105
|
|
|
@@ -208,30 +244,21 @@ def load_config():
|
|
|
208
244
|
return load_user_config()
|
|
209
245
|
|
|
210
246
|
|
|
211
|
-
def
|
|
212
|
-
|
|
213
|
-
input_data = json.loads(sys.stdin.read())
|
|
214
|
-
except json.JSONDecodeError:
|
|
215
|
-
sys.exit(0)
|
|
216
|
-
|
|
247
|
+
def check(input_data, proactivity, experience):
|
|
248
|
+
"""Run teach logic. Returns output dict or None."""
|
|
217
249
|
session_id = input_data.get("session_id", "default")
|
|
218
250
|
message = input_data.get("last_assistant_message", "")
|
|
219
251
|
|
|
220
252
|
if not message:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
# Check proactivity and experience
|
|
224
|
-
proactivity, experience = load_config()
|
|
253
|
+
return None
|
|
225
254
|
|
|
226
|
-
# Only fire on moderate or high proactivity
|
|
227
255
|
if proactivity == "low":
|
|
228
|
-
|
|
256
|
+
return None
|
|
229
257
|
|
|
230
258
|
is_beginner = experience == "beginner"
|
|
231
259
|
|
|
232
|
-
# Non-beginners only get teaching on high proactivity
|
|
233
260
|
if not is_beginner and proactivity != "high":
|
|
234
|
-
|
|
261
|
+
return None
|
|
235
262
|
|
|
236
263
|
state = load_state(session_id)
|
|
237
264
|
shown_keywords = set(state["shown_keywords"])
|
|
@@ -241,7 +268,6 @@ def main():
|
|
|
241
268
|
# Scan for technology keywords
|
|
242
269
|
for category, keywords in KEYWORDS.items():
|
|
243
270
|
for keyword, description in keywords.items():
|
|
244
|
-
# Word-boundary match, case-insensitive
|
|
245
271
|
pattern = r'\b' + re.escape(keyword) + r'\b'
|
|
246
272
|
if re.search(pattern, message, re.IGNORECASE):
|
|
247
273
|
if keyword.lower() not in shown_keywords:
|
|
@@ -250,7 +276,6 @@ def main():
|
|
|
250
276
|
f"I noticed we're talking about {keyword} ({description}). "
|
|
251
277
|
f"Want me to explain more? Just say `/claudia:explain {keyword.lower()}`"
|
|
252
278
|
)
|
|
253
|
-
# Only one keyword tip per response to avoid noise
|
|
254
279
|
break
|
|
255
280
|
if tips:
|
|
256
281
|
break
|
|
@@ -279,7 +304,6 @@ def main():
|
|
|
279
304
|
revealed_commands.add(command)
|
|
280
305
|
tips.append(reveal["tip"])
|
|
281
306
|
break
|
|
282
|
-
# Only one command reveal per response
|
|
283
307
|
if len(tips) > (1 if tips else 0):
|
|
284
308
|
break
|
|
285
309
|
|
|
@@ -287,19 +311,30 @@ def main():
|
|
|
287
311
|
state["shown_keywords"] = list(shown_keywords)
|
|
288
312
|
state["revealed_commands"] = list(revealed_commands)
|
|
289
313
|
save_state(session_id, state)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"systemMessage": colored,
|
|
296
|
-
})
|
|
297
|
-
print(output)
|
|
298
|
-
elif shown_keywords != set(state["shown_keywords"]) or revealed_commands != set(state["revealed_commands"]):
|
|
314
|
+
tip_text = "\n".join(f"Claudia: {tip}" for tip in tips)
|
|
315
|
+
colored = "\n".join(f"\033[38;5;160m{line}\033[0m" for line in tip_text.split("\n"))
|
|
316
|
+
return {"additionalContext": tip_text, "systemMessage": colored}
|
|
317
|
+
|
|
318
|
+
if shown_keywords != set(state["shown_keywords"]) or revealed_commands != set(state["revealed_commands"]):
|
|
299
319
|
state["shown_keywords"] = list(shown_keywords)
|
|
300
320
|
state["revealed_commands"] = list(revealed_commands)
|
|
301
321
|
save_state(session_id, state)
|
|
302
322
|
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main():
|
|
327
|
+
try:
|
|
328
|
+
input_data = json.loads(sys.stdin.read())
|
|
329
|
+
except json.JSONDecodeError:
|
|
330
|
+
sys.exit(0)
|
|
331
|
+
|
|
332
|
+
proactivity, experience = load_config()
|
|
333
|
+
session_id = input_data.get("session_id", "default")
|
|
334
|
+
result = check(input_data, proactivity, experience)
|
|
335
|
+
if result and stop_lock_acquire(session_id):
|
|
336
|
+
print(json.dumps(result))
|
|
337
|
+
|
|
303
338
|
sys.exit(0)
|
|
304
339
|
|
|
305
340
|
|
package/package.json
CHANGED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# Claudia: check-secrets.sh
|
|
4
|
-
# PreToolUse hook that blocks hardcoded secrets in Edit/Write/MultiEdit operations.
|
|
5
|
-
# Exit 2 = block, Exit 0 = allow.
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
set -euo pipefail
|
|
9
|
-
|
|
10
|
-
# Read JSON input from stdin
|
|
11
|
-
INPUT=$(cat)
|
|
12
|
-
|
|
13
|
-
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
|
|
14
|
-
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','default'))" 2>/dev/null || echo "default")
|
|
15
|
-
|
|
16
|
-
# Extract content to scan based on tool type
|
|
17
|
-
CONTENT=$(echo "$INPUT" | python3 -c "
|
|
18
|
-
import sys, json
|
|
19
|
-
data = json.load(sys.stdin)
|
|
20
|
-
tool = data.get('tool_name', '')
|
|
21
|
-
ti = data.get('tool_input', {})
|
|
22
|
-
if tool == 'Write':
|
|
23
|
-
print(ti.get('content', ''))
|
|
24
|
-
elif tool == 'Edit':
|
|
25
|
-
print(ti.get('new_string', ''))
|
|
26
|
-
elif tool == 'MultiEdit':
|
|
27
|
-
edits = ti.get('edits', [])
|
|
28
|
-
print(' '.join(e.get('new_string', '') for e in edits))
|
|
29
|
-
" 2>/dev/null || echo "")
|
|
30
|
-
|
|
31
|
-
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
|
|
32
|
-
|
|
33
|
-
# Skip if no content to check
|
|
34
|
-
if [ -z "$CONTENT" ]; then
|
|
35
|
-
exit 0
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
# Skip test files and example/fixture files
|
|
39
|
-
case "$FILE_PATH" in
|
|
40
|
-
*test*|*spec*|*fixture*|*mock*|*.example*|*.sample*|*.md)
|
|
41
|
-
exit 0
|
|
42
|
-
;;
|
|
43
|
-
esac
|
|
44
|
-
|
|
45
|
-
# State file for session-aware dedup
|
|
46
|
-
STATE_DIR="$HOME/.claude"
|
|
47
|
-
STATE_FILE="$STATE_DIR/claudia_secrets_state_${SESSION_ID}.json"
|
|
48
|
-
|
|
49
|
-
# Secret patterns to check
|
|
50
|
-
# Each pattern: "REGEX_PATTERN|DESCRIPTION|SECRET_TYPE"
|
|
51
|
-
PATTERNS=(
|
|
52
|
-
'AKIA[0-9A-Z]{16}|AWS Access Key ID detected|aws_key'
|
|
53
|
-
'[0-9a-zA-Z/+]{40}(?=.*AWS)|AWS Secret Access Key detected|aws_secret'
|
|
54
|
-
'sk-[a-zA-Z0-9]{20,}|OpenAI/Stripe-style secret key detected|sk_key'
|
|
55
|
-
'ghp_[a-zA-Z0-9]{36}|GitHub personal access token detected|github_pat'
|
|
56
|
-
'gho_[a-zA-Z0-9]{36}|GitHub OAuth token detected|github_oauth'
|
|
57
|
-
'glpat-[a-zA-Z0-9\-]{20,}|GitLab personal access token detected|gitlab_pat'
|
|
58
|
-
'xox[bpras]-[a-zA-Z0-9\-]{10,}|Slack token detected|slack_token'
|
|
59
|
-
'-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----|Private key detected|private_key'
|
|
60
|
-
'password\s*[=:]\s*["\x27][^"\x27]{8,}["\x27]|Hardcoded password detected|password'
|
|
61
|
-
'secret\s*[=:]\s*["\x27][^"\x27]{8,}["\x27]|Hardcoded secret detected|secret'
|
|
62
|
-
'api[_-]?key\s*[=:]\s*["\x27][a-zA-Z0-9]{16,}["\x27]|Hardcoded API key detected|api_key'
|
|
63
|
-
'mongodb(\+srv)?://[^/\s]+:[^@\s]+@|MongoDB connection string with credentials|mongo_uri'
|
|
64
|
-
'postgres(ql)?://[^/\s]+:[^@\s]+@|PostgreSQL connection string with credentials|pg_uri'
|
|
65
|
-
'mysql://[^/\s]+:[^@\s]+@|MySQL connection string with credentials|mysql_uri'
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
# Check each pattern
|
|
69
|
-
for pattern_line in "${PATTERNS[@]}"; do
|
|
70
|
-
IFS='|' read -r regex description secret_type <<< "$pattern_line"
|
|
71
|
-
|
|
72
|
-
if echo "$CONTENT" | grep -qP "$regex" 2>/dev/null || echo "$CONTENT" | grep -qE "$regex" 2>/dev/null; then
|
|
73
|
-
# Check dedup state
|
|
74
|
-
WARNING_KEY="${FILE_PATH}-${secret_type}"
|
|
75
|
-
|
|
76
|
-
if [ -f "$STATE_FILE" ]; then
|
|
77
|
-
if grep -q "\"${WARNING_KEY}\"" "$STATE_FILE" 2>/dev/null; then
|
|
78
|
-
# Already warned in this session, allow
|
|
79
|
-
exit 0
|
|
80
|
-
fi
|
|
81
|
-
fi
|
|
82
|
-
|
|
83
|
-
# Record this warning
|
|
84
|
-
mkdir -p "$STATE_DIR"
|
|
85
|
-
if [ -f "$STATE_FILE" ]; then
|
|
86
|
-
# Append to existing state
|
|
87
|
-
python3 -c "
|
|
88
|
-
import json
|
|
89
|
-
try:
|
|
90
|
-
with open('$STATE_FILE') as f:
|
|
91
|
-
state = json.load(f)
|
|
92
|
-
except:
|
|
93
|
-
state = []
|
|
94
|
-
state.append('$WARNING_KEY')
|
|
95
|
-
with open('$STATE_FILE', 'w') as f:
|
|
96
|
-
json.dump(state, f)
|
|
97
|
-
" 2>/dev/null || true
|
|
98
|
-
else
|
|
99
|
-
echo "[\"${WARNING_KEY}\"]" > "$STATE_FILE"
|
|
100
|
-
fi
|
|
101
|
-
|
|
102
|
-
# Block with explanation (vermillion colored)
|
|
103
|
-
C="\033[38;5;209m"
|
|
104
|
-
R="\033[0m"
|
|
105
|
-
echo -e "${C}Claudia: ${description} in ${FILE_PATH}.${R}" >&2
|
|
106
|
-
echo "" >&2
|
|
107
|
-
echo -e "${C}Secrets should never be hardcoded in source files. Use:" >&2
|
|
108
|
-
echo " - Environment variables (.env file, gitignored)" >&2
|
|
109
|
-
echo " - A secret manager (AWS SSM, Vault, Doppler)" >&2
|
|
110
|
-
echo -e " - CI/CD secrets for pipelines${R}" >&2
|
|
111
|
-
echo "" >&2
|
|
112
|
-
echo -e "${C}If this is intentionally a test fixture or example, rename the file to include 'test', 'example', or 'fixture'.${R}" >&2
|
|
113
|
-
exit 2
|
|
114
|
-
fi
|
|
115
|
-
done
|
|
116
|
-
|
|
117
|
-
# No secrets found
|
|
118
|
-
exit 0
|