feat: add mac-optimizer skill (92) — Claude Code only

macOS system health toolkit with 5 audit modules (packages, environment,
security, cleanup, resources) and cleanup executor. Fixed bugs from review:
- Replace GNU timeout with perl alarm (macOS compatible)
- Remove Linux-only ps --sort flag, use portable sort
- Add JSON escaping to all audit scripts
- Remove redundant classify_size branch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:59:24 +09:00
parent 338176abbe
commit f1a973c42d
13 changed files with 1145 additions and 4 deletions

View File

@@ -0,0 +1,98 @@
# Mac Optimizer
Modular macOS system health toolkit. Runs read-only audits first, then recommends actions with user consent.
## Module Routing
| Keywords | Module |
|---|---|
| brew, homebrew, npm, nvm, pip, pyenv, packages, update, outdated | packages |
| path, shell, zshrc, environment, env, config, symlink | environment |
| security, firewall, sip, gatekeeper, filevault, ports, ssh | security |
| cache, cleanup, clean, logs, clutter, disk space, free space, trash | cleanup |
| cpu, memory, ram, disk, battery, processes, resources, slow | resources |
| doctor, audit, health, full check, everything, system check | doctor (all) |
Default to **doctor** when the request is ambiguous.
## Execution Model
Every module follows this flow:
1. **Audit** — run the module's script (read-only)
2. **Report** — parse JSON output, present findings as a severity-ranked table
3. **Recommend** — list available actions grouped by risk
4. **Consent** — ask user which actions to approve (per-category, per-item for risky)
5. **Act** — execute only approved actions
## Running Audit Scripts
All scripts are in `scripts/` and output JSON lines to stdout:
```bash
bash scripts/audit_packages.sh
bash scripts/audit_environment.sh
bash scripts/audit_security.sh
bash scripts/audit_cleanup.sh
bash scripts/audit_resources.sh
```
Each JSON line has: `{"module":"...","severity":"...","finding":"...","action":"...","details":"..."}`
Severity levels: `critical` > `warning` > `info`
If a script exits non-zero, report the error and continue with other modules.
## Running Cleanup
```bash
bash scripts/cleanup_execute.sh --help # See all targets
bash scripts/cleanup_execute.sh --dry-run <targets> # Preview (default)
bash scripts/cleanup_execute.sh --execute <targets> # Actually clean
```
**Always run --dry-run first and show the user what will happen before --execute.**
## Doctor Mode Workflow
1. Run all 5 audit scripts sequentially, collect all JSON findings
2. Parse and group findings by severity (critical first, then warning, then info)
3. Present unified report table to the user
4. **STOP and ask**: "Which actions would you like me to perform? You can approve by category (e.g., 'update packages and clean caches') or review each item."
5. Execute only approved actions
6. Present final summary of actions taken
## Safety Rules
- **Never execute cleanup without explicit user approval**
- **Always show sizes before deleting anything**
- **Security module is read-only** — present findings and remediation guidance only
- **Back up shell configs** before modifying: `cp ~/.zshrc ~/.config/mac-optimizer-backups/.zshrc.$(date +%s)`
- **Process deny-list**: never suggest killing `kernel_task`, `launchd`, `WindowServer`, `loginwindow`, `mds`, `mds_stores`, `opendirectoryd`, `coreaudiod`, `SystemUIServer`, `Finder`, `Dock`
- **No sudo by default** — if an action needs sudo, state why and ask first
- Docker cleanup uses `docker system prune`, never direct file deletion
## Report Format
Present findings as a markdown table:
```
### Critical
| Module | Finding | Recommended Action |
|---|---|---|
| security | Firewall disabled | Enable in System Settings |
### Warning
| Module | Finding | Recommended Action |
|---|---|---|
### Info
| Module | Finding | Recommended Action |
|---|---|---|
```
## Reference Files
- **references/packages.md** — package manager update strategies and commands
- **references/security-checks.md** — security benchmarks and remediation guidance
- **references/cleanup-targets.md** — full list of cleanup paths with risk ratings

View File

@@ -0,0 +1,74 @@
# Cleanup Targets Reference
## Risk Ratings
| Risk | Meaning |
|---|---|
| Safe | Can be deleted without consequence; regenerated automatically |
| Moderate | Review before deleting; may lose useful data |
| Risky | May break applications; requires careful inspection |
| Info only | Report size but do not offer deletion |
## Target Details
### User Logs (Safe)
- **Path**: `~/Library/Logs/`
- **Cleanup target**: `user-logs`
- **Notes**: Application crash reports and debug logs. Regenerated as needed.
### System Logs (Moderate, requires sudo)
- **Path**: `/var/log/`
- **Cleanup target**: not available via cleanup_execute.sh (requires sudo)
- **Notes**: System-level logs. Only clean old/rotated files. Active logs should not be deleted.
### User Caches (Safe, per-app)
- **Path**: `~/Library/Caches/`
- **Cleanup target**: `user-caches`
- **Notes**: App caches regenerated on next use. Some apps may need to re-download data. Browser caches can be large.
### Homebrew Cache (Safe)
- **Path**: `$(brew --cache)` (typically `~/Library/Caches/Homebrew/`)
- **Cleanup target**: `brew-cache`
- **Notes**: Downloaded bottles and source archives. `brew cleanup` handles this properly.
### npm Cache (Safe)
- **Path**: `~/.npm/_cacache/`
- **Cleanup target**: `npm-cache`
- **Notes**: Package download cache. `npm cache clean --force` is the proper way to clear.
### pip Cache (Safe)
- **Path**: `~/Library/Caches/pip/`
- **Cleanup target**: `pip-cache`
- **Notes**: Downloaded wheel/sdist cache. `pip cache purge` is the proper way to clear.
### Xcode DerivedData (Safe)
- **Path**: `~/Library/Developer/Xcode/DerivedData/`
- **Cleanup target**: `xcode-derived`
- **Notes**: Build artifacts and indexes. Rebuilt on next Xcode build. Can grow very large.
### Xcode Archives (Moderate)
- **Path**: `~/Library/Developer/Xcode/Archives/`
- **Cleanup target**: `xcode-archives`
- **Notes**: App Store submission archives. May be needed for symbolication of crash reports. Review before deleting.
### iOS DeviceSupport (Safe)
- **Path**: `~/Library/Developer/Xcode/iOS DeviceSupport/`
- **Cleanup target**: `ios-support`
- **Notes**: Debug symbols for connected iOS devices. Re-downloaded when device connects again.
### Docker (Moderate)
- **Cleanup target**: `docker`
- **Notes**: Uses `docker system prune -f`. Removes stopped containers, unused networks, dangling images. Does NOT remove named volumes. Never delete Docker's filesystem directly.
### Trash (Safe)
- **Path**: `~/.Trash/`
- **Cleanup target**: `trash`
- **Notes**: Items in Trash. User has already "deleted" these.
### Old Downloads (Info only)
- **Path**: `~/Downloads/` (files >90 days old)
- **Notes**: Report only. User should review manually. Never auto-delete downloads.
### Application Support Remnants (Risky)
- **Path**: `~/Library/Application Support/` orphaned directories
- **Notes**: Report only. Directories from uninstalled apps. Difficult to determine automatically which are truly orphaned. Manual review required.

