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.

  • namecodebase-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

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.

~/.claude/skills/codebase-onboarding/SKILL.md

--- name: codebase-onboarding description: Maps an unfamiliar repository quickly by finding entry points, build and test commands, module boundaries, data flow, and conventions,… license: MIT ---

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:

SignalsKindLook next at
pnpm-workspace.yaml, turbo.json, nx.json, go.work, Cargo [workspace]MonorepoPackage list and dependency graph (see monorepo-navigation)
next.config.*, app/ or pages/Next.js appapp/**/page.tsx, route.ts, middleware.ts
manage.py, settings.pyDjangourls.py, INSTALLED_APPS, models
main.go under cmd/<name>/Go service/CLIcmd/*/main.go, internal/
src/main/java, @SpringBootApplicationSpring BootApplication class, controllers, application.yml
config/routes.rbRailsRoutes, controllers, db/schema.rb
bin in package.json, [project.scripts]CLI toolThe bin target and its argument parser
Dockerfile, compose.yaml, k8s/, terraform/Deployed serviceStartup 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: bin targets, argparse/click/typer/cobra/clap definitions.
  • 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:

  1. Entry handler: file:line.
  2. Validation and auth: where and how.
  3. Business logic: which module/service owns the decision.
  4. Persistence or external calls: which repository/client, which tables/APIs.
  5. 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_ENV branches) 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 180 lines · 7.0 KB
#!/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.

esc