Codebase Onboarding skill for Claude Code
From git clone to a verified map in one session. Instead of summarizing files at random, Claude classifies the repo, runs a bundled map script for languages, manifests, CI and entry points, proves the build and test commands work, and traces one real flow from handler to database with file and line references. The result is a one-page architecture note with run instructions, a module map, hotspots, and open questions.
- name
codebase-onboarding - size213 lines · ≈2,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Maps an unfamiliar repository quickly by finding entry points, build and test commands, module boundaries, data flow, and conventions, proving the commands run, and writing a concise architecture note with file-path evidence. Includes a stdlib script that summarizes languages, manifests, CI, and candidate entry points. Use when the user opens a new or inherited codebase, asks how a repo works, where to start, how a request flows through the system, or wants an architecture overview or onboarding doc. Try asking
- I just inherited this repo — how does it work and where do I start?
- Trace what happens when a user hits POST /checkout in this codebase
- Write an architecture note for this project that a new hire could follow
Use it when
- You are new to a repository and need a working mental model fast
- You need to know how a specific request or command flows through the code
- The README is missing or stale and you want verified run instructions
- A teammate needs an onboarding or architecture doc
Skip it when
- The repo is a pnpm, Turborepo or Nx workspace and you need task wiring — use monorepo-navigation
- You need to start changing untested legacy code safely — use legacy-code-rescue
How to check the result
The build and fastest single-test command were run successfully, and two randomly chosen claims in the note check out against the cited file paths.
The common mistake it prevents
Trusting README setup steps over the CI workflow, then documenting commands that no longer work.
Onboarding is done when you can build and test the project, trace one real request or command end to end with file paths, name the main modules and what owns what, and hand over a one-page note that someone else can verify. Read selectively; a map beats a summary of every file.
Ground rules
- Breadth first, then one deep trace. Do not read files top to bottom until you know which ones matter.
- Every claim in the architecture note cites a file path (and ideally a symbol). If you inferred something, label it as inferred.
- Run the build and test commands; documentation is often stale. Record what actually worked, including required env vars and service dependencies.
- Do not change code during onboarding except throwaway experiments on a scratch branch.
- Stop exploring when the user’s question is answered; full coverage is not the goal.
Workflow
- [ ] 1. Orient: size, languages, manifests, CI
- [ ] 2. Read the docs that exist (fast, skeptical)
- [ ] 3. Get it building and testing
- [ ] 4. Find entry points
- [ ] 5. Trace one real flow end to end
- [ ] 6. Map modules, boundaries, and data
- [ ] 7. Note conventions and hotspots
- [ ] 8. Write the architecture note
1. Orient
Run the bundled map script, then fill gaps by hand:
python3 scripts/repo_map.py <repo_dir> # languages, dirs, manifests, CI, entry points
python3 scripts/repo_map.py <repo_dir> --depth 4 # deeper for monorepos
git log --oneline -20 # recent activity and commit style
git shortlog -sn --since="1 year ago" | head # who knows the code
git ls-files | wc -l
Classify the repo early; it decides where to look next:
| Signals | Kind | Look next at |
|---|---|---|
pnpm-workspace.yaml, turbo.json, nx.json, go.work, Cargo [workspace] | Monorepo | Package list and dependency graph (see monorepo-navigation) |
next.config.*, app/ or pages/ | Next.js app | app/**/page.tsx, route.ts, middleware.ts |
manage.py, settings.py | Django | urls.py, INSTALLED_APPS, models |
main.go under cmd/<name>/ | Go service/CLI | cmd/*/main.go, internal/ |
src/main/java, @SpringBootApplication | Spring Boot | Application class, controllers, application.yml |
config/routes.rb | Rails | Routes, controllers, db/schema.rb |
bin in package.json, [project.scripts] | CLI tool | The bin target and its argument parser |
Dockerfile, compose.yaml, k8s/, terraform/ | Deployed service | Startup command, env vars, external dependencies |
2. Read existing docs (skeptically)
Read in this order and stop when you have enough: README*, CONTRIBUTING*,
docs/ index, CLAUDE.md/AGENTS.md, ADRs (docs/adr, docs/decisions), the CI
workflow (it is the most honest build documentation). Note contradictions between
docs and CI.
3. Build and test
Use the commands CI uses. Typical discovery:
cat package.json | sed -n '/"scripts"/,/}/p' # npm/pnpm scripts
grep -E '^[a-zA-Z_-]+:' Makefile # make targets
sed -n '/jobs:/,$p' .github/workflows/*.yml | grep -E 'run:'
Record: toolchain versions (.nvmrc, .tool-versions, .python-version,
go.mod go line), install command, env vars needed (.env.example), services needed
(DB, Redis, queues via compose.yaml), and the fastest single-test command. If the
build fails, capture the error and the fix; it goes in the note.
4. Find entry points
Entry points are where control enters the system. List all kinds that exist:
- HTTP: route registrations (
app.get(,router.,@Get(,urls.py,routes.rb,app/**/route.ts). - CLI:
bintargets,argparse/click/typer/cobra/clapdefinitions. - Background: queue consumers, cron/scheduled jobs, event handlers, webhooks.
- UI: router config or file-based routes; the root layout.
- Startup:
main(), server bootstrap, DI container setup, migrations on boot.
grep -rnE "app\.(get|post|put|delete|use)\(|router\.(get|post)|@(Get|Post|Controller)\(" src | head -40
grep -rnE "add_parser|@click\.command|cobra\.Command|#\[derive\(Parser" --include=*.{py,go,rs} . | head
grep -rnE "cron|schedule|consumer|subscribe\(|@task|@shared_task" --include=*.{ts,js,py,go,rb} . | head
5. Trace one real flow
Pick the flow the user cares about, or the most central one (sign-in, checkout, the main CLI command). Follow it from entry to persistence and back:
- Entry handler: file:line.
- Validation and auth: where and how.
- Business logic: which module/service owns the decision.
- Persistence or external calls: which repository/client, which tables/APIs.
- Response or side effects: events emitted, jobs enqueued, caches touched.
Use the editor’s go-to-definition or grep -rn "functionName"; for dynamic dispatch
(DI containers, event buses, decorators), search for registration sites. When static
reading stalls, run the flow with a breakpoint or a temporary log line on a scratch
branch to confirm the path.
6. Map modules, boundaries, and data
- Modules: top-level packages/directories and a one-line responsibility each. Note
which ones import which (
grep -rn "from '@/billing" src | cut -d: -f1 | sort -u). - Boundaries: where layering is enforced (lint rules, package exports, Nx tags) versus merely conventional.
- Data: schema source of truth (
schema.prisma,db/schema.rb, migrations dir,models.py), main entities and their relationships, other datastores (cache, search, object storage), and external services (from env var names and client libraries).
Draw only what helps; a short Mermaid diagram of the main containers is usually enough:
flowchart LR
web[Next.js web] --> api[API routes]
api --> svc[billing service]
svc --> db[(Postgres)]
svc --> stripe[Stripe API]
api --> q[[BullMQ jobs]] --> worker[worker]
7. Conventions and hotspots
- Conventions: error handling style, logging, config loading, test layout and naming, dependency injection, naming patterns. Copy them when you later write code.
- Hotspots: most-changed files are where bugs and context live.
git log --since="6 months ago" --name-only --format= | grep -v '^$' | sort | uniq -c | sort -rn | head -15
- Landmines: TODO/FIXME/HACK density, disabled tests, files over ~1000 lines, generated code that must not be edited by hand (look for “DO NOT EDIT” headers).
8. Write the architecture note
Keep it to about one page. Template:
# <Project> — architecture note
_Written <date> from commit <sha>. Inferred items marked (inferred)._
## What it is
<One paragraph: purpose, users, deployment shape.>
## Run it
- Toolchain: Node 22 (.nvmrc), pnpm 10
- Setup: `pnpm install && cp .env.example .env && docker compose up -d db`
- Dev: `pnpm dev` (http://localhost:3000)
- Test: `pnpm test` (all), `pnpm vitest run path/to/file.test.ts` (one)
- Gotchas: needs `STRIPE_SECRET_KEY` set to any value or boot fails (src/env.ts:12)
## Map
| Area | Path | Responsibility |
|---|---|---|
| Web UI | apps/web | Next.js App Router frontend |
| Billing | packages/billing | Plans, proration, Stripe sync |
## Request flow: <name>
1. `apps/web/app/api/checkout/route.ts:POST` validates body with Zod
2. calls `createCheckout()` in `packages/billing/src/checkout.ts:40`
3. ...
## Data
<Source of truth for schema, main entities, other stores, external APIs.>
## Conventions
<Errors, logging, tests, naming — the things to copy.>
## Hotspots and risks
<Most-churned files, fragile areas, missing tests.>
## Open questions
<Things you could not determine; who might know (from git shortlog).>
Save it where the user wants (default: present it in chat; offer docs/architecture.md).
Gotchas
- README build steps that differ from CI: trust CI, then tell the user the README is stale.
- Generated directories (
generated/,__generated__,*.pb.go, OpenAPI clients) inflate line counts and mislead tracing; find the generator source instead. - Framework magic hides entry points: file-based routing, decorators, autoloading, DI containers. Search for registration, not just calls.
- Monorepos often have one “real” app and many stale packages; check commit recency per package before mapping all of them.
- Env-specific behavior (feature flags,
NODE_ENVbranches) changes the flow; note which configuration your trace assumed.
Verification
- Build and fastest test command were actually run, with results recorded.
- Pick two claims from the note at random and confirm them by opening the cited files.
- The traced flow can be re-followed by someone else using only the file:line list.
- Open questions are listed rather than guessed.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/repo_map.py
#!/usr/bin/env python3
"""Print a quick structural map of a repository.
Usage:
python3 repo_map.py [repo_dir] [--depth N]
Reports: languages by file and line count, top-level directories by size,
build/test manifests with their scripts or targets, CI and container files,
and candidate entry points. Standard library only. Exits 1 if repo_dir is
not a directory.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tomllib
from collections import Counter, defaultdict
from pathlib import Path
SKIP_DIRS = {
".git", "node_modules", "vendor", "dist", "build", "out", "target", ".next",
".nuxt", ".svelte-kit", ".venv", "venv", "env", "__pycache__", ".mypy_cache",
".pytest_cache", ".ruff_cache", ".turbo", ".cache", "coverage", ".gradle",
".idea", ".vscode", "Pods", ".terraform", "bin", "obj",
}
LANG = {
".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript",
".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript", ".go": "Go",
".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".rb": "Ruby", ".php": "PHP",
".cs": "C#", ".swift": "Swift", ".c": "C", ".h": "C/C++ header", ".cpp": "C++",
".cc": "C++", ".scala": "Scala", ".ex": "Elixir", ".exs": "Elixir",
".vue": "Vue", ".svelte": "Svelte", ".astro": "Astro", ".sql": "SQL",
".sh": "Shell", ".tf": "Terraform", ".dart": "Dart", ".lua": "Lua",
}
ENTRY_NAMES = re.compile(
r"^(main|index|app|server|cli|manage|wsgi|asgi|__main__|program|application)\.[a-z]+$"
)
MANIFESTS = [
"package.json", "pnpm-workspace.yaml", "turbo.json", "nx.json", "pyproject.toml",
"requirements.txt", "setup.py", "go.mod", "Cargo.toml", "Gemfile", "composer.json",
"pom.xml", "build.gradle", "build.gradle.kts", "Makefile", "justfile", "Taskfile.yml",
"Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yaml",
".tool-versions", ".nvmrc", ".python-version", "mise.toml",
]
MAX_LINES_FILE = 2_000_000 # bytes; skip counting lines in huge files
def walk(root: Path):
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")
or d in {".github", ".gitlab", ".circleci"}]
for f in filenames:
yield Path(dirpath) / f
def count_lines(p: Path) -> int:
try:
if p.stat().st_size > MAX_LINES_FILE:
return 0
with p.open("rb") as fh:
return sum(1 for _ in fh)
except OSError:
return 0
def describe_manifest(p: Path) -> list[str]:
name = p.name
try:
if name == "package.json":
data = json.loads(p.read_text(encoding="utf-8"))
out = []
if data.get("workspaces"):
out.append(f"workspaces: {data['workspaces']}")
for k, v in (data.get("scripts") or {}).items():
out.append(f"script {k}: {v}")
if data.get("main") or data.get("bin"):
out.append(f"main/bin: {data.get('main') or data.get('bin')}")
return out
if name == "pyproject.toml":
data = tomllib.loads(p.read_text(encoding="utf-8"))
out = []
proj = data.get("project", {})
if proj.get("scripts"):
out += [f"console script {k}: {v}" for k, v in proj["scripts"].items()]
tools = sorted(data.get("tool", {}).keys())
if tools:
out.append(f"tool config: {', '.join(tools)}")
return out
if name in {"Makefile", "justfile"}:
targets = re.findall(r"^([A-Za-z0-9_.-]+):(?!=)", p.read_text(encoding="utf-8", errors="replace"), re.M)
return [f"targets: {', '.join(dict.fromkeys(targets))}"] if targets else []
if name == "go.mod":
m = re.search(r"^module\s+(\S+)", p.read_text(encoding="utf-8"), re.M)
return [f"module {m.group(1)}"] if m else []
if name == "Cargo.toml":
data = tomllib.loads(p.read_text(encoding="utf-8"))
if "workspace" in data:
return [f"workspace members: {data['workspace'].get('members')}"]
return [f"crate {data.get('package', {}).get('name')}"]
except (OSError, ValueError, tomllib.TOMLDecodeError) as exc:
return [f"(could not parse: {exc.__class__.__name__})"]
return []
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description="Structural map of a repository")
ap.add_argument("repo_dir", nargs="?", default=".")
ap.add_argument("--depth", type=int, default=3, help="max depth for manifests/entry points")
ns = ap.parse_args(argv)
depth = ns.depth
root = Path(ns.repo_dir).resolve()
if not root.is_dir():
print(f"not a directory: {root}", file=sys.stderr)
return 1
lang_files: Counter[str] = Counter()
lang_lines: Counter[str] = Counter()
top_files: Counter[str] = Counter()
manifests: list[Path] = []
ci: list[Path] = []
entries: list[Path] = []
tests = 0
for p in walk(root):
rel = p.relative_to(root)
top = rel.parts[0] if len(rel.parts) > 1 else "(root files)"
top_files[top] += 1
lang = LANG.get(p.suffix.lower())
if lang:
lang_files[lang] += 1
lang_lines[lang] += count_lines(p)
if p.name in MANIFESTS and len(rel.parts) <= depth:
manifests.append(rel)
if rel.parts[0] in {".github", ".gitlab", ".circleci"} or p.name in {".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml"}:
if p.suffix in {".yml", ".yaml"} or p.name == "Jenkinsfile":
ci.append(rel)
if lang and (ENTRY_NAMES.match(p.name) or (p.name == "main.go")) and len(rel.parts) <= depth + 1:
entries.append(rel)
low = str(rel).lower()
if lang and ("test" in low or "spec" in low):
tests += 1
print(f"# Repo map: {root}\n")
print("## Languages (files, lines)")
for lang, n in lang_files.most_common(12):
print(f"- {lang}: {n} files, {lang_lines[lang]:,} lines")
print(f"- test-like source files: {tests}")
print("\n## Top-level directories (file count)")
for d, n in top_files.most_common(20):
print(f"- {d}: {n}")
print("\n## Manifests and build files")
for m in sorted(manifests, key=lambda r: (len(r.parts), str(r))):
print(f"- {m}")
for line in describe_manifest(root / m)[:25]:
print(f" {line}")
print("\n## CI / automation")
for c in sorted(ci) or []:
print(f"- {c}")
if not ci:
print("- none found")
print("\n## Candidate entry points")
by_dir: dict[str, list[str]] = defaultdict(list)
for e in sorted(entries, key=lambda r: (len(r.parts), str(r))):
by_dir[str(e.parent)].append(e.name)
for d, names in list(by_dir.items())[:30]:
print(f"- {d}/: {', '.join(names)}")
if not entries:
print("- none matched; check manifests (main/bin/scripts) and framework conventions")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.