View File

@@ -0,0 +1,52 @@
# Package Manager Reference
## Homebrew
### Update workflow
```bash
brew update # Update Homebrew itself and formulae list
brew outdated # List outdated formulae
brew outdated --cask # List outdated casks
brew upgrade # Upgrade all outdated formulae
brew upgrade <name> # Upgrade specific formula
brew upgrade --cask # Upgrade all casks
brew cleanup --prune=0 # Remove all cached downloads
brew autoremove # Remove orphaned dependencies
brew doctor # Diagnose issues
```
### Safety notes
- `brew upgrade` is generally safe but can break projects pinned to specific versions
- Always check `brew doctor` warnings — some are cosmetic, some indicate real issues
- `brew autoremove` only removes packages not depended on by anything
## npm / nvm
### Update workflow
```bash
nvm ls # List installed Node versions
nvm install --lts # Install latest LTS
npm outdated -g # Check global packages
npm update -g # Update all global packages
npm cache clean --force # Clear npm cache
```
### Safety notes
- Global npm packages are version-independent from project dependencies
- Updating global packages won't affect project-level node_modules
## Python / pyenv
### Update workflow
```bash
pyenv versions # List installed versions
pyenv install --list # Available versions
pip list --outdated # Outdated packages in current env
pip install --upgrade <pkg> # Upgrade specific package
pip cache purge # Clear pip cache
```
### Safety notes
- Only audit pyenv-managed environments (not system Python)
- pip upgrades can break dependency chains — suggest `--dry-run` first
- Virtual environments in project directories are not scanned (out of scope v1)

View File

@@ -0,0 +1,53 @@
# Security Checks Reference
## SIP (System Integrity Protection)
- **Check**: `csrutil status`
- **Expected**: "System Integrity Protection status: enabled."
- **Remediation**: Reboot to Recovery Mode (Cmd+R), open Terminal, run `csrutil enable`
- **Severity**: Critical if disabled
## Gatekeeper
- **Check**: `spctl --status`
- **Expected**: "assessments enabled"
- **Remediation**: `sudo spctl --master-enable`
- **Severity**: Critical if disabled
## Firewall
- **Check**: `/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate`
- **Fallback**: `defaults read /Library/Preferences/com.apple.alf globalstate` (0=off, 1=on, 2=essential)
- **Remediation**: System Settings > Network > Firewall > Turn On
- **Severity**: Critical if disabled
## FileVault
- **Check**: `fdesetup status`
- **Expected**: "FileVault is On."
- **Remediation**: System Settings > Privacy & Security > FileVault > Turn On
- **Severity**: Critical if disabled (disk not encrypted)
## Open Ports
- **Check**: `lsof -iTCP -sTCP:LISTEN -nP`
- **Review**: unexpected services listening on all interfaces (0.0.0.0 or *)
- **Severity**: Info (review needed, not automatically bad)
## SSH Configuration
- **Check**: `/etc/ssh/sshd_config`
- **Flags**: `PasswordAuthentication yes`, `PermitRootLogin yes`
- **Remediation**: Set to `no` and restart sshd
- **Severity**: Warning
## Remote Services
- **Remote Login**: `systemsetup -getremotelogin`
- **Remote Management**: `defaults read /Library/Preferences/com.apple.RemoteManagement ARD_AllLocalUsers`
- **Remediation**: Disable in System Settings > General > Sharing
- **Severity**: Info/Warning depending on context
## Software Updates
- **Check**: `softwareupdate -l` (can be slow, 30s timeout)
- **Remediation**: System Settings > General > Software Update
- **Severity**: Warning if updates available
## Screen Lock
- **Check**: `defaults -currentHost read com.apple.screensaver idleTime`
- **Check**: `defaults read com.apple.screensaver askForPassword`
- **Remediation**: System Settings > Lock Screen
- **Severity**: Warning if password not required

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# audit_cleanup.sh — Scan caches, logs, and clutter on macOS, report sizes
# Output: JSON lines to stdout, one per finding
set -euo pipefail
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' '; }
json_finding() {
local severity="$1" finding action details
finding=$(json_escape "$2")
action=$(json_escape "$3")
details=$(json_escape "$4")
printf '{"module":"cleanup","severity":"%s","finding":"%s","action":"%s","details":"%s"}\n' \
"$severity" "$finding" "$action" "$details"
}
# Get directory size in human-readable and bytes
dir_size() {
local path="$1"
if [ -d "$path" ]; then
local bytes=$(du -sk "$path" 2>/dev/null | awk '{print $1}')
local human=$(du -sh "$path" 2>/dev/null | awk '{print $1}')
echo "${bytes:-0}|${human:-0B}"
else
echo "0|0B"
fi
}
classify_size() {
local kb="$1"
if [ "$kb" -gt 1048576 ] 2>/dev/null; then # > 1GB
echo "warning"
else
echo "info"
fi
}
total_reclaimable_kb=0
# --- User logs ---
result=$(dir_size "$HOME/Library/Logs")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "User logs: ${human}" "clean" "~/Library/Logs/ [safe, no sudo]"
# --- System logs ---
result=$(dir_size "/var/log" 2>/dev/null || echo "0|0B")
IFS='|' read -r kb human <<< "$result"
if [ "$kb" -gt 0 ] 2>/dev/null; then
severity=$(classify_size "$kb")
json_finding "$severity" "System logs: ${human}" "clean" "/var/log/ [moderate, requires sudo]"
fi
# --- User caches ---
result=$(dir_size "$HOME/Library/Caches")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "User caches: ${human}" "clean" "~/Library/Caches/ [safe, per-app]"
# --- Homebrew cache ---
if command -v brew &>/dev/null; then
brew_cache=$(brew --cache 2>/dev/null || echo "")
if [ -n "$brew_cache" ] && [ -d "$brew_cache" ]; then
result=$(dir_size "$brew_cache")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "Homebrew cache: ${human}" "brew cleanup" "$brew_cache [safe]"
fi
fi
# --- npm cache ---
npm_cache="$HOME/.npm/_cacache"
if [ -d "$npm_cache" ]; then
result=$(dir_size "$npm_cache")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "npm cache: ${human}" "npm cache clean --force" "$npm_cache [safe]"
fi
# --- pip cache ---
pip_cache="$HOME/Library/Caches/pip"
if [ -d "$pip_cache" ]; then
result=$(dir_size "$pip_cache")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "pip cache: ${human}" "pip cache purge" "$pip_cache [safe]"
fi
# --- Xcode derived data ---
xcode_derived="$HOME/Library/Developer/Xcode/DerivedData"
if [ -d "$xcode_derived" ]; then
result=$(dir_size "$xcode_derived")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "Xcode DerivedData: ${human}" "clean" "$xcode_derived [safe]"
fi
# --- Xcode archives ---
xcode_archives="$HOME/Library/Developer/Xcode/Archives"
if [ -d "$xcode_archives" ]; then
result=$(dir_size "$xcode_archives")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "Xcode Archives: ${human}" "clean" "$xcode_archives [moderate - check before deleting]"
fi
# --- iOS Device Support ---
ios_support="$HOME/Library/Developer/Xcode/iOS DeviceSupport"
if [ -d "$ios_support" ]; then
result=$(dir_size "$ios_support")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
severity=$(classify_size "$kb")
json_finding "$severity" "iOS DeviceSupport: ${human}" "clean" "$ios_support [safe]"
fi
# --- Docker ---
if command -v docker &>/dev/null; then
docker_df=$(docker system df 2>/dev/null || echo "")
if [ -n "$docker_df" ]; then
reclaimable=$(echo "$docker_df" | grep -i "reclaimable" | head -1 || echo "")
json_finding "info" "Docker space usage detected" "docker system prune" "Run docker system df for details [use docker system prune to clean]"
fi
else
# Skip silently if docker not installed
true
fi
# --- Trash ---
trash_dir="$HOME/.Trash"
if [ -d "$trash_dir" ]; then
result=$(dir_size "$trash_dir")
IFS='|' read -r kb human <<< "$result"
total_reclaimable_kb=$((total_reclaimable_kb + kb))
if [ "$kb" -gt 0 ] 2>/dev/null; then
severity=$(classify_size "$kb")
json_finding "$severity" "Trash: ${human}" "empty trash" "$trash_dir [safe]"
fi
fi
# --- Old downloads (report only) ---
if [ -d "$HOME/Downloads" ]; then
old_files=$(find "$HOME/Downloads" -maxdepth 1 -mtime +90 -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$old_files" -gt 0 ] 2>/dev/null; then
old_size=$(find "$HOME/Downloads" -maxdepth 1 -mtime +90 -type f -exec du -ck {} + 2>/dev/null | tail -1 | awk '{print $1}')
old_human=$(echo "$old_size" | awk '{printf "%.1fM", $1/1024}')
json_finding "info" "${old_files} files in Downloads older than 90 days (${old_human})" "review" "Info only - review manually"
fi
fi
# --- Total summary ---
total_human=$(echo "$total_reclaimable_kb" | awk '{if($1>1048576) printf "%.1fG",$1/1048576; else if($1>1024) printf "%.1fM",$1/1024; else printf "%dK",$1}')
json_finding "info" "Total scannable space: ${total_human}" "review" "Approx reclaimable across all categories"

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# audit_environment.sh — Audit shell environment and paths on macOS
# Output: JSON lines to stdout, one per finding
set -euo pipefail
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' '; }
json_finding() {
local severity="$1" finding action details
finding=$(json_escape "$2")
action=$(json_escape "$3")
details=$(json_escape "$4")
printf '{"module":"environment","severity":"%s","finding":"%s","action":"%s","details":"%s"}\n' \
"$severity" "$finding" "$action" "$details"
}
# --- PATH analysis ---
IFS=':' read -ra path_dirs <<< "$PATH"
total_dirs=${#path_dirs[@]}
# Duplicate detection (compatible with bash 3)
duplicates=()
for i in "${!path_dirs[@]}"; do
dir="${path_dirs[$i]}"
is_dup=false
for j in "${!path_dirs[@]}"; do
if [ "$j" -lt "$i" ] && [ "${path_dirs[$j]}" = "$dir" ]; then
is_dup=true
break
fi
done
if $is_dup; then
duplicates+=("$dir")
fi
done
if [ ${#duplicates[@]} -gt 0 ]; then
dup_list=$(printf '%s, ' "${duplicates[@]}" | sed 's/, $//')
json_finding "warning" "${#duplicates[@]} duplicate PATH entries" "deduplicate" "$dup_list"
else
json_finding "info" "No duplicate PATH entries" "none" "${total_dirs} directories in PATH"
fi
# Non-existent directories
missing=()
for dir in "${path_dirs[@]}"; do
if [ ! -d "$dir" ]; then
missing+=("$dir")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
missing_list=$(printf '%s, ' "${missing[@]}" | sed 's/, $//')
json_finding "warning" "${#missing[@]} non-existent directories in PATH" "remove" "$missing_list"
fi
# --- Broken symlinks ---
broken_symlinks=()
for check_dir in /usr/local/bin "$HOME/.local/bin"; do
if [ -d "$check_dir" ]; then
while IFS= read -r link; do
broken_symlinks+=("$link")
done < <(find "$check_dir" -maxdepth 1 -type l ! -exec test -e {} \; -print 2>/dev/null || true)
fi
done
if [ ${#broken_symlinks[@]} -gt 0 ]; then
broken_list=$(printf '%s, ' "${broken_symlinks[@]}" | sed 's/, $//')
json_finding "warning" "${#broken_symlinks[@]} broken symlinks found" "remove" "$broken_list"
else
json_finding "info" "No broken symlinks in bin directories" "none" ""
fi
# --- Shell config analysis ---
shell_configs=()
for cfg in "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv" "$HOME/.bash_profile" "$HOME/.bashrc"; do
if [ -f "$cfg" ]; then
shell_configs+=("$(basename "$cfg")")
fi
done
json_finding "info" "Shell configs found: ${shell_configs[*]}" "none" ""
# Check for PATH modifications across multiple files
path_mods=0
for cfg in "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv" "$HOME/.bash_profile" "$HOME/.bashrc"; do
if [ -f "$cfg" ]; then
count=$(grep -c -E 'export PATH|PATH=' "$cfg" 2>/dev/null || echo 0)
count=$(echo "$count" | tr -d '[:space:]')
path_mods=$((path_mods + count))
fi
done
if [ "$path_mods" -gt 5 ]; then
json_finding "warning" "PATH modified ${path_mods} times across shell configs" "consolidate" "Consider consolidating PATH modifications"
fi
# --- Environment variable checks ---
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
json_finding "info" "ANTHROPIC_API_KEY not set" "none" "Set if using Claude API"
fi
if [ -n "${JAVA_HOME:-}" ] && [ ! -d "$JAVA_HOME" ]; then
json_finding "warning" "JAVA_HOME points to non-existent directory" "fix" "$JAVA_HOME"
fi
# --- Shell startup time ---
if command -v zsh &>/dev/null; then
startup_time=$( { time zsh -i -c exit; } 2>&1 | grep real | awk '{print $2}' || echo "unknown")
# Parse the time - handle both formats like "0m0.123s" and "0.123"
if echo "$startup_time" | grep -q 'm'; then
seconds=$(echo "$startup_time" | sed 's/.*m//' | sed 's/s//')
else
seconds="$startup_time"
fi
if command -v python3 &>/dev/null; then
is_slow=$(python3 -c "print('slow' if float('${seconds}') > 0.5 else 'ok')" 2>/dev/null || echo "ok")
else
is_slow="ok"
fi
if [ "$is_slow" = "slow" ]; then
json_finding "warning" "Shell startup is slow (${startup_time})" "optimize" "Consider profiling with zsh -xv"
else
json_finding "info" "Shell startup time: ${startup_time}" "none" ""
fi
fi

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# audit_packages.sh — Audit package managers on macOS
# Output: JSON lines to stdout, one per finding
set -euo pipefail
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' '; }
json_finding() {
local severity="$1" finding action details
finding=$(json_escape "$2")
action=$(json_escape "$3")
details=$(json_escape "$4")
printf '{"module":"packages","severity":"%s","finding":"%s","action":"%s","details":"%s"}\n' \
"$severity" "$finding" "$action" "$details"
}
# --- Homebrew ---
if command -v brew &>/dev/null; then
echo '{"module":"packages","severity":"info","finding":"Homebrew detected","action":"none","details":"'$(brew --version | head -1)'"}'
# brew update
brew update --quiet 2>/dev/null || true
# outdated formulae
outdated=$(brew outdated --json=v2 2>/dev/null || echo '{}')
formula_count=$(echo "$outdated" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('formulae',[])))" 2>/dev/null || echo 0)
cask_count=$(echo "$outdated" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('casks',[])))" 2>/dev/null || echo 0)
if [ "$formula_count" -gt 0 ] 2>/dev/null; then
formula_list=$(echo "$outdated" | python3 -c "
import sys,json
d=json.load(sys.stdin)
names=[f['name'] for f in d.get('formulae',[])]
print(', '.join(names[:10]))
if len(names)>10: print(f'... and {len(names)-10} more')
" 2>/dev/null || echo "")
json_finding "warning" "${formula_count} Homebrew formulae outdated" "brew upgrade" "$formula_list"
fi
if [ "$cask_count" -gt 0 ] 2>/dev/null; then
json_finding "warning" "${cask_count} Homebrew casks outdated" "brew upgrade --cask" ""
fi
if [ "$formula_count" -eq 0 ] && [ "$cask_count" -eq 0 ] 2>/dev/null; then
json_finding "info" "All Homebrew packages up to date" "none" ""
fi
# brew doctor
doctor_output=$(brew doctor 2>&1 || true)
doctor_warnings=$(echo "$doctor_output" | grep -c "Warning:" 2>/dev/null || echo 0)
if [ "$doctor_warnings" -gt 0 ] 2>/dev/null; then
json_finding "warning" "brew doctor found ${doctor_warnings} warning(s)" "brew doctor" "Run brew doctor for details"
fi
# autoremove candidates
autoremove_output=$(brew autoremove --dry-run 2>/dev/null || echo "")
orphan_count=$(echo "$autoremove_output" | grep -c "^=" 2>/dev/null || echo 0)
if echo "$autoremove_output" | grep -q "would remove" 2>/dev/null; then
json_finding "info" "Orphaned Homebrew dependencies found" "brew autoremove" "$autoremove_output"
fi
else
json_finding "info" "Homebrew not installed" "none" "Consider installing from https://brew.sh"
fi
# --- npm / nvm ---
if command -v nvm &>/dev/null || [ -d "$HOME/.nvm" ]; then
# Source nvm if available
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh" 2>/dev/null
if command -v nvm &>/dev/null; then
node_versions=$(nvm ls --no-colors 2>/dev/null | grep -c "v[0-9]" || echo 0)
current_node=$(nvm current 2>/dev/null || echo "none")
json_finding "info" "nvm detected: ${node_versions} version(s), current: ${current_node}" "none" ""
fi
fi
if command -v npm &>/dev/null; then
json_finding "info" "npm detected" "none" "$(npm --version 2>/dev/null)"
npm_outdated=$(npm outdated -g --json 2>/dev/null || echo '{}')
npm_outdated_count=$(echo "$npm_outdated" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
if [ "$npm_outdated_count" -gt 0 ] 2>/dev/null; then
json_finding "warning" "${npm_outdated_count} global npm packages outdated" "npm update -g" ""
fi
else
json_finding "info" "npm not installed" "none" ""
fi
# --- Python / pyenv ---
if command -v pyenv &>/dev/null; then
pyenv_versions=$(pyenv versions --bare 2>/dev/null | wc -l | tr -d ' ')
current_python=$(pyenv version-name 2>/dev/null || echo "system")
json_finding "info" "pyenv detected: ${pyenv_versions} version(s), current: ${current_python}" "none" ""
# Check pip outdated for current environment
if command -v pip &>/dev/null || command -v pip3 &>/dev/null; then
pip_cmd=$(command -v pip3 || command -v pip)
pip_outdated=$($pip_cmd list --outdated --format=json 2>/dev/null || echo '[]')
pip_count=$(echo "$pip_outdated" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
if [ "$pip_count" -gt 0 ] 2>/dev/null; then
json_finding "warning" "${pip_count} pip packages outdated in current env" "pip install --upgrade" ""
fi
fi
elif command -v python3 &>/dev/null; then
py_version=$(python3 --version 2>/dev/null || echo "unknown")
json_finding "info" "System Python: ${py_version} (no pyenv)" "none" ""
fi
# --- Other package managers (presence detection only) ---
for mgr in yarn pnpm cargo go gem composer; do
if command -v "$mgr" &>/dev/null; then
version=$($mgr --version 2>/dev/null | head -1 || echo "unknown")
json_finding "info" "${mgr} detected: ${version}" "none" "Presence check only"
fi
done

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# audit_resources.sh — Monitor CPU, memory, disk, battery on macOS
# Output: JSON lines to stdout, one per finding
set -euo pipefail
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' '; }
json_finding() {
local severity="$1" finding action details
finding=$(json_escape "$2")
action=$(json_escape "$3")
details=$(json_escape "$4")
printf '{"module":"resources","severity":"%s","finding":"%s","action":"%s","details":"%s"}\n' \
"$severity" "$finding" "$action" "$details"
}
# Process deny-list (never suggest termination)
DENY_LIST="kernel_task|launchd|WindowServer|loginwindow|mds|mds_stores|opendirectoryd|coreaudiod|SystemUIServer|Finder|Dock"
# --- CPU ---
load_avg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1}')
cpu_count=$(sysctl -n hw.ncpu 2>/dev/null || echo 1)
if command -v python3 &>/dev/null; then
cpu_severity=$(python3 -c "
load=$load_avg; cores=$cpu_count
if load > cores * 2: print('critical')
elif load > cores: print('warning')
else: print('info')
" 2>/dev/null || echo "info")
else
cpu_severity="info"
fi
json_finding "$cpu_severity" "Load average: ${load_avg} (${cpu_count} cores)" "review" ""
# Top CPU-consuming processes (excluding deny-list from action suggestions)
top_cpu=$(ps aux | sort -nrk 3 | head -5 || echo "")
if [ -n "$top_cpu" ]; then
while IFS= read -r line; do
proc_name=$(echo "$line" | awk '{print $11}' | xargs basename 2>/dev/null || echo "unknown")
cpu_pct=$(echo "$line" | awk '{print $3}')
mem_pct=$(echo "$line" | awk '{print $4}')
pid=$(echo "$line" | awk '{print $2}')
# Skip low-usage processes
is_high=$(python3 -c "print('yes' if float('${cpu_pct}') > 10 else 'no')" 2>/dev/null || echo "no")
if [ "$is_high" = "yes" ]; then
if echo "$proc_name" | grep -qE "$DENY_LIST"; then
json_finding "info" "High CPU: ${proc_name} (${cpu_pct}% CPU, PID ${pid})" "none" "System process - do not terminate"
else
json_finding "warning" "High CPU: ${proc_name} (${cpu_pct}% CPU, PID ${pid})" "quit" "User process - can be terminated"
fi
fi
done <<< "$top_cpu"
fi
# --- Memory ---
vm_stat_output=$(vm_stat 2>/dev/null || echo "")
if [ -n "$vm_stat_output" ]; then
page_size=$(vm_stat | head -1 | grep -o '[0-9]*' || echo 16384)
free_pages=$(echo "$vm_stat_output" | grep "Pages free" | awk '{print $3}' | tr -d '.')
active_pages=$(echo "$vm_stat_output" | grep "Pages active" | awk '{print $3}' | tr -d '.')
inactive_pages=$(echo "$vm_stat_output" | grep "Pages inactive" | awk '{print $3}' | tr -d '.')
wired_pages=$(echo "$vm_stat_output" | grep "Pages wired" | awk '{print $4}' | tr -d '.')
compressed_pages=$(echo "$vm_stat_output" | grep "Pages stored in compressor" | awk '{print $5}' | tr -d '.' || echo 0)
if command -v python3 &>/dev/null; then
mem_summary=$(python3 -c "
ps=${page_size}
free_mb = int(${free_pages:-0}) * ps / 1048576
active_mb = int(${active_pages:-0}) * ps / 1048576
inactive_mb = int(${inactive_pages:-0}) * ps / 1048576
wired_mb = int(${wired_pages:-0}) * ps / 1048576
compressed_mb = int(${compressed_pages:-0}) * ps / 1048576
used_mb = active_mb + wired_mb + compressed_mb
total_mb = free_mb + active_mb + inactive_mb + wired_mb + compressed_mb
pct = used_mb / total_mb * 100 if total_mb > 0 else 0
print(f'{used_mb:.0f}MB used / {total_mb:.0f}MB total ({pct:.0f}%)')
if pct > 90: print('SEVERITY:critical')
elif pct > 75: print('SEVERITY:warning')
else: print('SEVERITY:info')
" 2>/dev/null || echo "unknown")
mem_severity=$(echo "$mem_summary" | grep "SEVERITY:" | cut -d: -f2)
mem_info=$(echo "$mem_summary" | head -1)
json_finding "${mem_severity:-info}" "Memory: ${mem_info}" "review" ""
fi
fi
# --- Swap ---
swap_info=$(sysctl vm.swapusage 2>/dev/null || echo "")
if [ -n "$swap_info" ]; then
swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*M" | awk '{print $3}' | tr -d 'M')
if [ -n "$swap_used" ] && command -v python3 &>/dev/null; then
swap_severity=$(python3 -c "
s=float('${swap_used}')
if s > 4096: print('warning')
elif s > 1024: print('info')
else: print('info')
" 2>/dev/null || echo "info")
json_finding "$swap_severity" "Swap: ${swap_used}MB used" "review" "$swap_info"
fi
fi
# --- Disk usage ---
disk_info=$(df -h / 2>/dev/null | tail -1)
if [ -n "$disk_info" ]; then
disk_used_pct=$(echo "$disk_info" | awk '{print $5}' | tr -d '%')
disk_avail=$(echo "$disk_info" | awk '{print $4}')
disk_total=$(echo "$disk_info" | awk '{print $2}')
if [ "$disk_used_pct" -gt 90 ] 2>/dev/null; then
json_finding "critical" "Disk ${disk_used_pct}% full (${disk_avail} free of ${disk_total})" "cleanup" "Consider running cleanup module"
elif [ "$disk_used_pct" -gt 75 ] 2>/dev/null; then
json_finding "warning" "Disk ${disk_used_pct}% full (${disk_avail} free of ${disk_total})" "monitor" ""
else
json_finding "info" "Disk ${disk_used_pct}% full (${disk_avail} free of ${disk_total})" "none" ""
fi
fi
# --- Large directories (top 5 in home) ---
if command -v du &>/dev/null; then
large_dirs=$(du -sh "$HOME"/*/ "$HOME"/.[!.]* 2>/dev/null | sort -hr | head -5 || echo "")
if [ -n "$large_dirs" ]; then
dir_summary=$(echo "$large_dirs" | awk '{printf "%s (%s), ", $2, $1}' | sed 's/, $//')
json_finding "info" "Largest directories in home" "review" "$dir_summary"
fi
fi
# --- Battery (laptops only) ---
battery_info=$(system_profiler SPPowerDataType 2>/dev/null || echo "")
if echo "$battery_info" | grep -q "Cycle Count"; then
cycle_count=$(echo "$battery_info" | grep "Cycle Count" | awk '{print $NF}')
condition=$(echo "$battery_info" | grep "Condition" | awk '{print $NF}')
max_capacity=$(echo "$battery_info" | grep "Maximum Capacity" | awk '{print $NF}' || echo "unknown")
if [ "$condition" = "Normal" ]; then
json_finding "info" "Battery: ${condition}, ${cycle_count} cycles, ${max_capacity} capacity" "none" ""
else
json_finding "warning" "Battery: ${condition}, ${cycle_count} cycles, ${max_capacity} capacity" "review" "Battery may need service"
fi
fi

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# audit_security.sh — Security posture assessment for macOS (read-only)
# Output: JSON lines to stdout, one per finding
set -euo pipefail
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' '; }
json_finding() {
local severity="$1" finding action details
finding=$(json_escape "$2")
action=$(json_escape "$3")
details=$(json_escape "$4")
printf '{"module":"security","severity":"%s","finding":"%s","action":"%s","details":"%s"}\n' \
"$severity" "$finding" "$action" "$details"
}
# --- SIP (System Integrity Protection) ---
sip_status=$(csrutil status 2>/dev/null || echo "unknown")
if echo "$sip_status" | grep -q "enabled"; then
json_finding "info" "SIP enabled" "none" ""
elif echo "$sip_status" | grep -q "disabled"; then
json_finding "critical" "SIP is disabled" "report" "System Integrity Protection should be enabled"
else
json_finding "warning" "SIP status unknown" "report" "$sip_status"
fi
# --- Gatekeeper ---
gatekeeper_status=$(spctl --status 2>/dev/null || echo "unknown")
if echo "$gatekeeper_status" | grep -q "assessments enabled"; then
json_finding "info" "Gatekeeper enabled" "none" ""
elif echo "$gatekeeper_status" | grep -q "assessments disabled"; then
json_finding "critical" "Gatekeeper is disabled" "report" "Enable via: sudo spctl --master-enable"
else
json_finding "warning" "Gatekeeper status unknown" "report" "$gatekeeper_status"
fi
# --- Firewall ---
fw_tool="/usr/libexec/ApplicationFirewall/socketfilterfw"
if [ -x "$fw_tool" ]; then
fw_status=$("$fw_tool" --getglobalstate 2>/dev/null || echo "unknown")
if echo "$fw_status" | grep -qi "enabled"; then
json_finding "info" "Firewall enabled" "none" ""
else
json_finding "critical" "Firewall is disabled" "report" "Enable in System Settings > Network > Firewall"
fi
else
# Fallback to defaults
fw_default=$(defaults read /Library/Preferences/com.apple.alf globalstate 2>/dev/null || echo "unknown")
case "$fw_default" in
0) json_finding "critical" "Firewall is disabled" "report" "Enable in System Settings > Network > Firewall" ;;
1|2) json_finding "info" "Firewall enabled" "none" "" ;;
*) json_finding "warning" "Firewall status unknown" "report" "" ;;
esac
fi
# --- FileVault ---
fv_status=$(fdesetup status 2>/dev/null || echo "unknown")
if echo "$fv_status" | grep -q "FileVault is On"; then
json_finding "info" "FileVault enabled (disk encryption)" "none" ""
elif echo "$fv_status" | grep -q "FileVault is Off"; then
json_finding "critical" "FileVault is off (disk not encrypted)" "report" "Enable in System Settings > Privacy and Security > FileVault"
else
json_finding "warning" "FileVault status unknown" "report" "$fv_status"
fi
# --- Open listening ports ---
listening_ports=$(lsof -iTCP -sTCP:LISTEN -nP 2>/dev/null | tail -n +2 || echo "")
port_count=$(echo "$listening_ports" | grep -c . 2>/dev/null || echo 0)
if [ "$port_count" -gt 0 ]; then
# Extract unique port/process combos
port_summary=$(echo "$listening_ports" | awk '{print $1":"$9}' | sort -u | head -10 | tr '\n' ', ' | sed 's/,$//')
json_finding "info" "${port_count} listening TCP connections" "review" "$port_summary"
fi
# --- SSH config ---
ssh_config="/etc/ssh/sshd_config"
if [ -f "$ssh_config" ]; then
# Check password auth
if grep -qi "^PasswordAuthentication yes" "$ssh_config" 2>/dev/null; then
json_finding "warning" "SSH password authentication enabled" "report" "Consider key-based auth only"
fi
# Check root login
if grep -qi "^PermitRootLogin yes" "$ssh_config" 2>/dev/null; then
json_finding "warning" "SSH root login permitted" "report" "Consider disabling root SSH login"
fi
fi
# --- Remote login ---
remote_login=$(systemsetup -getremotelogin 2>/dev/null || echo "unknown")
if echo "$remote_login" | grep -qi "on"; then
json_finding "info" "Remote Login (SSH) is enabled" "review" "Disable if not needed: System Settings > General > Sharing"
fi
# --- Remote management ---
remote_mgmt=$(defaults read /Library/Preferences/com.apple.RemoteManagement ARD_AllLocalUsers 2>/dev/null || echo "not configured")
if [ "$remote_mgmt" = "1" ]; then
json_finding "warning" "Remote Management enabled for all users" "review" "Check System Settings > General > Sharing"
fi
# --- Software updates ---
# Just check if updates are available (this can be slow, so we time-limit it)
# macOS lacks GNU timeout — use perl alarm as a portable alternative
update_check=$(perl -e 'alarm 30; exec @ARGV' -- softwareupdate -l 2>&1 || echo "timeout or error")
if echo "$update_check" | grep -q "No new software available"; then
json_finding "info" "macOS software is up to date" "none" ""
elif echo "$update_check" | grep -q "timeout or error"; then
json_finding "info" "Software update check timed out" "none" "Run softwareupdate -l manually"
else
update_count=$(echo "$update_check" | grep -c "^\*" 2>/dev/null || echo 0)
json_finding "warning" "${update_count} software update(s) available" "softwareupdate" "Run: softwareupdate -l for details"
fi
# --- Screen lock ---
screen_lock_delay=$(defaults -currentHost read com.apple.screensaver idleTime 2>/dev/null || echo "not set")
ask_password=$(defaults read com.apple.screensaver askForPassword 2>/dev/null || echo "unknown")
if [ "$ask_password" = "0" ]; then
json_finding "warning" "Screen lock password not required" "report" "Enable in System Settings > Lock Screen"
elif [ "$screen_lock_delay" != "not set" ] && [ "$screen_lock_delay" -gt 300 ] 2>/dev/null; then
json_finding "info" "Screen lock delay: ${screen_lock_delay}s (consider reducing)" "report" ""
fi

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# cleanup_execute.sh — Execute cleanup actions on macOS
# Usage: cleanup_execute.sh [--dry-run|--execute] <target> [<target>...]
# Targets: user-logs, user-caches, brew-cache, npm-cache, pip-cache,
# xcode-derived, xcode-archives, ios-support, docker, trash
set -euo pipefail
MODE="dry-run"
TARGETS=()
BACKUP_DIR="${BACKUP_DIR:-$HOME/.config/mac-optimizer-backups}"
usage() {
echo "Usage: $0 [--dry-run|--execute] <target> [<target>...]"
echo ""
echo "Options:"
echo " --dry-run Show what would be deleted (default)"
echo " --execute Actually perform cleanup"
echo ""
echo "Targets:"
echo " user-logs ~/Library/Logs/"
echo " user-caches ~/Library/Caches/"
echo " brew-cache Homebrew download cache"
echo " npm-cache npm cache"
echo " pip-cache pip cache"
echo " xcode-derived Xcode DerivedData"
echo " xcode-archives Xcode Archives"
echo " ios-support iOS DeviceSupport files"
echo " docker Docker system prune"
echo " trash Empty Trash"
exit 1
}
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) MODE="dry-run"; shift ;;
--execute) MODE="execute"; shift ;;
--help|-h) usage ;;
*) TARGETS+=("$1"); shift ;;
esac
done
if [ ${#TARGETS[@]} -eq 0 ]; then
echo "Error: No targets specified"
usage
fi
log_action() {
local target="$1" action="$2" size="$3"
echo "[${MODE}] ${target}: ${action} (${size})"
}
clean_dir() {
local name="$1" path="$2"
if [ ! -d "$path" ]; then
echo "[skip] ${name}: directory not found (${path})"
return
fi
local size=$(du -sh "$path" 2>/dev/null | awk '{print $1}')
if [ "$MODE" = "dry-run" ]; then
log_action "$name" "would remove contents of ${path}" "$size"
else
log_action "$name" "removing contents of ${path}" "$size"
find "$path" -mindepth 1 -delete 2>/dev/null || {
# Fallback: remove what we can
find "$path" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
}
echo "[done] ${name}: cleaned"
fi
}
for target in "${TARGETS[@]}"; do
case "$target" in
user-logs)
clean_dir "User Logs" "$HOME/Library/Logs"
;;
user-caches)
clean_dir "User Caches" "$HOME/Library/Caches"
;;
brew-cache)
if command -v brew &>/dev/null; then
brew_cache=$(brew --cache 2>/dev/null || echo "")
if [ -n "$brew_cache" ]; then
size=$(du -sh "$brew_cache" 2>/dev/null | awk '{print $1}')
if [ "$MODE" = "dry-run" ]; then
log_action "Homebrew Cache" "would run brew cleanup" "$size"
else
log_action "Homebrew Cache" "running brew cleanup" "$size"
brew cleanup --prune=0 2>/dev/null || true
echo "[done] Homebrew Cache: cleaned"
fi
fi
else
echo "[skip] brew-cache: Homebrew not installed"
fi
;;
npm-cache)
if command -v npm &>/dev/null; then
npm_cache="$HOME/.npm/_cacache"
if [ -d "$npm_cache" ]; then
size=$(du -sh "$npm_cache" 2>/dev/null | awk '{print $1}')
if [ "$MODE" = "dry-run" ]; then
log_action "npm Cache" "would run npm cache clean --force" "$size"
else
log_action "npm Cache" "running npm cache clean" "$size"
npm cache clean --force 2>/dev/null || true
echo "[done] npm Cache: cleaned"
fi
fi
else
echo "[skip] npm-cache: npm not installed"
fi
;;
pip-cache)
pip_cache="$HOME/Library/Caches/pip"
if [ -d "$pip_cache" ]; then
size=$(du -sh "$pip_cache" 2>/dev/null | awk '{print $1}')
if [ "$MODE" = "dry-run" ]; then
log_action "pip Cache" "would run pip cache purge" "$size"
else
log_action "pip Cache" "running pip cache purge" "$size"
pip3 cache purge 2>/dev/null || pip cache purge 2>/dev/null || true
echo "[done] pip Cache: cleaned"
fi
else
echo "[skip] pip-cache: no pip cache found"
fi
;;
xcode-derived)
clean_dir "Xcode DerivedData" "$HOME/Library/Developer/Xcode/DerivedData"
;;
xcode-archives)
clean_dir "Xcode Archives" "$HOME/Library/Developer/Xcode/Archives"
;;
ios-support)
clean_dir "iOS DeviceSupport" "$HOME/Library/Developer/Xcode/iOS DeviceSupport"
;;
docker)
if command -v docker &>/dev/null; then
if [ "$MODE" = "dry-run" ]; then
echo "[dry-run] Docker: would run docker system prune"
docker system df 2>/dev/null || echo " (Docker not running)"
else
echo "[execute] Docker: running docker system prune -f"
docker system prune -f 2>/dev/null || echo " Docker prune failed (is Docker running?)"
fi
else
echo "[skip] docker: Docker not installed"
fi
;;
trash)
trash_dir="$HOME/.Trash"
if [ -d "$trash_dir" ]; then
size=$(du -sh "$trash_dir" 2>/dev/null | awk '{print $1}')
if [ "$MODE" = "dry-run" ]; then
log_action "Trash" "would empty Trash" "$size"
else
log_action "Trash" "emptying Trash" "$size"
rm -rf "$trash_dir"/* 2>/dev/null || true
rm -rf "$trash_dir"/.[!.]* 2>/dev/null || true
echo "[done] Trash: emptied"
fi
fi
;;
*)
echo "[error] Unknown target: ${target}"
;;
esac
done