Skip to content

Instantly share code, notes, and snippets.

@viktorbezdek
Last active April 2, 2026 09:58
Show Gist options
  • Select an option

  • Save viktorbezdek/b9a5b6d962723857949b6cacbab5cd03 to your computer and use it in GitHub Desktop.

Select an option

Save viktorbezdek/b9a5b6d962723857949b6cacbab5cd03 to your computer and use it in GitHub Desktop.
Document your personal Claude Code / AI setup on macOS — no dependencies, just bash + python3

Document Your Personal AI Setup

What this does

Generates a comprehensive markdown report of your Claude Code / Claude Desktop configuration on macOS — including installed skills, agents, MCP servers, backup configs, session analytics, and an AI usage profile.

How to run

Open Terminal and paste this:

curl -fsSL https://gist.githubusercontent.com/viktorbezdek/b9a5b6d962723857949b6cacbab5cd03/raw/document-ai-setup.sh | bash

That's it. The script:

  • Needs no API keys, no Claude Code CLI, no internet beyond fetching the script itself
  • Requires only macOS bash + python3 (ships with Xcode CLI tools)
  • Runs in ~10 seconds
  • Writes ai-setup-<your-username>-<date>.md to your current directory
  • Opens Finder at the file location for easy drag-and-drop

What gets documented

Section What it covers
1. Installation Claude Code version, path, Node/Bun versions, shell config
2. Global config ~/.claude/ directory tree, settings.json, local settings
3. Rules Every rule file with organization pattern
4. Skills All installed skills with descriptions from SKILL.md
5. Agents All custom agents grouped by category with frontmatter
6. MCP servers Server configs from ~/.claude.json and mcp.json
7. Projects Every project with .claude/ or CLAUDE.md (agents, skills, hooks)
8. Backups All backup configs (.claude-backup-*, .bak, .old, Trash, ecosystem tools)
9. Env vars CLAUDE_/ANTHROPIC_ variable names (values always redacted)
10. Hooks All configured hooks by event type
11. Plugins Enabled plugins and VS Code extensions
12. Sessions Memory.db stats, session counts per project
13. Analysis Session metadata, tool usage, work patterns, prompt analysis, commands/skills used, per-session summaries, AI usage narrative
14. Timeline Chronological history from file dates, backups, and sessions
15. Dashboard All stats in one summary table
16. Health Issues, stale backups, missing configs, improvement suggestions

After running

  1. Open the Asana task where your AI setup documentation was requested
  2. Drag and drop the generated .md file into the task (Finder is already open at the file)
  3. Add a brief comment highlighting anything notable about your setup

Security

  • API keys, tokens, and passwords are always replaced with ***REDACTED***
  • Memory databases are never read (only file size reported)
  • User prompts are truncated to 120 characters and redacted for secrets
  • Shell commands shown are truncated to 100 characters and redacted
  • Full message content is never stored — only summaries and metadata
  • The script runs locally — no data is sent anywhere
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment