|
#!/usr/bin/env bash |
|
# ============================================================================ |
|
# Document Personal AI Setup |
|
# |
|
# One-liner: |
|
# curl -fsSL https://gist.githubusercontent.com/viktorbezdek/b9a5b6d962723857949b6cacbab5cd03/raw/document-ai-setup.sh | bash |
|
# |
|
# What it does: |
|
# Scans your macOS machine for all Claude Code / Claude Desktop / AI |
|
# configuration and produces a comprehensive markdown document. |
|
# Includes current config, backup configs, session analysis, and |
|
# an AI usage profile. |
|
# |
|
# Requirements: |
|
# - macOS (bash 3.2+ which ships with macOS) |
|
# - python3 (for session analysis — ships with Xcode CLI tools) |
|
# |
|
# Does NOT require: |
|
# - Claude Code CLI |
|
# - Claude Desktop |
|
# - Any API keys or authentication |
|
# - Internet access |
|
# ============================================================================ |
|
|
|
set -euo pipefail |
|
|
|
TIMESTAMP=$(date +%Y-%m-%d) |
|
USERNAME_SAFE=$(whoami | tr -dc 'a-zA-Z0-9._-') |
|
HOST_SHORT=$(hostname -s | tr -dc 'a-zA-Z0-9._-') |
|
OUTPUT_FILE="./ai-setup-${USERNAME_SAFE}-${TIMESTAMP}.md" |
|
|
|
# Colors |
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m' |
|
CYAN='\033[0;36m'; DIM='\033[0;90m'; BOLD='\033[1m'; NC='\033[0m' |
|
|
|
info() { printf "${CYAN}▸${NC} %s\n" "$*"; } |
|
ok() { printf "${GREEN}✓${NC} %s\n" "$*"; } |
|
warn() { printf "${YELLOW}⚠${NC} %s\n" "$*"; } |
|
fail() { printf "${RED}✗${NC} %s\n" "$*"; exit 1; } |
|
|
|
# ── Preflight ────────────────────────────────────────────────────────────── |
|
|
|
echo "" |
|
printf "${BOLD}Document Personal AI Setup${NC}\n" |
|
printf "${DIM}Scans your Claude Code / Claude Desktop configuration and produces${NC}\n" |
|
printf "${DIM}a comprehensive markdown report. No AI or API keys needed.${NC}\n" |
|
echo "" |
|
|
|
command -v python3 >/dev/null 2>&1 || fail "python3 not found. Install Xcode CLI tools: xcode-select --install" |
|
|
|
# ── Collect all data via Python ──────────────────────────────────────────── |
|
|
|
info "Scanning your AI setup..." |
|
|
|
python3 - "$OUTPUT_FILE" "$USERNAME_SAFE" "$HOST_SHORT" "$TIMESTAMP" << 'PYTHON_SCRIPT' |
|
import json, os, sys, glob, subprocess, re |
|
from collections import Counter, defaultdict |
|
from datetime import datetime |
|
from pathlib import Path |
|
|
|
OUTPUT_FILE = sys.argv[1] |
|
USERNAME = sys.argv[2] |
|
HOSTNAME = sys.argv[3] |
|
TIMESTAMP = sys.argv[4] |
|
HOME = os.path.expanduser("~") |
|
CLAUDE_DIR = os.path.join(HOME, ".claude") |
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────── |
|
|
|
def run(cmd): |
|
try: |
|
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10).stdout.strip() |
|
except: |
|
return "" |
|
|
|
def read_file(path, max_lines=None): |
|
try: |
|
with open(path) as f: |
|
if max_lines: |
|
return "".join(f.readline() for _ in range(max_lines)) |
|
return f.read() |
|
except: |
|
return "" |
|
|
|
def file_stat(path): |
|
try: |
|
s = os.stat(path) |
|
mod = datetime.fromtimestamp(s.st_mtime).strftime("%Y-%m-%d %H:%M") |
|
size = s.st_size |
|
if size > 1048576: |
|
return mod, f"{size/1048576:.1f} MB" |
|
elif size > 1024: |
|
return mod, f"{size/1024:.1f} KB" |
|
return mod, f"{size} B" |
|
except: |
|
return "unknown", "unknown" |
|
|
|
def dir_size(path): |
|
total = 0 |
|
try: |
|
for dirpath, _, filenames in os.walk(path): |
|
for f in filenames: |
|
try: |
|
total += os.path.getsize(os.path.join(dirpath, f)) |
|
except: |
|
pass |
|
except: |
|
pass |
|
if total > 1048576: |
|
return f"{total/1048576:.1f} MB" |
|
elif total > 1024: |
|
return f"{total/1024:.1f} KB" |
|
return f"{total} B" |
|
|
|
def dir_tree(path, prefix="", max_depth=3, depth=0): |
|
if depth >= max_depth: |
|
return "" |
|
lines = [] |
|
try: |
|
entries = sorted(os.listdir(path)) |
|
except: |
|
return "" |
|
# Filter out noise |
|
skip = {"node_modules", ".DS_Store", "__pycache__", ".git"} |
|
entries = [e for e in entries if e not in skip] |
|
for i, entry in enumerate(entries): |
|
full = os.path.join(path, entry) |
|
connector = "└── " if i == len(entries) - 1 else "├── " |
|
if os.path.isfile(full): |
|
# Skip large binary/db files |
|
if entry.endswith((".db", ".db-shm", ".db-wal", ".jsonl", ".zst")): |
|
_, sz = file_stat(full) |
|
lines.append(f"{prefix}{connector}{entry} ({sz})") |
|
else: |
|
lines.append(f"{prefix}{connector}{entry}") |
|
elif os.path.isdir(full): |
|
lines.append(f"{prefix}{connector}{entry}/") |
|
ext = " " if i == len(entries) - 1 else "│ " |
|
lines.append(dir_tree(full, prefix + ext, max_depth, depth + 1)) |
|
return "\n".join(lines) |
|
|
|
def redact(text): |
|
"""Replace API keys/tokens/secrets with REDACTED""" |
|
patterns = [ |
|
(r'(sk-[a-zA-Z0-9_-]{10,})', '***REDACTED***'), |
|
(r'(xoxb-[a-zA-Z0-9-]+)', '***REDACTED***'), |
|
(r'(ghp_[a-zA-Z0-9]+)', '***REDACTED***'), |
|
(r'(gho_[a-zA-Z0-9]+)', '***REDACTED***'), |
|
(r'("(?:key|token|secret|password|api_key|apiKey|auth)["\s]*:\s*")[^"]+(")', r'\1***REDACTED***\2'), |
|
(r'(export\s+\w*(?:KEY|TOKEN|SECRET|PASSWORD)\w*=)[^\s]+', r'\1***REDACTED***'), |
|
] |
|
for pat, repl in patterns: |
|
text = re.sub(pat, repl, text, flags=re.IGNORECASE) |
|
return text |
|
|
|
def extract_frontmatter(path): |
|
"""Extract name/description from YAML frontmatter""" |
|
content = read_file(path, 20) |
|
if not content.startswith("---"): |
|
return None, None |
|
try: |
|
end = content.index("---", 3) |
|
fm = content[3:end] |
|
name = desc = None |
|
for line in fm.split("\n"): |
|
if line.startswith("name:"): |
|
name = line.split(":", 1)[1].strip().strip('"').strip("'") |
|
if line.startswith("description:"): |
|
desc = line.split(":", 1)[1].strip().strip('"').strip("'") |
|
return name, desc |
|
except: |
|
return None, None |
|
|
|
|
|
out = [] |
|
def w(line=""): |
|
out.append(line) |
|
|
|
# ── Start document ─────────────────────────────────────────────────────── |
|
|
|
w(f"# Personal AI Setup — {USERNAME}@{HOSTNAME}") |
|
w() |
|
w(f"**Generated:** {TIMESTAMP} ") |
|
w(f"**Machine:** {run('uname -m')} macOS {run('sw_vers -productVersion 2>/dev/null')}") |
|
w() |
|
|
|
# Collect stats for TOC and summary |
|
stats = {} |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 1: Installation |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 1. Claude Code Installation") |
|
w() |
|
|
|
claude_version = run("claude --version 2>/dev/null") |
|
claude_path = run("which claude 2>/dev/null") |
|
node_version = run("node --version 2>/dev/null") |
|
bun_version = run("bun --version 2>/dev/null") |
|
|
|
w("| Component | Value |") |
|
w("|-----------|-------|") |
|
w(f"| Claude Code version | `{claude_version or 'not installed'}` |") |
|
w(f"| Claude Code path | `{claude_path or 'not found'}` |") |
|
w(f"| Node.js version | `{node_version or 'not installed'}` |") |
|
w(f"| Bun version | `{bun_version or 'not installed'}` |") |
|
w() |
|
|
|
# Check shell config for claude references |
|
for rc in [".zshrc", ".bashrc", ".zprofile", ".bash_profile"]: |
|
rc_path = os.path.join(HOME, rc) |
|
if os.path.exists(rc_path): |
|
content = read_file(rc_path) |
|
claude_lines = [l.strip() for l in content.split("\n") if "claude" in l.lower() and not l.strip().startswith("#")] |
|
if claude_lines: |
|
w(f"**Claude references in `~/{rc}`:**") |
|
w("```bash") |
|
for l in claude_lines[:10]: |
|
w(redact(l)) |
|
w("```") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 2: Global Configuration |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 2. Global Configuration (`~/.claude/`)") |
|
w() |
|
|
|
if os.path.isdir(CLAUDE_DIR): |
|
w("### Directory tree") |
|
w() |
|
w("```text") |
|
w(".claude/") |
|
w(dir_tree(CLAUDE_DIR, max_depth=2)) |
|
w("```") |
|
w() |
|
|
|
for cfg in ["settings.json", "settings.local.json", "keybindings.json"]: |
|
cfg_path = os.path.join(CLAUDE_DIR, cfg) |
|
if os.path.exists(cfg_path): |
|
content = read_file(cfg_path) |
|
w(f"### `~/.claude/{cfg}`") |
|
w() |
|
w("```json") |
|
w(redact(content.rstrip())) |
|
w("```") |
|
w() |
|
else: |
|
w("*`~/.claude/` directory does not exist.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 3: Global Rules |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 3. Global Rules") |
|
w() |
|
|
|
rules_dir = os.path.join(CLAUDE_DIR, "rules") |
|
rule_files = [] |
|
if os.path.isdir(rules_dir): |
|
for root, _, files in os.walk(rules_dir): |
|
for f in sorted(files): |
|
if f.endswith(".md"): |
|
rule_files.append(os.path.join(root, f)) |
|
|
|
stats["rules"] = len(rule_files) |
|
|
|
if rule_files: |
|
w(f"**{len(rule_files)} rule files found.**") |
|
w() |
|
w("| File | First line |") |
|
w("|------|-----------|") |
|
for rf in rule_files: |
|
rel = os.path.relpath(rf, HOME) |
|
first = read_file(rf, 1).strip().replace("|", "\\|")[:80] |
|
w(f"| `~/{rel}` | {first} |") |
|
w() |
|
else: |
|
w("*No rules configured.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 4: Installed Skills |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 4. Installed Skills") |
|
w() |
|
|
|
skills_dir = os.path.join(CLAUDE_DIR, "skills") |
|
skills = [] |
|
if os.path.isdir(skills_dir): |
|
for d in sorted(os.listdir(skills_dir)): |
|
skill_path = os.path.join(skills_dir, d) |
|
if os.path.isdir(skill_path): |
|
skill_md = os.path.join(skill_path, "SKILL.md") |
|
name, desc = None, None |
|
if os.path.exists(skill_md): |
|
name, desc = extract_frontmatter(skill_md) |
|
has_scripts = os.path.isdir(os.path.join(skill_path, "scripts")) |
|
has_templates = os.path.isdir(os.path.join(skill_path, "templates")) |
|
skills.append((d, name or d, desc or "", has_scripts, has_templates)) |
|
|
|
stats["skills"] = len(skills) |
|
|
|
if skills: |
|
w(f"**{len(skills)} skills installed.**") |
|
w() |
|
w("| Skill | Description | Scripts | Templates |") |
|
w("|-------|-------------|---------|-----------|") |
|
for dirname, name, desc, has_s, has_t in skills: |
|
desc_short = (desc[:70] + "...") if len(desc) > 73 else desc |
|
w(f"| {name} | {desc_short} | {'yes' if has_s else '-'} | {'yes' if has_t else '-'} |") |
|
w() |
|
else: |
|
w("*No skills installed.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 5: Custom Agents |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 5. Custom Agents") |
|
w() |
|
|
|
agents_dir = os.path.join(CLAUDE_DIR, "agents") |
|
agents_by_category = defaultdict(list) |
|
if os.path.isdir(agents_dir): |
|
for root, _, files in os.walk(agents_dir): |
|
for f in sorted(files): |
|
if f.endswith(".md"): |
|
path = os.path.join(root, f) |
|
rel = os.path.relpath(os.path.dirname(path), agents_dir) |
|
category = rel if rel != "." else "root" |
|
name, desc = extract_frontmatter(path) |
|
agents_by_category[category].append((name or f.replace(".md",""), desc or "", f)) |
|
|
|
total_agents = sum(len(v) for v in agents_by_category.values()) |
|
stats["agents"] = total_agents |
|
|
|
if agents_by_category: |
|
w(f"**{total_agents} agents across {len(agents_by_category)} categories.**") |
|
w() |
|
for cat in sorted(agents_by_category.keys()): |
|
agents = agents_by_category[cat] |
|
w(f"### {cat} ({len(agents)})") |
|
w() |
|
w("| Agent | Description |") |
|
w("|-------|-------------|") |
|
for name, desc, _ in agents: |
|
desc_short = (desc[:80] + "...") if len(desc) > 83 else desc |
|
w(f"| {name} | {desc_short} |") |
|
w() |
|
else: |
|
w("*No custom agents configured.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 6: MCP Servers |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 6. MCP Server Configuration") |
|
w() |
|
|
|
mcp_servers = [] |
|
for mcp_file in [os.path.join(HOME, ".claude.json"), os.path.join(CLAUDE_DIR, "mcp.json")]: |
|
if os.path.exists(mcp_file): |
|
w(f"### `{os.path.relpath(mcp_file, HOME)}`") |
|
w() |
|
content = read_file(mcp_file) |
|
w("```json") |
|
w(redact(content.rstrip())) |
|
w("```") |
|
w() |
|
try: |
|
data = json.loads(content) |
|
servers = data.get("mcpServers", data.get("servers", {})) |
|
if isinstance(servers, dict): |
|
mcp_servers.extend(servers.keys()) |
|
except: |
|
pass |
|
|
|
stats["mcp_servers"] = len(mcp_servers) |
|
|
|
if not mcp_servers: |
|
w("*No MCP server configuration found.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 7: Project-Level Configurations |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 7. Project-Level Configurations") |
|
w() |
|
|
|
project_dirs = [] |
|
search_roots = [os.path.join(HOME, d) for d in ["Work", "Projects", "Developer", "Code", "src"]] |
|
for search_root in search_roots: |
|
if not os.path.isdir(search_root): |
|
continue |
|
for root, dirs, files in os.walk(search_root): |
|
depth = root.replace(search_root, "").count(os.sep) |
|
if depth > 3: |
|
dirs.clear() |
|
continue |
|
if ".claude" in dirs: |
|
proj_claude = os.path.join(root, ".claude") |
|
has_agents = os.path.isdir(os.path.join(proj_claude, "agents")) |
|
has_skills = os.path.isdir(os.path.join(proj_claude, "skills")) |
|
has_settings = os.path.exists(os.path.join(proj_claude, "settings.json")) |
|
has_hooks = False |
|
if has_settings: |
|
s = read_file(os.path.join(proj_claude, "settings.json")) |
|
has_hooks = '"hooks"' in s |
|
has_claude_md = os.path.exists(os.path.join(root, "CLAUDE.md")) |
|
project_dirs.append((root, has_agents, has_skills, has_settings, has_hooks, has_claude_md)) |
|
elif "CLAUDE.md" in files and ".claude" not in dirs: |
|
project_dirs.append((root, False, False, False, False, True)) |
|
|
|
stats["projects"] = len(project_dirs) |
|
|
|
if project_dirs: |
|
w(f"**{len(project_dirs)} projects with Claude configuration.**") |
|
w() |
|
w("| Project | Agents | Skills | Settings | Hooks | CLAUDE.md |") |
|
w("|---------|--------|--------|----------|-------|-----------|") |
|
for path, a, sk, se, h, cm in project_dirs: |
|
name = os.path.relpath(path, HOME) |
|
w(f"| `~/{name}` | {'yes' if a else '-'} | {'yes' if sk else '-'} | {'yes' if se else '-'} | {'yes' if h else '-'} | {'yes' if cm else '-'} |") |
|
w() |
|
else: |
|
w("*No project-level configurations found.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 8: Backups and Previous Configurations |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 8. Backup and Previous Configurations") |
|
w() |
|
|
|
# Master discovery |
|
claude_items = run(f"find {HOME} -maxdepth 3 -name '*claude*' 2>/dev/null").split("\n") |
|
claude_items = [x.strip() for x in claude_items if x.strip()] |
|
|
|
# Classify |
|
backups = [] |
|
ecosystem = [] |
|
backup_patterns = ["backup", ".bak", ".old", ".prev", "-orig", ".save", "Trash"] |
|
ecosystem_names = {".claude-flow", ".claude-mem", ".claude-server-commander", ".cache"} |
|
|
|
for item in claude_items: |
|
basename = os.path.basename(item) |
|
parent = os.path.basename(os.path.dirname(item)) |
|
# Skip the main config and project dirs |
|
if item == os.path.join(HOME, ".claude") or item == os.path.join(HOME, ".claude.json"): |
|
continue |
|
if "/Work/" in item or "/Projects/" in item or "/Developer/" in item: |
|
continue |
|
if any(p in item.lower() for p in backup_patterns): |
|
backups.append(item) |
|
elif any(e in basename or e in parent for e in ecosystem_names): |
|
ecosystem.append(item) |
|
elif basename.startswith(".claude-") and os.path.isdir(item) and item != CLAUDE_DIR: |
|
# Any .claude-* directory in home that isn't the main one could be a backup |
|
backups.append(item) |
|
|
|
# Also check broader locations |
|
broader = run(f"find {HOME}/Desktop {HOME}/Documents {HOME}/Downloads -maxdepth 2 -name '*claude*' 2>/dev/null") |
|
if broader: |
|
for item in broader.split("\n"): |
|
if item.strip() and item.strip() not in claude_items: |
|
backups.append(item.strip()) |
|
|
|
stats["backups"] = len(backups) |
|
|
|
if backups: |
|
w(f"### Backup configurations ({len(backups)} found)") |
|
w() |
|
for bp in sorted(backups): |
|
mod, size = file_stat(bp) if os.path.isfile(bp) else ("", dir_size(bp)) |
|
if not mod and os.path.isdir(bp): |
|
mod_ts = run(f"stat -f '%m' '{bp}' 2>/dev/null") |
|
if mod_ts: |
|
try: |
|
mod = datetime.fromtimestamp(int(mod_ts)).strftime("%Y-%m-%d %H:%M") |
|
except: |
|
mod = "unknown" |
|
w(f"#### `{os.path.relpath(bp, HOME)}`") |
|
w() |
|
w(f"- **Modified:** {mod}") |
|
w(f"- **Size:** {size}") |
|
if os.path.isdir(bp): |
|
w("- **Contents:**") |
|
w("```text") |
|
w(dir_tree(bp, max_depth=2)) |
|
w("```") |
|
# Compare with current if it has settings/skills |
|
bp_skills = os.path.join(bp, "skills") |
|
bp_settings = os.path.join(bp, "settings.json") |
|
if os.path.isdir(bp_skills): |
|
bp_count = len([d for d in os.listdir(bp_skills) if os.path.isdir(os.path.join(bp_skills, d))]) |
|
w(f"- **Skills in backup:** {bp_count} (current: {stats.get('skills', '?')})") |
|
if os.path.exists(bp_settings): |
|
w("- **settings.json preview:**") |
|
w("```json") |
|
w(redact(read_file(bp_settings, 20).rstrip())) |
|
w("```") |
|
elif os.path.isfile(bp) and bp.endswith(".json"): |
|
w("- **Content preview:**") |
|
w("```json") |
|
w(redact(read_file(bp, 20).rstrip())) |
|
w("```") |
|
w() |
|
else: |
|
w("*No backup configurations found.*") |
|
w() |
|
|
|
if ecosystem: |
|
w(f"### Related ecosystem tools ({len(ecosystem)} found)") |
|
w() |
|
w("| Path | Type | Size | Modified |") |
|
w("|------|------|------|----------|") |
|
for ep in sorted(ecosystem): |
|
rel = os.path.relpath(ep, HOME) |
|
is_dir = os.path.isdir(ep) |
|
mod, size = file_stat(ep) if os.path.isfile(ep) else ("", dir_size(ep)) |
|
if is_dir: |
|
mod_ts = run(f"stat -f '%m' '{ep}' 2>/dev/null") |
|
try: mod = datetime.fromtimestamp(int(mod_ts)).strftime("%Y-%m-%d") |
|
except: mod = "" |
|
w(f"| `~/{rel}` | {'dir' if is_dir else 'file'} | {size} | {mod} |") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 9: Environment Variables |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 9. Environment Variables") |
|
w() |
|
|
|
env_vars = [] |
|
for rc in [".zshrc", ".bashrc", ".zprofile", ".bash_profile"]: |
|
rc_path = os.path.join(HOME, rc) |
|
if os.path.exists(rc_path): |
|
for line in read_file(rc_path).split("\n"): |
|
ls = line.strip() |
|
if ls.startswith("#"): |
|
continue |
|
if re.search(r'(CLAUDE_|ANTHROPIC_|OPENAI_|LITELLM_|LANGFUSE_)', ls): |
|
# Extract variable name |
|
m = re.match(r'export\s+(\w+)', ls) |
|
if m: |
|
env_vars.append((m.group(1), rc)) |
|
|
|
if env_vars: |
|
w("| Variable | Set in |") |
|
w("|----------|--------|") |
|
for var, source in env_vars: |
|
w(f"| `{var}` | `~/{source}` |") |
|
w() |
|
w("*All values redacted. Only variable names shown.*") |
|
else: |
|
w("*No AI-related environment variables found in shell config files.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 10: Hooks |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 10. Hooks Configuration") |
|
w() |
|
|
|
settings_path = os.path.join(CLAUDE_DIR, "settings.json") |
|
if os.path.exists(settings_path): |
|
try: |
|
settings = json.loads(read_file(settings_path)) |
|
hooks = settings.get("hooks", {}) |
|
if hooks: |
|
w(f"**{sum(len(v) for v in hooks.values())} hook entries across {len(hooks)} event types.**") |
|
w() |
|
for event, entries in hooks.items(): |
|
w(f"### `{event}`") |
|
w() |
|
w("| Matcher | Command |") |
|
w("|---------|---------|") |
|
for entry in entries: |
|
matcher = entry.get("matcher", "(all)") |
|
for h in entry.get("hooks", []): |
|
cmd = h.get("command", "?") |
|
w(f"| `{matcher}` | `{cmd[:80]}` |") |
|
w() |
|
else: |
|
w("*No hooks configured.*") |
|
w() |
|
except: |
|
w("*Could not parse settings.json for hooks.*") |
|
w() |
|
else: |
|
w("*No settings.json found.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 11: Plugins and Extensions |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 11. Plugins and Extensions") |
|
w() |
|
|
|
if os.path.exists(settings_path): |
|
try: |
|
settings = json.loads(read_file(settings_path)) |
|
plugins = settings.get("enabledPlugins", {}) |
|
if plugins: |
|
w("### Enabled plugins") |
|
w() |
|
w("| Plugin | Enabled |") |
|
w("|--------|---------|") |
|
for p, v in plugins.items(): |
|
w(f"| `{p}` | {v} |") |
|
w() |
|
except: |
|
pass |
|
|
|
vscode_ext = run("code --list-extensions 2>/dev/null | grep -i claude") |
|
if vscode_ext: |
|
w("### VS Code extensions") |
|
w() |
|
for ext in vscode_ext.split("\n"): |
|
if ext.strip(): |
|
w(f"- `{ext.strip()}`") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 12: Session History |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 12. Session History and Memory") |
|
w() |
|
|
|
mem_db = os.path.join(CLAUDE_DIR, "memory.db") |
|
if os.path.exists(mem_db): |
|
mod, size = file_stat(mem_db) |
|
w(f"- **memory.db**: {size}, last modified {mod}") |
|
|
|
session_data = os.path.join(CLAUDE_DIR, "session-data") |
|
if os.path.isdir(session_data): |
|
count = len(os.listdir(session_data)) |
|
w(f"- **session-data/**: {count} files") |
|
|
|
projects_dir = os.path.join(CLAUDE_DIR, "projects") |
|
if os.path.isdir(projects_dir): |
|
w() |
|
w("### Projects with session data") |
|
w() |
|
w("| Project | Sessions | Size |") |
|
w("|---------|----------|------|") |
|
for d in sorted(os.listdir(projects_dir)): |
|
pdir = os.path.join(projects_dir, d) |
|
if not os.path.isdir(pdir): |
|
continue |
|
jsonl_count = len(glob.glob(os.path.join(pdir, "*.jsonl"))) |
|
if jsonl_count > 0: |
|
sz = dir_size(pdir) |
|
# Decode name |
|
decoded = d.replace("-Users-" + USERNAME + "-", "").replace("-", "/") |
|
w(f"| {decoded} | {jsonl_count} | {sz} |") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 13: Session Analysis |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 13. Session Analysis") |
|
w() |
|
w("*Includes prompt summaries (truncated) and commands. Secrets are redacted.*") |
|
w() |
|
|
|
sessions = glob.glob(os.path.join(projects_dir, "*", "*.jsonl")) if os.path.isdir(projects_dir) else [] |
|
|
|
# ── Helpers for prompt/command extraction ── |
|
|
|
_TAG_RE = re.compile(r'<[^>]+>') |
|
_MULTI_WS = re.compile(r'\s+') |
|
|
|
def clean_prompt(raw): |
|
"""Strip XML system tags, collapse whitespace, truncate.""" |
|
if not raw: |
|
return "" |
|
text = _TAG_RE.sub('', raw) |
|
text = _MULTI_WS.sub(' ', text).strip() |
|
return redact(text[:120]) + ("..." if len(text) > 120 else "") |
|
|
|
_PROMPT_CATEGORIES = [ |
|
(re.compile(r'\b(fix|bug|error|broken|crash|fail|issue|wrong)\b', re.I), "bug-fix"), |
|
(re.compile(r'\b(refactor|rename|cleanup|clean up|extract|move|reorganize)\b', re.I), "refactor"), |
|
(re.compile(r'\b(test|spec|coverage|tdd|assert)\b', re.I), "testing"), |
|
(re.compile(r'\b(add|create|implement|build|new|feature|support)\b', re.I), "feature"), |
|
(re.compile(r'\b(deploy|push|publish|release|ship|merge|pr|pull request)\b', re.I), "deploy/git"), |
|
(re.compile(r'\b(explain|what|how|why|show|describe|doc|readme)\b', re.I), "exploration"), |
|
(re.compile(r'\b(config|setup|install|env|init)\b', re.I), "config/setup"), |
|
] |
|
|
|
def classify_prompt(text): |
|
for pat, label in _PROMPT_CATEGORIES: |
|
if pat.search(text): |
|
return label |
|
return "other" |
|
|
|
total_sessions = 0 |
|
total_messages = Counter() |
|
total_tool_calls = Counter() |
|
proj_stats = {} |
|
session_durations = [] |
|
hourly_activity = Counter() |
|
weekly_activity = Counter() |
|
monthly_activity = Counter() |
|
sessions_by_length = {"short (<10)": 0, "medium (10-50)": 0, "long (50-200)": 0, "marathon (200+)": 0} |
|
|
|
# Prompt & command extraction accumulators |
|
all_session_summaries = [] # list of (project, date, opening_prompt, commands[], skills[]) |
|
prompt_categories = Counter() # category -> count |
|
all_bash_commands = Counter() # command prefix -> count |
|
all_skills_used = Counter() # skill name -> count |
|
prompt_lengths = [] # char lengths of user prompts |
|
|
|
for sf in sessions: |
|
try: |
|
pname = os.path.basename(os.path.dirname(sf)) |
|
fsize = os.path.getsize(sf) |
|
if pname not in proj_stats: |
|
proj_stats[pname] = {"sessions": 0, "user": 0, "assistant": 0, "tools": Counter(), "first": None, "last": None, "size": 0} |
|
proj_stats[pname]["sessions"] += 1 |
|
proj_stats[pname]["size"] += fsize |
|
total_sessions += 1 |
|
timestamps, uc, ac = [], 0, 0 |
|
# Per-session extraction |
|
sess_opening = "" |
|
sess_commands = [] |
|
sess_skills = [] |
|
sess_date = "" |
|
with open(sf) as f: |
|
for line in f: |
|
try: |
|
d = json.loads(line) |
|
t = d.get("type") |
|
ts = d.get("timestamp") |
|
if ts: timestamps.append(ts) |
|
if t == "user": |
|
uc += 1; total_messages["user"] += 1 |
|
raw = d.get("message", {}).get("content", "") |
|
if isinstance(raw, list): |
|
raw = " ".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in raw) |
|
# Skip system/command noise |
|
stripped = _TAG_RE.sub('', raw).strip() |
|
if stripped and not stripped.startswith("DONE|"): |
|
prompt_lengths.append(len(stripped)) |
|
cat = classify_prompt(stripped) |
|
prompt_categories[cat] += 1 |
|
if not sess_opening: |
|
sess_opening = clean_prompt(raw) |
|
if ts: |
|
try: sess_date = ts[:10] |
|
except: pass |
|
elif t == "assistant": |
|
ac += 1; total_messages["assistant"] += 1 |
|
content = d.get("message", {}).get("content", []) |
|
if isinstance(content, list): |
|
for block in content: |
|
if isinstance(block, dict) and block.get("type") == "tool_use": |
|
name = block.get("name", "?") |
|
total_tool_calls[name] += 1 |
|
proj_stats[pname]["tools"][name] += 1 |
|
inp = block.get("input", {}) |
|
if name == "Bash": |
|
cmd = inp.get("command", "") |
|
if cmd: |
|
# Extract meaningful command prefix (skip comments, assignments, env vars) |
|
first_line = cmd.strip().split("\n")[0].strip() |
|
# Skip lines that are just comments or var assignments |
|
if first_line and not first_line.startswith("#") and "=" not in first_line.split()[0]: |
|
prefix = first_line.split()[0].split("/")[-1] if first_line.split() else "" |
|
# Skip common shell builtins that are noise |
|
if prefix and prefix not in ("set", "export", "unset", "true", "false"): |
|
all_bash_commands[prefix] += 1 |
|
if len(sess_commands) < 20: |
|
sess_commands.append(redact(cmd[:100])) |
|
elif name == "Skill": |
|
skill = inp.get("skill", "?") |
|
all_skills_used[skill] += 1 |
|
sess_skills.append(skill) |
|
except: pass |
|
proj_stats[pname]["user"] += uc |
|
proj_stats[pname]["assistant"] += ac |
|
mt = uc + ac |
|
if mt < 10: sessions_by_length["short (<10)"] += 1 |
|
elif mt < 50: sessions_by_length["medium (10-50)"] += 1 |
|
elif mt < 200: sessions_by_length["long (50-200)"] += 1 |
|
else: sessions_by_length["marathon (200+)"] += 1 |
|
if timestamps: |
|
first, last = timestamps[0], timestamps[-1] |
|
ps = proj_stats[pname] |
|
if ps["first"] is None or first < ps["first"]: ps["first"] = first |
|
if ps["last"] is None or last > ps["last"]: ps["last"] = last |
|
try: |
|
t0 = datetime.fromisoformat(first.replace("Z","+00:00")) |
|
t1 = datetime.fromisoformat(last.replace("Z","+00:00")) |
|
dur = (t1 - t0).total_seconds() / 60 |
|
if dur > 0: session_durations.append(dur) |
|
hourly_activity[t0.hour] += 1 |
|
weekly_activity[t0.strftime("%A")] += 1 |
|
monthly_activity[t0.strftime("%Y-%m")] += 1 |
|
except: pass |
|
# Store session summary (only if there was a real prompt) |
|
if sess_opening: |
|
decoded_pname = pname.replace("-Users-" + USERNAME + "-", "").replace("-", "/") |
|
all_session_summaries.append((decoded_pname, sess_date, sess_opening, sess_commands[:10], sess_skills)) |
|
except: pass |
|
|
|
stats["sessions"] = total_sessions |
|
stats["messages"] = total_messages["user"] + total_messages["assistant"] |
|
stats["hours"] = round(sum(session_durations) / 60, 1) if session_durations else 0 |
|
|
|
if total_sessions > 0: |
|
# 13a: Overall |
|
w("### 13a. Overall Activity") |
|
w() |
|
all_firsts = [ps["first"] for ps in proj_stats.values() if ps["first"]] |
|
all_lasts = [ps["last"] for ps in proj_stats.values() if ps["last"]] |
|
w(f"- **Total sessions:** {total_sessions}") |
|
w(f"- **Total messages:** {total_messages['user']} user + {total_messages['assistant']} assistant = {sum(total_messages.values())}") |
|
w(f"- **Messages per session:** {sum(total_messages.values()) / total_sessions:.1f} avg") |
|
if all_firsts and all_lasts: |
|
w(f"- **Date range:** {min(all_firsts)[:10]} to {max(all_lasts)[:10]}") |
|
w(f"- **Total AI session time:** {stats['hours']} hours") |
|
w() |
|
|
|
# 13b: Project breakdown |
|
w("### 13b. Project Breakdown") |
|
w() |
|
w("| Project | Sessions | User | Assistant | Top Tool | First | Last | Size |") |
|
w("|---------|----------|------|-----------|----------|-------|------|------|") |
|
for pn, ps in sorted(proj_stats.items(), key=lambda x: -x[1]["sessions"]): |
|
decoded = pn.replace("-Users-" + USERNAME + "-", "").replace("-", "/") |
|
top_tool = ps["tools"].most_common(1)[0][0] if ps["tools"] else "-" |
|
first_d = ps["first"][:10] if ps["first"] else "-" |
|
last_d = ps["last"][:10] if ps["last"] else "-" |
|
sz = f"{ps['size']/1048576:.1f}M" if ps["size"] > 1048576 else f"{ps['size']/1024:.0f}K" |
|
w(f"| {decoded} | {ps['sessions']} | {ps['user']} | {ps['assistant']} | {top_tool} | {first_d} | {last_d} | {sz} |") |
|
w() |
|
|
|
# 13c: Tool usage |
|
w("### 13c. Tool Usage Profile") |
|
w() |
|
total_calls = sum(total_tool_calls.values()) |
|
w("| Tool | Calls | % |") |
|
w("|------|-------|---|") |
|
for tool, count in total_tool_calls.most_common(20): |
|
pct = count / total_calls * 100 if total_calls else 0 |
|
w(f"| {tool} | {count} | {pct:.1f}% |") |
|
w() |
|
|
|
# Categorize |
|
categories = {"File I/O": ["Read", "Write", "Edit", "MultiEdit", "NotebookEdit"], |
|
"Search": ["Glob", "Grep"], |
|
"Execution": ["Bash"], |
|
"Web": ["WebFetch", "WebSearch"], |
|
"Agent": ["Agent", "SendMessage", "TaskCreate", "TaskUpdate"]} |
|
w("**By category:**") |
|
w() |
|
for cat, tools in categories.items(): |
|
cat_total = sum(total_tool_calls.get(t, 0) for t in tools) |
|
if cat_total > 0: |
|
pct = cat_total / total_calls * 100 if total_calls else 0 |
|
w(f"- **{cat}:** {cat_total} calls ({pct:.0f}%)") |
|
mcp_total = sum(v for k, v in total_tool_calls.items() if k.startswith("mcp__")) |
|
if mcp_total: |
|
w(f"- **MCP tools:** {mcp_total} calls ({mcp_total/total_calls*100:.0f}%)") |
|
w() |
|
|
|
# 13d: Work patterns |
|
w("### 13d. Work Patterns") |
|
w() |
|
w("**Hourly distribution:**") |
|
w("```text") |
|
max_h = max(hourly_activity.values()) if hourly_activity else 1 |
|
for h in range(24): |
|
count = hourly_activity.get(h, 0) |
|
bar = "█" * int(count / max_h * 30) if max_h else "" |
|
w(f" {h:02d}:00 {bar} {count}") |
|
w("```") |
|
w() |
|
|
|
w("**Day of week:**") |
|
w("```text") |
|
max_d = max(weekly_activity.values()) if weekly_activity else 1 |
|
for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]: |
|
count = weekly_activity.get(day, 0) |
|
bar = "█" * int(count / max_d * 20) if max_d else "" |
|
w(f" {day:9s} {bar} {count}") |
|
w("```") |
|
w() |
|
|
|
if monthly_activity: |
|
w("**Monthly trend:**") |
|
w("```text") |
|
max_m = max(monthly_activity.values()) if monthly_activity else 1 |
|
for month, count in sorted(monthly_activity.items()): |
|
bar = "█" * int(count / max_m * 20) if max_m else "" |
|
w(f" {month} {bar} {count}") |
|
w("```") |
|
w() |
|
|
|
# 13e: Session characteristics |
|
w("### 13e. Session Characteristics") |
|
w() |
|
w("| Type | Count |") |
|
w("|------|-------|") |
|
for k, v in sessions_by_length.items(): |
|
w(f"| {k} | {v} |") |
|
w() |
|
if session_durations: |
|
sd = sorted(session_durations) |
|
w(f"- **Median duration:** {sd[len(sd)//2]:.1f} min") |
|
w(f"- **Average duration:** {sum(sd)/len(sd):.1f} min") |
|
w(f"- **Longest session:** {max(sd):.0f} min") |
|
w() |
|
|
|
# 13f: Prompt Analysis |
|
w("### 13f. Prompt Analysis") |
|
w() |
|
total_prompts = sum(prompt_categories.values()) |
|
if total_prompts > 0: |
|
w("**Prompt categories:**") |
|
w() |
|
w("| Category | Count | % |") |
|
w("|----------|-------|---|") |
|
for cat, cnt in prompt_categories.most_common(): |
|
w(f"| {cat} | {cnt} | {cnt/total_prompts*100:.1f}% |") |
|
w() |
|
if prompt_lengths: |
|
avg_len = sum(prompt_lengths) / len(prompt_lengths) |
|
med_idx = len(prompt_lengths) // 2 |
|
sorted_lens = sorted(prompt_lengths) |
|
w(f"- **Avg prompt length:** {avg_len:.0f} chars") |
|
w(f"- **Median prompt length:** {sorted_lens[med_idx]} chars") |
|
w(f"- **Longest prompt:** {max(sorted_lens)} chars") |
|
w() |
|
else: |
|
w("*No user prompts found.*") |
|
w() |
|
|
|
# 13g: Commands & Skills |
|
w("### 13g. Commands and Skills Used") |
|
w() |
|
if all_bash_commands: |
|
w("**Top shell commands:**") |
|
w() |
|
w("| Command | Invocations |") |
|
w("|---------|-------------|") |
|
for cmd, cnt in all_bash_commands.most_common(20): |
|
w(f"| `{cmd}` | {cnt} |") |
|
w() |
|
if all_skills_used: |
|
w("**Skills invoked:**") |
|
w() |
|
w("| Skill | Times |") |
|
w("|-------|-------|") |
|
for sk, cnt in all_skills_used.most_common(): |
|
w(f"| `{sk}` | {cnt} |") |
|
w() |
|
if not all_bash_commands and not all_skills_used: |
|
w("*No commands or skills extracted.*") |
|
w() |
|
|
|
# 13h: Recent Session Summaries |
|
w("### 13h. Session Summaries (recent)") |
|
w() |
|
w("*Opening prompt + key commands per session (last 30 sessions shown).*") |
|
w() |
|
# Sort by date descending, take last 30 |
|
sorted_summaries = sorted(all_session_summaries, key=lambda x: x[1], reverse=True)[:30] |
|
if sorted_summaries: |
|
for proj, date, opening, cmds, skills in sorted_summaries: |
|
w(f"**{date} — {proj}**") |
|
w(f"> {opening}") |
|
if skills: |
|
w(f"- Skills: {', '.join(f'`{s}`' for s in skills[:5])}") |
|
if cmds: |
|
w(f"- Commands ({len(cmds)}):") |
|
for c in cmds[:5]: |
|
w(f" - `{c}`") |
|
if len(cmds) > 5: |
|
w(f" - *...and {len(cmds) - 5} more*") |
|
w() |
|
else: |
|
w("*No session summaries available.*") |
|
w() |
|
|
|
# 13i: AI Usage Profile narrative (renumbered from 13f) |
|
w("### 13i. AI Usage Profile") |
|
w() |
|
|
|
# Determine primary work type from tool usage |
|
read_pct = total_tool_calls.get("Read", 0) / total_calls * 100 if total_calls else 0 |
|
write_pct = (total_tool_calls.get("Write", 0) + total_tool_calls.get("Edit", 0)) / total_calls * 100 if total_calls else 0 |
|
bash_pct = total_tool_calls.get("Bash", 0) / total_calls * 100 if total_calls else 0 |
|
search_pct = (total_tool_calls.get("Glob", 0) + total_tool_calls.get("Grep", 0)) / total_calls * 100 if total_calls else 0 |
|
|
|
# Determine intensity |
|
if total_sessions > 50: |
|
intensity = "daily driver — deeply integrated into the development workflow" |
|
elif total_sessions > 20: |
|
intensity = "power user — regular, purposeful engagement" |
|
elif total_sessions > 5: |
|
intensity = "active adopter — moderate but growing usage" |
|
else: |
|
intensity = "early explorer — light usage so far" |
|
|
|
# Session style |
|
marathon_pct = sessions_by_length.get("marathon (200+)", 0) / total_sessions * 100 if total_sessions else 0 |
|
short_pct = sessions_by_length.get("short (<10)", 0) / total_sessions * 100 if total_sessions else 0 |
|
if marathon_pct > 20: |
|
style = "favors long, deep collaborative sessions — treats AI as a pair programming partner" |
|
elif short_pct > 60: |
|
style = "favors quick, targeted queries — uses AI as a lookup tool" |
|
else: |
|
style = "mixed interaction style — combines quick queries with longer collaborative work" |
|
|
|
# Top projects |
|
top_projs = sorted(proj_stats.items(), key=lambda x: -x[1]["sessions"])[:3] |
|
top_names = [p[0].replace("-Users-" + USERNAME + "-", "").replace("-", "/") for p in top_projs] |
|
|
|
w(f"This user is a **{intensity}**. Across {total_sessions} sessions totaling " |
|
f"{stats['hours']} hours, they have exchanged {sum(total_messages.values())} messages " |
|
f"with AI and triggered {total_calls} tool calls.") |
|
w() |
|
w(f"Their primary workflow is **code-centric**: {read_pct:.0f}% of tool calls are file reads, " |
|
f"{write_pct:.0f}% are writes/edits, and {bash_pct:.0f}% are command execution. " |
|
f"{'They make heavy use of search tools (Glob/Grep) suggesting large codebase navigation.' if search_pct > 15 else ''} " |
|
f"{'MCP tool usage indicates integration with external services beyond the local filesystem.' if mcp_total > 0 else ''}") |
|
w() |
|
w(f"The user {style}. Their heaviest AI investment is in: **{', '.join(top_names)}**.") |
|
w() |
|
|
|
# Time patterns |
|
peak_hour = max(hourly_activity, key=hourly_activity.get) if hourly_activity else 0 |
|
peak_day = max(weekly_activity, key=weekly_activity.get) if weekly_activity else "unknown" |
|
w(f"Peak activity occurs at **{peak_hour:02d}:00** on **{peak_day}s**. " |
|
f"{'Usage spans both work hours and evenings, suggesting AI is used beyond standard work hours.' if hourly_activity.get(21, 0) + hourly_activity.get(22, 0) > 3 else ''}") |
|
w() |
|
|
|
else: |
|
w("*No session data found to analyze.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 14: Timeline |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 14. Configuration Timeline") |
|
w() |
|
|
|
events = [] |
|
|
|
# Earliest files |
|
if os.path.isdir(CLAUDE_DIR): |
|
earliest_out = run(f"find '{CLAUDE_DIR}' -type f -not -name '*.jsonl' -exec stat -f '%m %N' {{}} + 2>/dev/null | sort -n | head -3") |
|
for line in earliest_out.split("\n"): |
|
parts = line.strip().split(" ", 1) |
|
if len(parts) == 2: |
|
try: |
|
ts = datetime.fromtimestamp(int(parts[0])) |
|
events.append((ts, f"File created: `{os.path.relpath(parts[1], HOME)}`")) |
|
except: pass |
|
|
|
# Backup dates |
|
for bp in backups: |
|
mod_ts = run(f"stat -f '%m' '{bp}' 2>/dev/null") |
|
if mod_ts: |
|
try: |
|
ts = datetime.fromtimestamp(int(mod_ts)) |
|
events.append((ts, f"Backup: `{os.path.relpath(bp, HOME)}`")) |
|
except: pass |
|
|
|
# Session dates |
|
all_firsts = [ps["first"] for ps in proj_stats.values() if ps.get("first")] |
|
all_lasts = [ps["last"] for ps in proj_stats.values() if ps.get("last")] |
|
if all_firsts: |
|
try: |
|
ts = datetime.fromisoformat(min(all_firsts).replace("Z", "+00:00")).replace(tzinfo=None) |
|
events.append((ts, "First recorded session")) |
|
except: pass |
|
if all_lasts: |
|
try: |
|
ts = datetime.fromisoformat(max(all_lasts).replace("Z", "+00:00")).replace(tzinfo=None) |
|
events.append((ts, "Most recent session")) |
|
except: pass |
|
|
|
events.sort(key=lambda x: x[0]) |
|
if events: |
|
w("| Date | Event |") |
|
w("|------|-------|") |
|
for ts, desc in events: |
|
w(f"| {ts.strftime('%Y-%m-%d')} | {desc} |") |
|
else: |
|
w("*Could not construct timeline.*") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 15: Stats Dashboard |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 15. Statistics Dashboard") |
|
w() |
|
|
|
claude_size = dir_size(CLAUDE_DIR) if os.path.isdir(CLAUDE_DIR) else "0" |
|
backup_total = 0 |
|
for bp in backups: |
|
try: |
|
if os.path.isfile(bp): |
|
backup_total += os.path.getsize(bp) |
|
elif os.path.isdir(bp): |
|
for r, _, fs in os.walk(bp): |
|
for f in fs: |
|
try: backup_total += os.path.getsize(os.path.join(r, f)) |
|
except: pass |
|
except: pass |
|
backup_size = f"{backup_total/1048576:.1f} MB" if backup_total > 1048576 else f"{backup_total/1024:.1f} KB" if backup_total > 1024 else f"{backup_total} B" |
|
|
|
w("| Metric | Count |") |
|
w("|--------|-------|") |
|
w(f"| Skills installed | {stats.get('skills', 0)} |") |
|
w(f"| Custom agents | {stats.get('agents', 0)} |") |
|
w(f"| MCP servers | {stats.get('mcp_servers', 0)} |") |
|
w(f"| Projects with config | {stats.get('projects', 0)} |") |
|
w(f"| Rule files | {stats.get('rules', 0)} |") |
|
w(f"| Backup configs | {stats.get('backups', 0)} |") |
|
w(f"| Total sessions | {stats.get('sessions', 0)} |") |
|
w(f"| Total messages | {stats.get('messages', 0)} |") |
|
w(f"| Total AI hours | {stats.get('hours', 0)} |") |
|
w(f"| Disk: ~/.claude/ | {claude_size} |") |
|
w(f"| Disk: backups | {backup_size} |") |
|
w() |
|
|
|
# ══════════════════════════════════════════════════════════════════════════ |
|
# Section 16: Setup Health |
|
# ══════════════════════════════════════════════════════════════════════════ |
|
|
|
w("## 16. Setup Health") |
|
w() |
|
|
|
issues = [] |
|
suggestions = [] |
|
|
|
if not claude_version: |
|
issues.append("Claude Code CLI is not installed") |
|
if stats.get("skills", 0) == 0: |
|
suggestions.append("No skills installed — consider adding skills from github.com/viktorbezdek/skillstack") |
|
if stats.get("agents", 0) == 0: |
|
suggestions.append("No custom agents configured — agents can specialize Claude for specific tasks") |
|
if stats.get("mcp_servers", 0) == 0: |
|
suggestions.append("No MCP servers configured — MCP extends Claude with external tool access") |
|
if stats.get("rules", 0) == 0: |
|
suggestions.append("No global rules — rules help maintain coding standards across projects") |
|
if stats.get("backups", 0) > 3: |
|
suggestions.append(f"Found {stats['backups']} backup configs — consider cleaning up stale backups to free disk space") |
|
|
|
# Check for stale backups (>90 days old) |
|
for bp in backups: |
|
mod_ts = run(f"stat -f '%m' '{bp}' 2>/dev/null") |
|
if mod_ts: |
|
try: |
|
age_days = (datetime.now().astimezone() - datetime.fromtimestamp(int(mod_ts)).astimezone()).days |
|
if age_days > 90: |
|
suggestions.append(f"Backup `{os.path.relpath(bp, HOME)}` is {age_days} days old — safe to remove?") |
|
except: pass |
|
|
|
if issues: |
|
w("### Issues") |
|
w() |
|
for issue in issues: |
|
w(f"- {issue}") |
|
w() |
|
|
|
if suggestions: |
|
w("### Suggestions") |
|
w() |
|
for s in suggestions: |
|
w(f"- {s}") |
|
w() |
|
|
|
if not issues and not suggestions: |
|
w("*No issues or suggestions — setup looks healthy.*") |
|
w() |
|
|
|
# ── Write output ───────────────────────────────────────────────────────── |
|
|
|
# Insert TOC after title |
|
toc_lines = [] |
|
toc_lines.append("## Table of Contents") |
|
toc_lines.append("") |
|
for line in out: |
|
if line.startswith("## ") and not line.startswith("## Table"): |
|
anchor = line[3:].lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "").replace("`", "").replace("~", "").replace(".", "") |
|
toc_lines.append(f"- [{line[3:]}](#{anchor})") |
|
toc_lines.append("") |
|
|
|
# Find insertion point (after the metadata block) |
|
insert_at = 0 |
|
for i, line in enumerate(out): |
|
if line.startswith("**Machine:**"): |
|
insert_at = i + 1 |
|
break |
|
|
|
for i, tl in enumerate(toc_lines): |
|
out.insert(insert_at + i, tl) |
|
|
|
with open(OUTPUT_FILE, "w") as f: |
|
f.write("\n".join(out) + "\n") |
|
|
|
line_count = len(out) |
|
byte_count = os.path.getsize(OUTPUT_FILE) |
|
print(f"DONE|{OUTPUT_FILE}|{line_count}|{byte_count}") |
|
PYTHON_SCRIPT |
|
|
|
# ── Parse result and show summary ────────────────────────────────────── |
|
|
|
RESULT=$(tail -1 <<< "$(cat /dev/stdin 2>/dev/null || true)") |
|
# The python script prints to stdout which bash captures |
|
# Re-run to get the output line |
|
RESULT=$(python3 -c " |
|
import os, sys |
|
f = '${OUTPUT_FILE}' |
|
if os.path.exists(f): |
|
lines = len(open(f).readlines()) |
|
size = os.path.getsize(f) |
|
print(f'DONE|{f}|{lines}|{size}') |
|
else: |
|
print('FAIL') |
|
") |
|
|
|
if [[ "$RESULT" == FAIL* ]]; then |
|
fail "Documentation was not generated." |
|
fi |
|
|
|
IFS='|' read -r _ FILE LINES BYTES <<< "$RESULT" |
|
|
|
echo "" |
|
printf "${BOLD}${GREEN}Done!${NC}\n" |
|
echo "" |
|
printf " ${CYAN}File:${NC} %s\n" "$FILE" |
|
printf " ${CYAN}Size:${NC} %s lines / %s bytes\n" "$LINES" "$BYTES" |
|
echo "" |
|
echo "────────────────────────────────────────────────────────────────" |
|
echo "" |
|
printf " ${BOLD}Next step:${NC} Upload this file to the Asana task where your\n" |
|
printf " AI setup documentation was requested.\n" |
|
echo "" |
|
printf " ${CYAN}1.${NC} Open the Asana task\n" |
|
printf " ${CYAN}2.${NC} Drag and drop ${BOLD}%s${NC}\n" "$(basename "$FILE")" |
|
printf " into the task, or use the attachment paperclip\n" |
|
printf " ${CYAN}3.${NC} Add a comment noting any highlights from your setup\n" |
|
echo "" |
|
echo "────────────────────────────────────────────────────────────────" |
|
echo "" |
|
|
|
# Reveal in Finder for easy drag-and-drop |
|
if command -v open >/dev/null 2>&1; then |
|
open -R "$FILE" 2>/dev/null || true |
|
printf "${DIM} Finder opened to file location.${NC}\n" |
|
fi |