cortexhawk 3.2.0 → 3.3.1

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/mcp/README.md CHANGED
@@ -15,6 +15,7 @@ cp mcp/context7.json .mcp.json
15
15
 
16
16
  | Server | Purpose | Requires |
17
17
  |---|---|---|
18
+ | `github.json` | GitHub API — PRs, issues, reviews, repos | npx + `GITHUB_PERSONAL_ACCESS_TOKEN` env |
18
19
  | `context7.json` | Library docs lookup via Context7 | npx |
19
20
  | `sequential-thinking.json` | Step-by-step reasoning for complex problems | npx |
20
21
  | `puppeteer.json` | Browser automation and testing | npx |
@@ -35,3 +36,38 @@ Create or edit `.mcp.json` at your project root:
35
36
  ```
36
37
 
37
38
  Then restart Claude Code to activate.
39
+
40
+ ## GitHub Token Setup
41
+
42
+ `github.json` requires a `GITHUB_PERSONAL_ACCESS_TOKEN`. Options (pick one):
43
+
44
+ **Option 1 — `gh auth token` (recommended, zero extra credentials)**
45
+ ```bash
46
+ # ~/.zshrc or ~/.bashrc
47
+ export GITHUB_PERSONAL_ACCESS_TOKEN=$(gh auth token)
48
+ ```
49
+ Reuses existing `gh` CLI auth — nothing new to manage.
50
+
51
+ **Option 2 — `.claude/settings.json` (project-level, gitignored)**
52
+ ```json
53
+ {
54
+ "env": {
55
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxx"
56
+ }
57
+ }
58
+ ```
59
+
60
+ **Option 3 — Shell profile (direct)**
61
+ ```bash
62
+ export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxx
63
+ ```
64
+
65
+ **Option 4 — `direnv` (`.envrc`, add to `.gitignore`)**
66
+ ```bash
67
+ export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxx
68
+ ```
69
+
70
+ **Option 5 — Password manager CLI**
71
+ ```bash
72
+ export GITHUB_PERSONAL_ACCESS_TOKEN=$(op read "op://vault/github-pat/token")
73
+ ```
package/mcp/context7.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "context7": {
4
4
  "command": "npx",
5
- "args": ["-y", "@upstash/context7-mcp@latest"]
5
+ "args": ["-y", "@upstash/context7-mcp@2.1.1"]
6
6
  }
7
7
  }
8
8
  }
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "github": {
4
+ "command": "npx",
5
+ "args": ["-y", "@modelcontextprotocol/server-github@2025.4.8"],
6
+ "env": {
7
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "puppeteer": {
4
4
  "command": "npx",
5
- "args": ["-y", "@anthropic-ai/mcp-server-puppeteer"]
5
+ "args": ["-y", "@modelcontextprotocol/server-puppeteer@2025.5.12"]
6
6
  }
7
7
  }
8
8
  }
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "sequential-thinking": {
4
4
  "command": "npx",
5
- "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
5
+ "args": ["-y", "@modelcontextprotocol/server-sequential-thinking@2025.12.18"]
6
6
  }
7
7
  }
8
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortexhawk",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "Open-source development toolkit for Claude Code — optimized agents, skills, commands, hooks, and modes",
5
5
  "bin": {
6
6
  "cortexhawk": "./cortexhawk"
package/profiles/api.json CHANGED
@@ -23,5 +23,6 @@
23
23
  "workflow/commit",
24
24
  "workflow/confidence-check",
25
25
  "workflow/pr-review-comments"
26
- ]
26
+ ],
27
+ "mcp": ["github", "sequential-thinking"]
27
28
  }
@@ -23,5 +23,6 @@
23
23
  "workflow/commit",
24
24
  "workflow/confidence-check",
25
25
  "workflow/pr-review-comments"
26
- ]
26
+ ],
27
+ "mcp": ["github", "context7", "sequential-thinking"]
27
28
  }
@@ -46,7 +46,7 @@ for skill_file in "$CORTEXHAWK_DIR"/skills/*/*/SKILL.md; do
46
46
  done
47
47
 
48
48
  # --- Generate JSON ---
49
- PROFILE_FILE="/tmp/cortexhawk-autodetect-$$.json"
49
+ PROFILE_FILE=$(mktemp /tmp/cortexhawk-autodetect-XXXXXX)
50
50
  SKILL_JSON=$(echo "$SKILLS" | tr ',' '\n' | sed 's/.*/ "&"/' | paste -sd ',' - | sed 's/,/,\n/g')
51
51
  cat > "$PROFILE_FILE" << EOF
52
52
  {
@@ -0,0 +1,164 @@
1
+ #!/bin/bash
2
+ # doctor.sh — CortexHawk installation health diagnostics
3
+ # Sourced by install.sh when --doctor is used
4
+ # Uses shared functions: get_version, green, yellow
5
+ # Uses globals: GLOBAL, TARGET, SCRIPT_DIR
6
+
7
+ do_doctor() {
8
+ if [ "$GLOBAL" = true ]; then
9
+ TARGET="$HOME/.claude"
10
+ else
11
+ TARGET="$(pwd)/.claude"
12
+ fi
13
+
14
+ local ok=0 warn=0 err=0
15
+ _doc_ok() { echo " [OK] $1"; ok=$((ok+1)); }
16
+ _doc_warn() { echo " [WARN] $1"; warn=$((warn+1)); }
17
+ _doc_err() { echo " [ERR] $1"; err=$((err+1)); }
18
+
19
+ # Header
20
+ local version="" profile="" target_cli_name=""
21
+ if [ -f "$TARGET/.cortexhawk-manifest" ]; then
22
+ version=$(grep -o '"version": "[^"]*"' "$TARGET/.cortexhawk-manifest" | head -1 | cut -d'"' -f4)
23
+ profile=$(grep -o '"profile": "[^"]*"' "$TARGET/.cortexhawk-manifest" | head -1 | cut -d'"' -f4)
24
+ target_cli_name=$(grep -o '"target": "[^"]*"' "$TARGET/.cortexhawk-manifest" | head -1 | cut -d'"' -f4)
25
+ fi
26
+ echo "CortexHawk Doctor"
27
+ echo "==================="
28
+ echo " Installation: $TARGET"
29
+ echo " Version: ${version:-unknown}"
30
+ echo " Profile: ${profile:-unknown}"
31
+ echo " Target: ${target_cli_name:-claude}"
32
+ echo ""
33
+ echo "Checks:"
34
+
35
+ # 1. Manifest
36
+ if [ -f "$TARGET/.cortexhawk-manifest" ]; then
37
+ _doc_ok "Manifest present"
38
+ else
39
+ _doc_err "Manifest missing — run install.sh to create an installation"
40
+ fi
41
+
42
+ # 2. settings.json valid JSON
43
+ if [ -f "$TARGET/settings.json" ]; then
44
+ if command -v python3 >/dev/null 2>&1; then
45
+ if python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$TARGET/settings.json" 2>/dev/null; then
46
+ _doc_ok "settings.json valid JSON"
47
+ else
48
+ _doc_err "settings.json invalid JSON"
49
+ fi
50
+ else
51
+ _doc_warn "settings.json present (python3 not available for validation)"
52
+ fi
53
+ else
54
+ _doc_warn "settings.json not found"
55
+ fi
56
+
57
+ # 3. Component counts (compare installed vs source)
58
+ for comp in agents commands modes; do
59
+ local installed=0 source_count=0
60
+ installed=$(find "$TARGET/$comp" -name "*.md" -type f 2>/dev/null | wc -l)
61
+ source_count=$(find "$SCRIPT_DIR/$comp" -name "*.md" -type f 2>/dev/null | wc -l)
62
+ if [ "$installed" -eq "$source_count" ] 2>/dev/null; then
63
+ _doc_ok "$installed/$source_count $comp installed"
64
+ elif [ "$installed" -gt 0 ] 2>/dev/null; then
65
+ _doc_warn "$installed/$source_count $comp installed"
66
+ else
67
+ _doc_err "0/$source_count $comp installed"
68
+ fi
69
+ done
70
+
71
+ # 4. Skills (profile-dependent, just count what's there)
72
+ local skills_installed=0 skills_source=0
73
+ skills_installed=$(find "$TARGET/skills" -name "*.md" -type f 2>/dev/null | wc -l)
74
+ skills_source=$(find "$SCRIPT_DIR/skills" -name "*.md" -type f 2>/dev/null | wc -l)
75
+ if [ "$skills_installed" -gt 0 ] 2>/dev/null; then
76
+ _doc_ok "$skills_installed/$skills_source skills installed (profile: ${profile:-all})"
77
+ else
78
+ _doc_err "No skills installed"
79
+ fi
80
+
81
+ # 5. Hooks executable
82
+ local hooks_ok=0 hooks_total=0
83
+ for hook in "$TARGET/hooks/"*.sh; do
84
+ [ -f "$hook" ] || continue
85
+ hooks_total=$((hooks_total+1))
86
+ if [ -x "$hook" ]; then
87
+ hooks_ok=$((hooks_ok+1))
88
+ else
89
+ _doc_warn "Hook not executable: $(basename "$hook")"
90
+ fi
91
+ done
92
+ if [ "$hooks_total" -gt 0 ]; then
93
+ if [ "$hooks_ok" -eq "$hooks_total" ]; then
94
+ _doc_ok "$hooks_ok/$hooks_total hooks executable"
95
+ fi
96
+ else
97
+ _doc_warn "No hooks found"
98
+ fi
99
+
100
+ # 6. compose.yml vs settings.json coherence
101
+ if [ -f "$TARGET/../hooks/compose.yml" ] || [ -f "$SCRIPT_DIR/hooks/compose.yml" ]; then
102
+ _doc_ok "compose.yml present"
103
+ fi
104
+
105
+ # 7. MCP configs
106
+ if [ -d "$TARGET/mcp" ] && [ "$(find "$TARGET/mcp" -type f 2>/dev/null | wc -l)" -gt 0 ]; then
107
+ _doc_ok "MCP configs present"
108
+ elif [ -d "$TARGET/mcp" ]; then
109
+ _doc_warn "MCP directory exists but empty"
110
+ fi
111
+
112
+ # 8. docs/ workspace
113
+ local project_root
114
+ project_root="$(dirname "$TARGET")"
115
+ if [ -d "$project_root/docs" ]; then
116
+ _doc_ok "docs/ workspace exists"
117
+ else
118
+ _doc_warn "docs/ workspace missing"
119
+ fi
120
+
121
+ # 9. Broken symlinks in docs/plans/
122
+ local broken=0
123
+ if [ -d "$project_root/docs/plans" ]; then
124
+ while IFS= read -r link; do
125
+ [ -z "$link" ] && continue
126
+ _doc_warn "Broken symlink: $link"
127
+ broken=$((broken+1))
128
+ done < <(find "$project_root/docs/plans" -type l ! -exec test -e {} \; -print 2>/dev/null)
129
+ [ "$broken" -eq 0 ] && _doc_ok "No broken symlinks in docs/plans/"
130
+ fi
131
+
132
+ # 10. git-workflow.conf
133
+ if [ -f "$TARGET/git-workflow.conf" ]; then
134
+ _doc_ok "git-workflow.conf present"
135
+ else
136
+ _doc_warn "git-workflow.conf not found (run --init to configure)"
137
+ fi
138
+
139
+ # 11. CLAUDE.md at project root
140
+ if [ -f "$project_root/CLAUDE.md" ]; then
141
+ _doc_ok "CLAUDE.md present at project root"
142
+ else
143
+ _doc_warn "CLAUDE.md not found at project root"
144
+ fi
145
+
146
+ # 12. Version match source vs manifest
147
+ if [ -n "$version" ]; then
148
+ local source_version
149
+ source_version=$(get_version)
150
+ if [ "$version" = "$source_version" ]; then
151
+ _doc_ok "Version match: source $source_version = manifest $version"
152
+ else
153
+ _doc_warn "Version mismatch: source $source_version != manifest $version (run --update)"
154
+ fi
155
+ fi
156
+
157
+ # Summary
158
+ echo ""
159
+ echo "Summary: $ok OK, $warn WARN, $err ERR"
160
+
161
+ # Exit code: 1 if any errors
162
+ [ "$err" -gt 0 ] && exit 1
163
+ return 0
164
+ }
@@ -0,0 +1,179 @@
1
+ #!/bin/bash
2
+ # install-claude.sh — CortexHawk installer for Claude Code
3
+ # Sourced by install.sh for fresh Claude Code installations
4
+ # Uses shared functions: copy_all_components, count_component_files, generate_hooks_config,
5
+ # write_manifest, create_docs_workspace, run_audit, update_gitignore, setup_templates,
6
+ # install_git_post_merge_hook, do_quickstart
7
+
8
+ install_claude() {
9
+ if [ "$GLOBAL" = true ]; then
10
+ TARGET="$HOME/.claude"
11
+ else
12
+ TARGET="$(pwd)/.claude"
13
+ fi
14
+
15
+ if [ "$DRY_RUN" = true ]; then
16
+ echo "CortexHawk Dry Run (install)"
17
+ echo "=============================="
18
+ echo " Target: $TARGET"
19
+ echo " Profile: ${PROFILE:-all}"
20
+ echo ""
21
+ echo "Would install:"
22
+ count_component_files "$SCRIPT_DIR"
23
+ echo " settings.json"
24
+ [ ! -f "$(pwd)/CLAUDE.md" ] && echo " CLAUDE.md"
25
+ echo ""
26
+ echo "No files were modified (dry run)."
27
+ return
28
+ fi
29
+
30
+ echo "Installing for Claude Code to project: $TARGET"
31
+
32
+ copy_all_components "$SCRIPT_DIR" "$TARGET" "$PROFILE"
33
+
34
+ # Copy agent personas from project root if present
35
+ local project_root
36
+ project_root="$(dirname "$TARGET")"
37
+ if [ -d "$project_root/.cortexhawk-agents" ]; then
38
+ cp -r "$project_root/.cortexhawk-agents/"*.md "$TARGET/agents/" 2>/dev/null || true
39
+ local persona_count
40
+ persona_count=$(find "$project_root/.cortexhawk-agents" -name "*.md" -type f 2>/dev/null | wc -l | tr -d ' ')
41
+ [ "$persona_count" -gt 0 ] && echo " Loaded $persona_count agent persona(s) from .cortexhawk-agents/"
42
+ fi
43
+
44
+ local hooks_json
45
+ hooks_json=$(generate_hooks_config "$SCRIPT_DIR/hooks/compose.yml" ".claude/hooks")
46
+ if [ ! -f "$TARGET/settings.json" ]; then
47
+ # Fresh install: generate settings.json from scratch
48
+ if [ -n "$hooks_json" ] && [ "$hooks_json" != "{}" ]; then
49
+ echo "$hooks_json" | python3 -c "
50
+ import json, sys
51
+ hooks = json.load(sys.stdin)
52
+ with open(sys.argv[1]) as f:
53
+ permissions = json.load(f).get('permissions', {})
54
+ with open(sys.argv[2], 'w') as f:
55
+ json.dump({'permissions': permissions, 'hooks': hooks}, f, indent=2)
56
+ f.write('\n')
57
+ " "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
58
+ echo " Generated settings.json from hooks/compose.yml"
59
+ else
60
+ cp "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
61
+ fi
62
+ else
63
+ # Merge: preserve user customizations, add new hooks + permissions
64
+ if ! command -v python3 >/dev/null 2>&1; then
65
+ echo " Warning: python3 not found — copying settings.json (merge skipped)"
66
+ cp "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
67
+ else
68
+ python3 -c "
69
+ import json, sys, shutil, os
70
+
71
+ raw = sys.stdin.read().strip()
72
+ hooks = json.loads(raw) if raw else {}
73
+
74
+ # Load current settings (tolerate corrupted JSON)
75
+ try:
76
+ with open(sys.argv[1]) as f:
77
+ current = json.load(f)
78
+ except Exception:
79
+ backup = sys.argv[1] + '.bak'
80
+ if os.path.isfile(sys.argv[1]):
81
+ shutil.copy2(sys.argv[1], backup)
82
+ print(f' Warning: settings.json corrupted — backed up to {os.path.basename(backup)}', file=sys.stderr)
83
+ current = {}
84
+
85
+ try:
86
+ with open(sys.argv[2]) as f:
87
+ source = json.load(f)
88
+ except Exception:
89
+ source = {}
90
+
91
+ changes = []
92
+
93
+ # Merge hooks (regenerate from compose.yml)
94
+ if hooks and hooks != {}:
95
+ current['hooks'] = hooks
96
+ changes.append('hooks regenerated')
97
+
98
+ # Merge permissions (union: keep user additions + add new from source)
99
+ src_perms = source.get('permissions', {})
100
+ cur_perms = current.get('permissions', {})
101
+ for key in ('allow', 'deny'):
102
+ src_list = src_perms.get(key, [])
103
+ cur_list = cur_perms.get(key, [])
104
+ added = [p for p in src_list if p not in cur_list]
105
+ if added:
106
+ cur_list.extend(added)
107
+ changes.append(f'{len(added)} new {key} permission(s)')
108
+ cur_perms[key] = cur_list
109
+ current['permissions'] = cur_perms
110
+
111
+ with open(sys.argv[1], 'w') as f:
112
+ json.dump(current, f, indent=2)
113
+ f.write('\n')
114
+
115
+ if changes:
116
+ print(' Merged settings.json: ' + ', '.join(changes))
117
+ else:
118
+ print(' settings.json up to date — no merge needed')
119
+ " "$TARGET/settings.json" "$SCRIPT_DIR/settings.json" <<< "${hooks_json:-}"
120
+ fi
121
+ fi
122
+
123
+ PROJECT_ROOT="$(dirname "$TARGET")"
124
+ if [ ! -f "$PROJECT_ROOT/CLAUDE.md" ]; then
125
+ cp "$SCRIPT_DIR/CLAUDE.md" "$PROJECT_ROOT/CLAUDE.md"
126
+ else
127
+ echo "CLAUDE.md already exists — skipping"
128
+ fi
129
+
130
+ # Git workflow config (interactive in --init, defaults otherwise)
131
+ if [ "$GLOBAL" = false ]; then
132
+ if [ "$INIT_MODE" = true ]; then
133
+ source "$SCRIPT_DIR/scripts/git-workflow-init.sh" "$PROJECT_ROOT" "$TARGET"
134
+ elif [ ! -f "$TARGET/git-workflow.conf" ]; then
135
+ # Apply sensible defaults without asking
136
+ GIT_BRANCHING="direct-main"
137
+ GIT_COMMIT_CONVENTION="conventional"
138
+ GIT_PR_PREFERENCE="on-demand"
139
+ GIT_AUTO_PUSH="after-commit"
140
+ GIT_WORK_BRANCH=""
141
+ source "$SCRIPT_DIR/scripts/git-workflow-init.sh" "$PROJECT_ROOT" "$TARGET"
142
+ fi
143
+ fi
144
+
145
+ chmod +x "$TARGET/hooks/"*.sh 2>/dev/null || true
146
+
147
+ # Write manifest for future updates
148
+ write_manifest "$TARGET" "$PROFILE" "claude" false
149
+
150
+ # Create docs/ workspace for agent outputs (local only)
151
+ if [ "$GLOBAL" = false ]; then
152
+ create_docs_workspace "$(dirname "$TARGET")"
153
+ fi
154
+
155
+ run_audit "$(dirname "$TARGET")"
156
+ update_gitignore "$(dirname "$TARGET")" ".claude"
157
+ setup_templates "$(dirname "$TARGET")"
158
+
159
+ # Offer native git post-merge hook (opt-in, local only, interactive only)
160
+ if [ "$GLOBAL" = false ] && [ -d "$(pwd)/.git" ]; then
161
+ if [ -t 0 ]; then
162
+ echo ""
163
+ read -r -p "Install native git post-merge hook (auto-cleanup after merges)? [y/N]: " _hook_confirm
164
+ case "$_hook_confirm" in
165
+ [yY]*) install_git_post_merge_hook "$(pwd)" ;;
166
+ *) echo " → post-merge hook skipped (run: cortexhawk install --post-merge-hook to add later)" ;;
167
+ esac
168
+ fi
169
+ fi
170
+
171
+ echo ""
172
+ echo "CortexHawk installed successfully for Claude Code!"
173
+ echo ""
174
+ echo " 35 commands | 20 agents | 36 skills | 11 hooks | 7 modes"
175
+ echo ""
176
+ do_quickstart
177
+ echo ""
178
+ echo " To activate: exit Claude Code (ctrl+c) and relaunch 'claude' in this directory."
179
+ }
@@ -93,7 +93,7 @@ case "$skill_choice" in
93
93
  fi
94
94
 
95
95
  # Generate custom profile JSON
96
- PROFILE_FILE="/tmp/cortexhawk-custom-$(date +%s).json"
96
+ PROFILE_FILE=$(mktemp /tmp/cortexhawk-custom-XXXXXX)
97
97
  SKILL_LINES=""
98
98
  SKILL_COUNT=0
99
99
  for category in $SELECTED_CATS; do
@@ -133,7 +133,8 @@ if [ -n "$SKILLSMP_KEY" ]; then
133
133
  else
134
134
  echo "SKILLSMP_API_KEY=$SKILLSMP_KEY" >> .env
135
135
  fi
136
- green " → saved to .env"
136
+ chmod 600 .env
137
+ green " → saved to .env (permissions: owner-only)"
137
138
  else
138
139
  yellow " → skipped (use --search with local REGISTRY.md only)"
139
140
  fi
@@ -0,0 +1,132 @@
1
+ #!/bin/bash
2
+ # lint-guard-runner.sh — Formatter/linter execution with detection cache + parallel linters
3
+ # Called by hooks/lint-guard.sh
4
+ # Formatters: sequential (git add must not race). Linters: parallel (check-only).
5
+
6
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
7
+ [ -z "$REPO_ROOT" ] && exit 0
8
+ STAGED=$(git diff --cached --name-only 2>/dev/null)
9
+ [ -z "$STAGED" ] && exit 0
10
+
11
+ # Opt-out config
12
+ CONF_FILE="$REPO_ROOT/.claude/git-workflow.conf"
13
+ LINT_SKIP=$(grep '^LINT_SKIP=' "$CONF_FILE" 2>/dev/null | cut -d= -f2 | tr ',' '\n')
14
+
15
+ lint_cfg() {
16
+ local key="$1" default="${2:-auto}"
17
+ if [ -f "$REPO_ROOT/.cortexhawk-lint.yml" ]; then
18
+ local val
19
+ val=$(grep -E "^\s+$key:" "$REPO_ROOT/.cortexhawk-lint.yml" 2>/dev/null \
20
+ | sed 's/.*: *//' | tr -d ' \r')
21
+ echo "${val:-$default}"
22
+ else
23
+ echo "$default"
24
+ fi
25
+ }
26
+
27
+ TIMEOUT_SECS=$(lint_cfg "timeout" "30")
28
+ case "$TIMEOUT_SECS" in ''|*[!0-9]*|0) TIMEOUT_SECS=30 ;; esac
29
+ FAIL_ON_FMT=$(lint_cfg "fail_on_formatter" "false")
30
+ TIMEOUT_CMD=""
31
+ command -v timeout &>/dev/null && TIMEOUT_CMD="timeout $TIMEOUT_SECS"
32
+ command -v gtimeout &>/dev/null && [ -z "$TIMEOUT_CMD" ] && TIMEOUT_CMD="gtimeout $TIMEOUT_SECS"
33
+
34
+ # --- DETECTION CACHE (1hr TTL, safe key=value, no source) ---
35
+ mkdir -p "$REPO_ROOT/.claude" 2>/dev/null || true
36
+ CACHE="$REPO_ROOT/.claude/lint-guard-cache"
37
+ _mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || date +%s; }
38
+ [ -f "$CACHE" ] && [ $(( $(date +%s) - $(_mtime "$CACHE") )) -gt 3600 ] && rm -f "$CACHE"
39
+ cache_get() { grep -m1 "^$1=" "$CACHE" 2>/dev/null | cut -d= -f2; }
40
+ cache_set() { local _tmp; _tmp=$(mktemp "${CACHE}.XXXXXX"); { grep -v "^$1=" "$CACHE" 2>/dev/null; echo "$1=$2"; } > "$_tmp" && mv "$_tmp" "$CACHE" || rm -f "$_tmp"; }
41
+
42
+ # --- TOOL DETECTION ---
43
+ STAGED_JS=$(echo "$STAGED" | grep -E '\.(js|ts|jsx|tsx|mjs|cjs)$' || true)
44
+ STAGED_CSS=$(echo "$STAGED" | grep -E '\.(css|scss|sass|less)$' || true)
45
+ STAGED_PY=$(echo "$STAGED" | grep -E '\.py$' || true)
46
+ STAGED_RS=$(echo "$STAGED" | grep -E '\.rs$' || true)
47
+ STAGED_GO=$(echo "$STAGED" | grep -E '\.go$' || true)
48
+
49
+ has_config() {
50
+ case "$1" in
51
+ prettier) ls "$REPO_ROOT"/.prettierrc* "$REPO_ROOT"/prettier.config.* 2>/dev/null | grep -q . \
52
+ || grep -qs '"prettier"' "$REPO_ROOT/package.json" 2>/dev/null ;;
53
+ eslint) ls "$REPO_ROOT"/.eslintrc* "$REPO_ROOT"/eslint.config.* 2>/dev/null | grep -q . ;;
54
+ black) grep -qs '\[tool\.black\]' "$REPO_ROOT/pyproject.toml" 2>/dev/null ;;
55
+ flake8) [ -f "$REPO_ROOT/.flake8" ] || grep -qs '\[flake8\]' "$REPO_ROOT/setup.cfg" 2>/dev/null ;;
56
+ mypy) grep -qs '\[tool\.mypy\]' "$REPO_ROOT/pyproject.toml" 2>/dev/null || [ -f "$REPO_ROOT/mypy.ini" ] ;;
57
+ stylelint) ls "$REPO_ROOT"/.stylelintrc* "$REPO_ROOT"/stylelint.config.* 2>/dev/null | grep -q . ;;
58
+ rustfmt) [ -f "$REPO_ROOT/rustfmt.toml" ] || [ -f "$REPO_ROOT/.rustfmt.toml" ] ;;
59
+ gofmt) true ;;
60
+ esac
61
+ }
62
+
63
+ c_has_config() {
64
+ local key="HAS_$(echo "$1" | tr '[:lower:]' '[:upper:]')"
65
+ local cached; cached=$(cache_get "$key")
66
+ if [ -n "$cached" ]; then [ "$cached" = "1" ]; return $?; fi
67
+ if has_config "$1"; then cache_set "$key" "1"; return 0
68
+ else cache_set "$key" "0"; return 1; fi
69
+ }
70
+
71
+ is_enabled() {
72
+ echo "$LINT_SKIP" | grep -qx "$1" && return 1
73
+ local val; val=$(lint_cfg "$1"); [ "$val" != "false" ]
74
+ }
75
+
76
+ tool_ok() { command -v "$1" &>/dev/null; }
77
+
78
+ # Pre-populate cache before parallel stage to avoid write races
79
+ for _t in prettier eslint black flake8 mypy stylelint rustfmt gofmt; do
80
+ c_has_config "$_t" >/dev/null 2>&1
81
+ done
82
+
83
+ # --- FORMATTERS (sequential — git add must not race) ---
84
+ run_formatter() {
85
+ local name="$1" cmd="$2" files="$3"
86
+ is_enabled "$name" || return 0
87
+ c_has_config "$name" || return 0
88
+ tool_ok "$name" || { echo "lint-guard: $name not in PATH, skipping"; return 0; }
89
+ [ -z "$files" ] && return 0
90
+ echo "lint-guard: $name (auto-fix)..."
91
+ # shellcheck disable=SC2086
92
+ if echo "$files" | tr '\n' '\0' | xargs -0 $TIMEOUT_CMD $cmd 2>/dev/null; then
93
+ echo "$files" | tr '\n' '\0' | xargs -0 git add 2>/dev/null || true
94
+ else
95
+ echo "lint-guard: $name formatter failed" >&2
96
+ [ "$FAIL_ON_FMT" = "true" ] && exit 2
97
+ fi
98
+ }
99
+
100
+ [ -n "$STAGED_JS" ] && run_formatter "prettier" "prettier --write" "$STAGED_JS"
101
+ [ -n "$STAGED_CSS" ] && run_formatter "stylelint" "stylelint --fix" "$STAGED_CSS"
102
+ [ -n "$STAGED_PY" ] && run_formatter "black" "black" "$STAGED_PY"
103
+ [ -n "$STAGED_RS" ] && run_formatter "rustfmt" "rustfmt" "$STAGED_RS"
104
+ [ -n "$STAGED_GO" ] && run_formatter "gofmt" "gofmt -w" "$STAGED_GO"
105
+
106
+ # --- LINTERS (parallel — check-only, no file writes) ---
107
+ ERR_DIR=$(mktemp -d)
108
+ trap 'rm -rf "$ERR_DIR"' EXIT
109
+
110
+ run_linter_bg() {
111
+ local name="$1" cmd="$2" files="$3"
112
+ is_enabled "$name" || return 0
113
+ c_has_config "$name" || return 0
114
+ tool_ok "$name" || { echo "lint-guard: $name not in PATH, skipping"; return 0; }
115
+ [ -z "$files" ] && return 0
116
+ echo "lint-guard: $name (check)..."
117
+ # shellcheck disable=SC2086
118
+ if ! echo "$files" | tr '\n' '\0' | xargs -0 $TIMEOUT_CMD $cmd 2>&1; then
119
+ echo "BLOCKED by lint-guard: $name errors found. Fix above, re-stage, and retry." >&2
120
+ touch "$ERR_DIR/$name"
121
+ fi
122
+ }
123
+
124
+ # Output from parallel linters may interleave on screen; ERR_DIR signals are reliable regardless.
125
+ [ -n "$STAGED_JS" ] && run_linter_bg "eslint" "eslint --max-warnings=0" "$STAGED_JS" &
126
+ [ -n "$STAGED_PY" ] && run_linter_bg "flake8" "flake8" "$STAGED_PY" &
127
+ [ -n "$STAGED_PY" ] && run_linter_bg "mypy" "mypy --no-error-summary" "$STAGED_PY" &
128
+ wait
129
+
130
+ ERRORS=$(find "$ERR_DIR" -type f 2>/dev/null | wc -l | tr -d ' ')
131
+ [ "$ERRORS" -gt 0 ] && exit 2
132
+ exit 0