from __future__ import annotations from collections import Counter from pathlib import Path import json import re import shlex __all__ = ["extract_claude"] MAX_DOC_CHARS = 12000 MAX_SUMMARY_CHARS = 400 MAX_SOURCE_SNIPPET = 240 _FRONTMATTER_RE = re.compile(r"^---\s*$", re.M) _HEADING_RE = re.compile(r"^#{1,6}\s+(.*\S)\s*$") _BULLET_COMMAND_RE = re.compile(r"`/([A-Za-z0-9_.-]+)`") _TOKEN_RE = re.compile(r"[A-Za-z0-9_.:/@+-]+") def extract_claude(home: Path) -> dict: root = _resolve_claude_root(home) backup_root = Path("/home/smlflg/ClaudeSystemBackup") backup_present = backup_root.exists() settings = _load_json(root / "settings.json") permissions = settings.get("permissions", {}) if isinstance(settings, dict) else {} hooks = settings.get("hooks", {}) if isinstance(settings, dict) else {} command_items = _collect_markdown_surface(root / "commands", "command") agent_items = _collect_markdown_surface(root / "agents", "agent") skill_items = _collect_markdown_surface(root / "skills", "skill") command_count = len(command_items) agent_count = len(agent_items) skill_count = len(skill_items) session_count = _count_files(root / "sessions", include_hidden=False) session_log_count = _count_files(root / "session-logs", include_hidden=False) telemetry_count = _count_files(root / "telemetry", include_hidden=False) project_transcript_count = _count_files(root / "projects", include_hidden=False) project_tool_usage = _sample_project_tool_usage(root / "projects") root_group_counts = _command_group_counts(command_items) settings_allow = _as_list(permissions.get("allow")) settings_deny = _as_list(permissions.get("deny")) mcp_allow = _extract_mcp_allow_entries(settings_allow) hook_groups = _summarize_hooks(hooks) instruction_layers = _build_instruction_layers(root) changelog = _load_text(root / "cache" / "changelog.md") changelog_candidates = _extract_changelog_command_candidates( changelog, {item["name"] for item in command_items}, ) backup_metadata = _backup_metadata(backup_root) if backup_present else {} discoveries = [] if _plugins_dormant(root / "plugins"): discoveries.append( { "kind": "dormant_surface", "title": "Plugins directory looks dormant", "reason": "Only blocklist.json was found under ~/.claude/plugins; no installed plugin manifests or plugin trees were present.", } ) if changelog_candidates: discoveries.append( { "kind": "feature_gap", "title": "Changelog-advertised commands/features have no local file evidence", "items": changelog_candidates, "reason": "Recent changelog entries mention slash commands or features that do not appear in the local command docs, settings, or hooks.", } ) if session_count == 0: discoveries.append( { "kind": "sparse_history", "title": "Sessions directory is empty", "reason": "No files were found under ~/.claude/sessions.", } ) if command_count >= 40 or agent_count >= 15: discoveries.append( { "kind": "lightly_used_surface", "title": "Large command and agent surfaces with a thin history footprint", "reason": ( f"{command_count} command docs, {agent_count} agent docs, and only " f"{session_count} session file(s) / {session_log_count} session-log file(s) suggest a broad but lightly evidenced surface." ), } ) if session_count and session_count <= 1: discoveries.append( { "kind": "sparse_history", "title": "Sessions directory is effectively sparse", "reason": "Exactly one session JSON file was present under ~/.claude/sessions, so the empty-dir candidate does not apply but the footprint is still minimal.", } ) warnings = [ "Skipped ~/.claude/.env, ~/.claude/.credentials.json, ~/.claude/settings.local.json, and other secret-bearing files.", "Backup data at /home/smlflg/ClaudeSystemBackup was read as filenames and counts only; no file contents were read there.", ] if not mcp_allow: warnings.append("No explicit MCP tool allow entries were found in settings.json permissions.allow.") return { "id": "claude-code", "name": "Claude Code", "status": { "home": str(root), "backup_home": str(backup_root) if backup_present else None, "settings_present": (root / "settings.json").exists(), "backup_present": backup_present, "model": _settings_model(settings), "default_mode": _permission_default_mode(permissions), "counts": { "commands": command_count, "agents": agent_count, "skills": skill_count, "sessions": session_count, "session_logs": session_log_count, "project_transcripts": project_transcript_count, "telemetry": telemetry_count, }, "backup_metadata": backup_metadata, }, "config": { "model": _settings_model(settings), "permissions": { "default_mode": _permission_default_mode(permissions), "allow_count": len(settings_allow), "deny_count": len(settings_deny), "additional_directories": _as_list(permissions.get("additionalDirectories")), }, "hooks": { "groups": [group["name"] for group in hook_groups], "group_count": len(hook_groups), }, "mcp_tools": mcp_allow, }, "instruction_layers": instruction_layers, "extensions": { "skills": skill_items, "commands": command_items, "agents": agent_items, "hooks": hook_groups, "mcp_tools": mcp_allow, }, "usage": { "counts": { "commands": command_count, "agents": agent_count, "skills": skill_count, "session_files": session_count, "session_log_files": session_log_count, "project_transcripts": project_transcript_count, "telemetry_files": telemetry_count, "tools_observed": sum(item["count"] for item in project_tool_usage), }, "command_groups": root_group_counts, "history": { "session_file_count": session_count, "session_log_file_count": session_log_count, "telemetry_file_count": telemetry_count, "project_transcript_count": project_transcript_count, }, "tools": { "observed_count": sum(item["count"] for item in project_tool_usage), "top": project_tool_usage, }, "surface_pressure": { "commands_vs_history": _surface_pressure_label(command_count, session_log_count), "agents_vs_history": _surface_pressure_label(agent_count, session_log_count), }, }, "discoveries": discoveries, "sources": _sources(root, backup_present), "warnings": warnings, } def _resolve_claude_root(home: Path) -> Path: home = Path(home).expanduser() candidates = [home] if home.name != ".claude": candidates.insert(0, home / ".claude") for candidate in candidates: if (candidate / "settings.json").exists() or (candidate / "CLAUDE.md").exists(): return candidate return candidates[0] def _load_json(path: Path): try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def _load_text(path: Path) -> str: if not path.exists() or not path.is_file(): return "" try: with path.open("r", encoding="utf-8", errors="replace") as handle: chunks = [] total = 0 for line in handle: chunks.append(line) total += len(line) if total >= MAX_DOC_CHARS: break return "".join(chunks) except OSError: return "" def _count_files(path: Path, include_hidden: bool = False) -> int: if not path.exists(): return 0 count = 0 for item in path.rglob("*"): if not item.is_file(): continue if ".git" in item.parts: continue relative = item.relative_to(path) if not include_hidden and any(part.startswith(".") for part in relative.parts): # The tree contains operational dotfiles we do not want to over-report. continue count += 1 return count def _sample_project_tool_usage(path: Path, max_files: int = 140) -> list[dict]: if not path.exists(): return [] counter = Counter() files = sorted( (item for item in path.rglob("*.jsonl") if item.is_file() and ".git" not in item.parts), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True, )[:max_files] for file_path in files: try: with file_path.open("r", encoding="utf-8", errors="replace") as handle: for line in handle: parsed = _safe_json_line(line) if parsed: counter.update(_tool_names_from_json(parsed)) except OSError: continue return [{"name": name, "count": count} for name, count in counter.most_common(16)] def _safe_json_line(line: str): try: parsed = json.loads(line) except json.JSONDecodeError: return None return parsed if isinstance(parsed, dict) else None def _tool_names_from_json(value) -> list[str]: names = [] if isinstance(value, dict): tool_type = value.get("type") name = value.get("name") or value.get("tool_name") or value.get("tool") if isinstance(name, str) and (tool_type in {"tool_use", "tool", "function"} or "tool" in str(tool_type).lower()): names.append(name) for nested in value.values(): names.extend(_tool_names_from_json(nested)) elif isinstance(value, list): for item in value: names.extend(_tool_names_from_json(item)) return names def _as_list(value): if isinstance(value, list): return value if value is None: return [] return [value] def _settings_model(settings: dict) -> str | None: model = settings.get("model") return model if isinstance(model, str) else None def _permission_default_mode(permissions: dict) -> str | None: mode = permissions.get("defaultMode") return mode if isinstance(mode, str) else None def _surface_pressure_label(surface_count: int, history_count: int) -> str: if history_count == 0: return "high" ratio = surface_count / history_count if ratio >= 10: return "high" if ratio >= 3: return "medium" return "low" def _collect_markdown_surface(base: Path, kind: str) -> list[dict]: if not base.exists(): return [] items = [] for path in sorted(p for p in base.rglob("*.md") if ".git" not in p.parts and p.is_file()): rel = path.relative_to(base.parent) if kind == "command" and rel.parts[0] != "commands": continue if kind == "agent" and rel.parts[0] != "agents": continue if kind == "skill" and rel.parts[0] != "skills": continue title, summary = _summarize_markdown(path) group = _surface_group(rel) items.append( { "name": _surface_name(path, title), "path": str(rel), "group": group, "summary": summary, } ) return items def _surface_group(rel_path: Path) -> str: parts = rel_path.parts if len(parts) <= 2: return "root" if parts[1] in {"gsd", "_archived"}: return parts[1] if parts[0] == "skills" and len(parts) > 2: return parts[1] return "root" def _surface_name(path: Path, title: str) -> str: stem = path.stem if stem == "SKILL" and path.parent.name: return path.parent.name if title and title != path.name: return title return stem def _summarize_markdown(path: Path) -> tuple[str, str]: text = _load_text(path) if not text: return path.stem, "" frontmatter, body = _split_frontmatter(text) metadata = _parse_frontmatter(frontmatter) title = _first_non_empty( metadata.get("name"), metadata.get("title"), _first_heading(text), path.stem, ) lead = metadata.get("description", "") if not lead: lead = _extract_prose_summary(body) summary = _compact_summary(" ".join(part for part in [title, lead] if part)) return title, summary def _split_frontmatter(text: str) -> tuple[str, str]: if not text.startswith("---"): return "", text lines = text.splitlines() if not lines or lines[0].strip() != "---": return "", text for index in range(1, len(lines)): if lines[index].strip() == "---": frontmatter = "\n".join(lines[1:index]) body = "\n".join(lines[index + 1 :]) return frontmatter, body return "", text def _parse_frontmatter(text: str) -> dict[str, str]: if not text: return {} data = {} for raw in text.splitlines(): if ":" not in raw: continue key, value = raw.split(":", 1) key = key.strip() value = value.strip().strip("'\"") if key: data[key] = value return data def _first_heading(text: str) -> str: for line in text.splitlines(): match = _HEADING_RE.match(line) if match: return match.group(1).strip() return "" def _extract_prose_summary(text: str) -> str: lines = [] in_fence = False for line in text.splitlines(): stripped = line.strip() if stripped.startswith("```"): in_fence = not in_fence continue if in_fence: continue if not stripped: if lines: break continue if stripped.startswith("#"): continue lines.append(stripped) if len(" ".join(lines)) >= MAX_SUMMARY_CHARS: break return _compact_summary(" ".join(lines)) def _compact_summary(text: str) -> str: text = re.sub(r"\s+", " ", text).strip() if len(text) > MAX_SUMMARY_CHARS: return text[: MAX_SUMMARY_CHARS - 1].rstrip() + "…" return text def _first_non_empty(*values) -> str: for value in values: if isinstance(value, str) and value.strip(): return value.strip() return "" def _command_group_counts(items: list[dict]) -> dict[str, int]: counter = Counter() for item in items: group = item.get("group") or "root" counter[group] += 1 return dict(sorted(counter.items())) def _summarize_hooks(hooks: dict) -> list[dict]: if not isinstance(hooks, dict): return [] items = [] for name in sorted(hooks): rules = hooks.get(name) if not isinstance(rules, list): continue command_names = [] matchers = [] for rule in rules: if not isinstance(rule, dict): continue matcher = rule.get("matcher") if isinstance(matcher, str) and matcher: matchers.append(matcher) for hook in _as_list(rule.get("hooks")): if not isinstance(hook, dict): continue command = hook.get("command") if isinstance(command, str) and command: command_names.append(_hook_command_name(command)) items.append( { "name": name, "rule_count": len(rules), "matchers": sorted({m for m in matchers if m}), "commands": sorted({c for c in command_names if c}), } ) return items def _hook_command_name(command: str) -> str: try: parts = shlex.split(command) except ValueError: parts = command.split() if not parts: return command.strip() candidate = parts[-1] if len(parts) > 1 else parts[0] return Path(candidate.strip("\"'")).name def _extract_mcp_allow_entries(allow_entries) -> list[str]: if not isinstance(allow_entries, list): return [] items = [] for entry in allow_entries: if isinstance(entry, str) and ("mcp__" in entry or entry.startswith("mcp:")): items.append(entry) return sorted(set(items)) def _build_instruction_layers(root: Path) -> list[dict]: ordered = [ root / "CLAUDE.md", root / "WelcheFehlerVermeiden.md", root / "WieArbeitestDuMitSamuel.md", root / "Skilluebersicht.md", root / "README.md", ] layers = [] for path in ordered: if not path.exists(): continue title, summary = _summarize_markdown(path) layers.append( { "name": title, "path": str(path), "summary": summary, } ) return layers def _extract_changelog_command_candidates(changelog: str, known_commands: set[str]) -> list[str]: if not changelog: return [] seen = [] for match in _BULLET_COMMAND_RE.finditer(changelog): name = match.group(1) if name not in known_commands and name not in seen: seen.append(name) return seen[:8] def _backup_metadata(root: Path) -> dict: if not root.exists(): return {} entries = [] file_counts = Counter() top_level = [] for child in sorted(root.iterdir(), key=lambda p: p.name): top_level.append(child.name) if child.is_file(): file_counts["."] += 1 entries.append({"name": child.name, "kind": "file"}) elif child.is_dir(): count = _count_files(child, include_hidden=True) file_counts[child.name] += count entries.append({"name": child.name, "kind": "dir", "file_count": count}) return { "top_level": top_level[:40], "entries": entries[:40], "file_counts": dict(sorted(file_counts.items())), } def _plugins_dormant(path: Path) -> bool: if not path.exists(): return False files = [p for p in path.iterdir() if p.is_file() and ".git" not in p.parts] subdirs = [p for p in path.iterdir() if p.is_dir() and not p.name.startswith(".")] return len(files) == 1 and files[0].name == "blocklist.json" and not subdirs def _sources(root: Path, backup_present: bool) -> list[dict]: entries = [ {"kind": "config", "path": str(root / "settings.json")}, {"kind": "instruction", "path": str(root / "CLAUDE.md")}, {"kind": "instruction", "path": str(root / "WelcheFehlerVermeiden.md")}, {"kind": "instruction", "path": str(root / "WieArbeitestDuMitSamuel.md")}, {"kind": "instruction", "path": str(root / "Skilluebersicht.md")}, {"kind": "instruction", "path": str(root / "README.md")}, {"kind": "surface", "path": str(root / "commands")}, {"kind": "surface", "path": str(root / "agents")}, {"kind": "surface", "path": str(root / "skills")}, {"kind": "surface", "path": str(root / "sessions")}, {"kind": "surface", "path": str(root / "session-logs")}, {"kind": "surface", "path": str(root / "telemetry")}, {"kind": "surface", "path": str(root / "cache" / "changelog.md")}, ] if backup_present: entries.append({"kind": "metadata_only", "path": "/home/smlflg/ClaudeSystemBackup"}) return entries