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 CHANGED
@@ -1,8 +1,10 @@
1
1
  # Claudia: Proactive Mentor Plugin for Claude Code
2
2
 
3
+ [![Tests](https://github.com/reganomalley/claudia/actions/workflows/test.yml/badge.svg)](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": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/check-secrets.sh",
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/claudia-run-suggest.py",
79
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/check-css.py",
91
80
  "timeout": 10
92
81
  }
93
- ]
94
- },
95
- {
96
- "hooks": [
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-teach.py",
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;209m{message}\033[0m"})
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;209m{message}\033[0m"})
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;209m{message}\033[0m"})
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;209m{message}\033[0m"})
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;209m{message}\033[0m"})
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;209m{message}\033[0m"})
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;209m{tip}\033[0m",
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 main():
119
- try:
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
- sys.exit(0)
128
-
129
- experience = load_config()
123
+ return None
130
124
 
131
- # Gate: beginner only
132
125
  if experience != "beginner":
133
- sys.exit(0)
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
- session_id = input_data.get("session_id", "default")
166
- if stop_lock_acquire(session_id):
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
- elif new_files > 0:
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 main():
135
- try:
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
- sys.exit(0)
145
-
146
- experience = load_config()
140
+ return None
147
141
 
148
- # Gate: beginner only
149
142
  if experience != "beginner":
150
- sys.exit(0)
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
- sys.exit(0)
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
- sys.exit(0)
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
- output = json.dumps({
187
+ return {
202
188
  "additionalContext": suggestion_text,
203
- "systemMessage": f"\033[38;5;209m{suggestion_text}\033[0m",
204
- })
205
- print(output)
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;209m{user_msg}\033[0m"
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 main():
87
- try:
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
- sys.exit(0)
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
- sys.exit(0)
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
- output = json.dumps({"additionalContext": msg, "systemMessage": f"\033[38;5;209m{msg}\033[0m"})
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
- output = json.dumps({"additionalContext": msg, "systemMessage": f"\033[38;5;209m{msg}\033[0m"})
136
- print(output)
137
- sys.exit(0)
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;209m{msg}\033[0m"
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 main():
212
- try:
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
- sys.exit(0)
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
- sys.exit(0)
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
- sys.exit(0)
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
- if stop_lock_acquire(session_id):
291
- tip_text = "\n".join(f"Claudia: {tip}" for tip in tips)
292
- colored = "\n".join(f"\033[38;5;209m{line}\033[0m" for line in tip_text.split("\n"))
293
- output = json.dumps({
294
- "additionalContext": tip_text,
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,6 +1,6 @@
1
1
  {
2
2
  "name": "claudia-mentor",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Proactive technology mentor, security advisor, and prompt coach for Claude Code",
5
5
  "author": "Regan O'Malley <regan@reganomalley.com>",
6
6
  "license": "MIT",
@@ -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