Onboarding Generator

commands no always-loaded rules -- loads on demand updated 2026-06-14

/onboarding - analyzes a repository and generates a structured ONBOARDING.md (architecture, dev setup, key commands, test workflow, glossary). Runs an inventory script to build a language-aware structural map, reads only the files that map surfaces, and writes prose with strict voice rules so the output reads like a knowledgeable teammate rather than generated documentation.

Tags

  • onboarding
  • documentation
  • inventory
  • command
  • skill

README

Onboarding Generator

Generates a structured ONBOARDING.md for any repository from a language-aware code inventory. Ships a thin /onboarding slash command, a skill with strict voice rules, and the inventory script the skill depends on.

The output is orientation for contributors (human or agent), not end-user documentation. Always regenerates from scratch.

What This Module Provides

Source Target Purpose
commands/onboarding.md commands/onboarding.md /onboarding slash command entry point
skills/onboarding/SKILL.md skills/onboarding/SKILL.md Voice rules, section contract, sanity checklist
scripts/inventory.mjs scripts/inventory.mjs Node script producing JSON inventory (languages, frameworks, entry points, scripts, docs, infra, monorepo)

The Output: ONBOARDING.md

Six sections, in this exact order:

  1. Overview - one paragraph, three sentences max.
  2. Architecture - concrete files and directories, ASCII diagram when two or more pieces interact.
  3. Dev Setup - prerequisites, install, env (link to .env.example), first-run command.
  4. Key Commands - short table of the commands a working engineer actually types.
  5. Test Workflow - unit / integration / e2e layout, commands, pre-push expectation.
  6. Glossary - five to fifteen project-specific terms, one sentence each.

Voice Rules

The skill enforces a specific voice. Output should read like a knowledgeable teammate explaining the repo over coffee:

  • Direct, no hedges ("probably", "might", "should usually" are banned).
  • Concrete over abstract - name files and directories, not "the middleware layer".
  • Match codebase formality - read CLAUDE.md / README.md first, mirror the tone.
  • Never include secrets - link to .env.example, never paste values.
  • Link instead of duplicate - if an answer lives in docs/architecture.md, point there.
  • No preamble ("Welcome!") or filler close ("That should get you started!").

Full list in skills/onboarding/SKILL.md.

Usage

/onboarding                    # Inventory the current repo, write ONBOARDING.md at the root
/onboarding --dry-run          # Print the markdown to stdout, do not write the file
/onboarding path/to/repo       # Inventory a specific path

You can also run the inventory script directly to inspect the JSON:

node ~/.claude/scripts/inventory.mjs . --pretty

Stack Coverage

The inventory script detects:

  • Languages: TypeScript, JavaScript, Python, Go, Rust, Ruby, Java, PHP, Swift, C/C++, Shell.
  • JS frameworks: Vite, Webpack, Next.js, Nuxt, Remix, SvelteKit, Astro, Angular, React, Vue, Svelte, Solid.
  • Styling / UI: Tailwind, styled-components.
  • Backend / runtimes: Express, Fastify, Hono, Cloudflare Workers.
  • Testing: Vitest, Jest, Playwright, Cypress, pytest, RSpec, go test, cargo test.
  • Chrome extensions: detects manifest.json with MV2/MV3 structure, surfaces background / content / popup / options entry points.
  • Monorepos: npm/pnpm/yarn workspaces, turborepo, nx, lerna.
  • Package managers: pnpm, npm, yarn, bun, poetry, pipenv, cargo, bundler.
  • Infrastructure: Docker, docker-compose, Cloudflare Workers, Vercel, Netlify, GitHub Actions, CircleCI, Fly.io, Render, Heroku, Supabase, Terraform, Husky.

The script is bounded (depth 4, ignores node_modules, .git, dist, build output, caches). It never reads file contents beyond the manifests and config files it explicitly opens.

Manual Installation

mkdir -p ~/.claude/commands
mkdir -p ~/.claude/skills/onboarding
mkdir -p ~/.claude/scripts

cp modules/onboarding/commands/onboarding.md \
   ~/.claude/commands/onboarding.md

cp modules/onboarding/skills/onboarding/SKILL.md \
   ~/.claude/skills/onboarding/SKILL.md

cp modules/onboarding/scripts/inventory.mjs \
   ~/.claude/scripts/inventory.mjs

Requires node on $PATH. No other dependencies.

Design Notes

  • Always regenerates from scratch. No diffing against a prior ONBOARDING.md. Regeneration keeps the voice consistent and avoids fossilized claims that survive a refactor.
  • Inventory first, then read. The skill reads only the files the inventory surfaces - entry points, top-level docs, env example. It never globs the whole tree, never reads lockfiles, never opens node_modules.
  • Separation of concerns. The command is a thin argument parser and runner. The skill holds the voice rules and section contract. The inventory script is pure structural analysis with no prose logic.
  • No overlap with /docupdate. /docupdate audits existing documentation for drift. /onboarding writes a single new file from scratch. They are complementary.

Source

Pattern adapted from EveryInc's compound-engineering plugin (skills/onboarding/SKILL.md, skills/onboarding/scripts/inventory.mjs). The original is Ruby-aware; this implementation adds explicit support for Vite, Next.js, Chrome extensions, and monorepo tooling (turborepo, nx, pnpm workspaces) that appear across the portfolio.

Will install

Path Action Target Type
commands/onboarding.md commands/onboarding.md command
skills/onboarding/SKILL.md skills/onboarding/SKILL.md skill
scripts/inventory.mjs scripts/inventory.mjs script

Dependencies

No dependencies.

Required by

No other module depends on this one.

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/onboarding.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install onboarding@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

command (1)

commands/onboarding.md

---
description: Generate a structured ONBOARDING.md for the current repo from a code inventory. Architecture, dev setup, key commands, test workflow, glossary. Always regenerates from scratch.
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
argument-hint: "[path] [--dry-run]"
---

# /onboarding - Structured ONBOARDING.md Generator

Analyze the current repository and write `ONBOARDING.md` at the repo root. The file covers architecture, dev setup, key commands, test workflow, and a project glossary - sized for a new engineer (or a fresh Claude session) to get productive in under ten minutes.

Always regenerates from scratch. Never diffs against an existing ONBOARDING.md.

Follow the detailed instructions in `~/.claude/skills/onboarding/SKILL.md`. This command is the thin entry point; the skill holds the voice rules and the section contract.

---

## Phase 0: Parse Arguments

Extract from `$ARGUMENTS`:

- **Positional path** (optional): target repo root. Defaults to `$PWD`.
- **`--dry-run`**: print the generated markdown to stdout; do not write `ONBOARDING.md`.

If a path argument is present and is not a directory, stop and tell the user.

---

## Phase 1: Run the Inventory

Run the inventory script and capture the JSON output:

```bash
node ~/.claude/scripts/inventory.mjs "$TARGET_ROOT" --pretty
```

Read the returned JSON. If the script errors, or the output shows `languages: []` and `frameworks: []` and `scripts: {}`, STOP. Tell the user the repo looks too sparse for a meaningful onboarding doc and ask what they want covered instead.

Highlight inventory `notes[]` to the user if any are non-empty (for example "No README detected at repo root").

---

## Phase 2: Read What the Inventory Surfaces

Read only the files the inventory points at. The target set is:

- Every entry in `docs[]` (READMEs, architecture notes, CLAUDE.md, AGENTS.md, subdocs).
- The first one or two `entryPoints[]` that look like a human would start there.
- The `package.json` (or Cargo.toml / pyproject.toml / go.mod) for the scripts and dependency context already summarized in the inventory.
- The `envExample` file if present.

Do NOT glob the whole tree. Do NOT read lockfiles, generated output, node_modules, or .git.

For a monorepo, pick two or three representative workspace packages to sketch in Architecture. Do not enumerate every package.

---

## Phase 3: Write the Six Sections

Follow the section contract in the skill exactly. The output has six sections in this order:

1. **Overview** - one paragraph, three sentences max.
2. **Architecture** - concrete files and directories, ASCII diagram when two or more pieces interact.
3. **Dev Setup** - prerequisites, install, env (link to `.env.example`), first-run command.
4. **Key Commands** - short table of the commands a working engineer actually types.
5. **Test Workflow** - unit / integration / e2e layout, commands, pre-push expectation.
6. **Glossary** - five to fifteen project-specific terms, one sentence each.

Honor every voice rule from `~/.claude/skills/onboarding/SKILL.md`:

- Direct, no hedges, concrete over abstract.
- Match the formality of the existing `CLAUDE.md` / `README.md` tone.
- Never paste secrets, keys, or production URLs. Link to the env example.
- Link to existing docs instead of duplicating their content.
- No "Welcome!" preamble, no "That should get you started!" closer.

---

## Phase 4: Sanity Check

Before writing the file:

- [ ] Six sections present in the required order.
- [ ] No secrets or real URLs.
- [ ] No hedge words ("probably", "might", "should usually", "in most cases").
- [ ] File paths referenced exist in `entryPoints[]` or `docs[]` from the inventory.
- [ ] Under ~300 lines.
- [ ] Opens with `# Onboarding` or `# {name} Onboarding`, not "Welcome" or "This document...".

If any check fails, edit the draft before writing.

---

## Phase 5: Write or Print

If `--dry-run` was passed, print the markdown to stdout and stop.

Otherwise:

```bash
# Overwrite; regeneration is always from scratch.
# (Write tool, not bash heredoc - preserves exact content.)
```

Write the content to `${TARGET_ROOT}/ONBOARDING.md` using the Write tool.

Report to the user:

- File written (absolute path).
- Line count.
- Any inventory `notes[]` that were non-empty (gaps the user should know about).

Stop. Do not follow up with "Would you like me to..." - the file is the deliverable.
skill (1)

skills/onboarding/SKILL.md

---
name: onboarding
description: >
  Generate a structured ONBOARDING.md for the current repository. Runs an inventory script to build a language-aware structural map (languages, frameworks, entry points, scripts, docs, infrastructure, monorepo layout), reads only the files the inventory surfaces, then writes prose that reads like a knowledgeable teammate over coffee - not generated documentation. Always regenerates from scratch; no diffing against the previous file.
  Triggers: onboarding doc, write ONBOARDING.md, generate project onboarding, new engineer guide, fresh session primer.
---

# onboarding - Structured ONBOARDING.md Generator

Produce a single file at the repo root: `ONBOARDING.md`. The file is the deliverable. The skill does not modify any other documentation.

## When to Run

- A new engineer (or a fresh Claude session) will read this repo for the first time.
- The repo has grown enough that `README.md` alone no longer answers "how do I ship a change here today?"
- The project just crossed a refactor boundary and the old onboarding notes are stale.
- The user asks for "the five-minute tour" or an orientation doc.

Do NOT run for:

- Single-file utilities, one-off scripts, or projects without a meaningful internal structure.
- Private forks of well-documented upstreams (point the user at the upstream docs instead).
- Monorepo workspaces that already have per-package READMEs covering the same ground.

## Phase 0: Run the Inventory

Always start with the inventory script. It is cheap and the writer depends on its output.

```bash
node ~/.claude/scripts/inventory.mjs "$PWD" --pretty
```

If the command errors or returns a shallow inventory (no languages, no frameworks, no scripts), STOP and ask the user what kind of repo this is. Do not guess. A wrong onboarding doc is worse than no onboarding doc.

The inventory JSON contains:

- `name`, `description`, `languages`, `frameworks`, `packageManager`
- `monorepo` (isMonorepo, tool, workspaces)
- `entryPoints` (web, node-main, bin, ext-background, ext-content, python, go, rust)
- `scripts` (package.json scripts plus Makefile targets)
- `docs` (README, CLAUDE.md, docs/*, ARCHITECTURE.md, etc.)
- `infrastructure` (docker, cloudflare-workers, github-actions, husky, ...)
- `testRunners`, `envExample`, `topLevelDirs`, `notes`

## Phase 1: Read Only What the Inventory Surfaces

Read the specific files the inventory points at:

- Every doc in `docs[]` (they often already contain the answer; link, do not duplicate).
- `package.json` (or equivalent manifest) for the `scripts` and dependency context.
- `CLAUDE.md` and/or `AGENTS.md` at the repo root (the tone and project conventions live there).
- One or two entry points so architecture claims are grounded in real code.
- The env example file if present (for the "Setup" section).

Do NOT glob-read every source file. Do NOT read lockfiles. Do NOT read generated build output.

If the inventory lists a monorepo, pick the two or three workspace packages that look like the primary entry points (by scripts, entry points, or dependency surface) and sketch them. Do not enumerate every package.

## Phase 2: Write ONBOARDING.md

Always regenerate from scratch. Never diff against a prior ONBOARDING.md - regeneration keeps the voice consistent and avoids fossilized claims. If a prior ONBOARDING.md exists, overwrite it.

The output has six sections in this exact order:

1. **Overview** - one paragraph: what this repo is, who uses it, what it ships. Three sentences maximum.
2. **Architecture** - key components and how they connect. Use a small ASCII diagram when more than two pieces interact. Name concrete files and directories.
3. **Dev Setup** - prerequisites (languages, runtime versions), install command, env vars (link to `.env.example` - never paste values), first-run command.
4. **Key Commands** - a short table of the scripts a working engineer actually types. Trim noise (leave out `postinstall`, `prepare`, `lint:fix` unless meaningful). If a command has an obvious gotcha, note it in one clause.
5. **Test Workflow** - how tests are organized (unit / integration / e2e), how to run each tier, what counts as "tests pass before push."
6. **Glossary** - five to fifteen project-specific terms. Definitions in one sentence each. No generic terminology (do not define "TypeScript"). Lucas-specific portfolio terms like `clone`, `workspace`, `agent-id` go here when the repo uses them.

Link to files with relative paths. Use `file.ts:42` when pointing at a specific line.

## Voice Rules

These are non-negotiable. The output should read like a knowledgeable teammate explaining the repo over coffee.

- **Direct**: "Run `npm run dev`" not "You can run `npm run dev` to start the development server."
- **Cut hedges**: no "probably", "might", "should", "in most cases." If the claim needs a hedge, it is not confident enough to include.
- **Concrete over abstract**: "The auth middleware sets `req.user` from the JWT in `Authorization`" not "Authentication is handled by the middleware layer."
- **Match codebase formality**: if `CLAUDE.md` is terse and jokey, match it. If it is formal, match that. Read the existing tone before writing.
- **Never include secrets**: no example keys, no sample tokens, no production URLs. Link to `.env.example` and move on.
- **Link instead of duplicate**: if the answer lives in `docs/architecture.md`, point there. The goal is orientation, not a second copy of the docs.
- **No preamble**: the file opens with `# Onboarding` (or `# {name} Onboarding`) and goes straight to the Overview. No "Welcome!" No "This document will help you..."
- **No filler summaries**: do not close with "That should get you started!" End on the last useful sentence.

## Anti-Patterns to Refuse

Refuse to produce output in these shapes, even if the user asks. Say what you are doing differently and continue.

- A bulleted list of every file in the repo. Onboarding is orientation, not inventory.
- A rewrite of the README. If the README covers the answer, link to it.
- Step-by-step tutorials for how to use the product. Onboarding is for contributors, not end users.
- Marketing prose ("our cutting-edge platform"). This is an internal doc for engineers.
- Duplicate `.env` values. Ever. Link to the example file.

## Phase 3: Sanity Check

After writing, re-read the file once before finalizing:

- [ ] Every section is present in the required order.
- [ ] No secrets, keys, or real URLs appear.
- [ ] No hedges slipped in ("probably", "might", "should usually").
- [ ] Every file path referenced actually exists (cross-check against inventory `docs[]` / `entryPoints[]`).
- [ ] The doc is under ~300 lines. If longer, cut the Architecture diagram or the Glossary.
- [ ] Opening sentence does not start with "Welcome" or "This document."

Then write to `ONBOARDING.md` at the repo root. Report file path and line count. Done.

## Notes for the Calling Agent

- The skill is idempotent. Running it twice regenerates the same shape.
- The inventory script is deliberately bounded (depth 4, ignores build output). For a very large monorepo (dozens of workspaces), let the inventory finish, then pick a small, interesting subset rather than re-expanding the scan.
- If the user wants a preview, pass `--dry-run` to the containing command - the skill prints the draft to stdout and skips the write.
script (1)

scripts/inventory.mjs

#!/usr/bin/env node
// inventory.mjs - Language-aware structural map of a repository.
//
// Reads a repo root (default: cwd) and prints a JSON object to stdout
// describing languages, frameworks, entry points, scripts, existing docs,
// infrastructure, and monorepo structure.
//
// The /onboarding command consumes this JSON. It is intentionally cheap:
// directory walks are bounded by depth, every glob is scoped, binary and
// lock files are skipped. The script never reads file contents beyond the
// set of manifests and config files it explicitly opens.
//
// Usage:
//   node inventory.mjs                    # inventory cwd
//   node inventory.mjs /path/to/repo      # inventory another path
//   node inventory.mjs --pretty           # pretty-print JSON
//
// Exit codes:
//   0 - ok (JSON printed)
//   1 - fatal error (root path missing, etc.)

import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
import { join, basename, relative, resolve } from "node:path";

// ---------- Config ----------

const MAX_DEPTH = 4;
const IGNORED_DIRS = new Set([
  "node_modules",
  ".git",
  ".next",
  ".nuxt",
  ".svelte-kit",
  ".turbo",
  ".cache",
  ".vercel",
  ".wrangler",
  "dist",
  "build",
  "out",
  "coverage",
  ".venv",
  "venv",
  "__pycache__",
  ".pytest_cache",
  "target",
  "vendor",
  "tmp",
]);

// ---------- CLI ----------

const args = process.argv.slice(2);
const pretty = args.includes("--pretty");
const positional = args.filter((a) => !a.startsWith("--"));
const rootArg = positional[0] || process.cwd();
const ROOT = resolve(rootArg);

if (!existsSync(ROOT) || !statSync(ROOT).isDirectory()) {
  process.stderr.write(`inventory: not a directory: ${ROOT}\n`);
  process.exit(1);
}

// ---------- Helpers ----------

function safeRead(path) {
  try {
    return readFileSync(path, "utf8");
  } catch {
    return null;
  }
}

function safeJson(path) {
  const raw = safeRead(path);
  if (!raw) return null;
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

function exists(rel) {
  return existsSync(join(ROOT, rel));
}

function firstExisting(paths) {
  for (const p of paths) if (exists(p)) return p;
  return null;
}

function listTopLevelDirs() {
  try {
    return readdirSync(ROOT, { withFileTypes: true })
      .filter((d) => d.isDirectory() && !IGNORED_DIRS.has(d.name) && !d.name.startsWith("."))
      .map((d) => d.name)
      .sort();
  } catch {
    return [];
  }
}

function walk(dir, depth, out, filter) {
  if (depth > MAX_DEPTH) return;
  let entries;
  try {
    entries = readdirSync(dir, { withFileTypes: true });
  } catch {
    return;
  }
  for (const e of entries) {
    if (IGNORED_DIRS.has(e.name)) continue;
    const full = join(dir, e.name);
    if (e.isDirectory()) {
      walk(full, depth + 1, out, filter);
    } else if (e.isFile() && filter(e.name, full)) {
      out.push(relative(ROOT, full));
    }
  }
}

function globFiles(filter) {
  const out = [];
  walk(ROOT, 0, out, filter);
  return out.sort();
}

// ---------- Detection ----------

function detectLanguages() {
  const langs = new Set();
  if (exists("package.json")) langs.add("javascript");
  if (exists("tsconfig.json") || globFiles((n) => n.endsWith(".ts") || n.endsWith(".tsx")).length > 0) {
    langs.add("typescript");
  }
  if (exists("Cargo.toml")) langs.add("rust");
  if (exists("pyproject.toml") || exists("requirements.txt") || exists("setup.py")) langs.add("python");
  if (exists("go.mod")) langs.add("go");
  if (exists("Gemfile")) langs.add("ruby");
  if (exists("composer.json")) langs.add("php");
  if (exists("pom.xml") || exists("build.gradle") || exists("build.gradle.kts")) langs.add("java");
  if (exists("Package.swift")) langs.add("swift");
  if (globFiles((n) => n === "CMakeLists.txt" || n.endsWith(".cpp") || n.endsWith(".cc")).length > 0) {
    langs.add("cpp");
  }
  if (globFiles((n) => n.endsWith(".sh")).length > 0) langs.add("shell");
  return [...langs].sort();
}

function detectFrameworks(pkg) {
  const fw = new Set();
  const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };

  // Build tools
  if (deps["vite"]) fw.add("vite");
  if (deps["webpack"]) fw.add("webpack");
  if (deps["next"]) fw.add("next.js");
  if (deps["nuxt"] || deps["nuxt3"]) fw.add("nuxt");
  if (deps["@remix-run/react"] || deps["@remix-run/node"]) fw.add("remix");
  if (deps["@sveltejs/kit"]) fw.add("sveltekit");
  if (deps["astro"]) fw.add("astro");
  if (deps["@angular/core"]) fw.add("angular");

  // UI
  if (deps["react"]) fw.add("react");
  if (deps["react-dom"]) fw.add("react-dom");
  if (deps["vue"]) fw.add("vue");
  if (deps["svelte"]) fw.add("svelte");
  if (deps["solid-js"]) fw.add("solid");

  // Styling
  if (deps["tailwindcss"]) fw.add("tailwind");
  if (deps["styled-components"]) fw.add("styled-components");

  // Backend / runtimes
  if (deps["express"]) fw.add("express");
  if (deps["fastify"]) fw.add("fastify");
  if (deps["hono"]) fw.add("hono");
  if (deps["wrangler"] || exists("wrangler.toml") || exists("wrangler.jsonc")) fw.add("cloudflare-workers");
  if (deps["@cloudflare/workers-types"]) fw.add("cloudflare-workers");

  // Testing
  if (deps["vitest"]) fw.add("vitest");
  if (deps["jest"]) fw.add("jest");
  if (deps["@playwright/test"] || deps["playwright"]) fw.add("playwright");
  if (deps["cypress"]) fw.add("cypress");

  // Backend-as-a-service
  if (deps["@supabase/supabase-js"] || exists("supabase")) fw.add("supabase");
  if (deps["firebase"] || deps["firebase-admin"]) fw.add("firebase");

  // Chrome extension detection (root, plus common CRX manifest homes)
  const manifestCandidates = ["manifest.json", "public/manifest.json", "static/manifest.json", "src/manifest.json"];
  for (const mp of manifestCandidates) {
    if (!exists(mp)) continue;
    const m = safeJson(join(ROOT, mp));
    if (m && (m.manifest_version || m.background || m.content_scripts)) {
      fw.add("chrome-extension");
      break;
    }
  }

  // Python frameworks
  const reqs = safeRead("requirements.txt") || "";
  const pyproject = safeRead("pyproject.toml") || "";
  const pyBlob = reqs + "\n" + pyproject;
  if (/\bdjango\b/i.test(pyBlob)) fw.add("django");
  if (/\bflask\b/i.test(pyBlob)) fw.add("flask");
  if (/\bfastapi\b/i.test(pyBlob)) fw.add("fastapi");

  return [...fw].sort();
}

function detectPackageManager() {
  if (exists("pnpm-lock.yaml")) return "pnpm";
  if (exists("yarn.lock")) return "yarn";
  if (exists("bun.lockb") || exists("bun.lock")) return "bun";
  if (exists("package-lock.json")) return "npm";
  if (exists("poetry.lock")) return "poetry";
  if (exists("Pipfile.lock")) return "pipenv";
  if (exists("Cargo.lock")) return "cargo";
  if (exists("Gemfile.lock")) return "bundler";
  return null;
}

function detectMonorepo(pkg) {
  const out = { isMonorepo: false, tool: null, workspaces: [] };
  if (pkg?.workspaces) {
    out.isMonorepo = true;
    out.tool = exists("pnpm-workspace.yaml") ? "pnpm" : "npm/yarn";
    const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages || [];
    out.workspaces = ws;
  } else if (exists("pnpm-workspace.yaml")) {
    out.isMonorepo = true;
    out.tool = "pnpm";
    const raw = safeRead("pnpm-workspace.yaml") || "";
    out.workspaces = raw
      .split("\n")
      .map((l) => l.trim())
      .filter((l) => l.startsWith("- "))
      .map((l) => l.slice(2).replace(/^["']|["']$/g, ""));
  } else if (exists("turbo.json") || exists("nx.json") || exists("lerna.json")) {
    out.isMonorepo = true;
    out.tool = exists("turbo.json") ? "turborepo" : exists("nx.json") ? "nx" : "lerna";
  }
  return out;
}

function detectEntryPoints(pkg) {
  const entries = [];
  // Package-declared entries
  if (pkg?.main) entries.push({ kind: "node-main", path: pkg.main });
  if (pkg?.module) entries.push({ kind: "esm-main", path: pkg.module });
  if (pkg?.bin) {
    if (typeof pkg.bin === "string") entries.push({ kind: "bin", path: pkg.bin });
    else for (const [name, p] of Object.entries(pkg.bin)) entries.push({ kind: `bin:${name}`, path: p });
  }

  // Vite / Next / app shells
  const webEntries = [
    "index.html",
    "src/main.ts",
    "src/main.tsx",
    "src/main.js",
    "src/main.jsx",
    "src/index.ts",
    "src/index.tsx",
    "src/index.js",
    "src/index.jsx",
    "app/page.tsx",
    "app/page.js",
    "pages/index.tsx",
    "pages/index.js",
    "app/layout.tsx",
  ];
  for (const p of webEntries) {
    if (exists(p)) entries.push({ kind: "web", path: p });
  }

  // Chrome extension (root, plus common CRX manifest homes)
  const extManifestCandidates = ["manifest.json", "public/manifest.json", "static/manifest.json", "src/manifest.json"];
  for (const mp of extManifestCandidates) {
    if (!exists(mp)) continue;
    const m = safeJson(join(ROOT, mp));
    if (!m) continue;
    if (m.background?.service_worker) entries.push({ kind: "ext-background", path: m.background.service_worker });
    if (m.background?.scripts) for (const s of m.background.scripts) entries.push({ kind: "ext-background", path: s });
    if (m.content_scripts) {
      for (const cs of m.content_scripts) for (const s of cs.js || []) entries.push({ kind: "ext-content", path: s });
    }
    if (m.action?.default_popup) entries.push({ kind: "ext-popup", path: m.action.default_popup });
    if (m.options_page) entries.push({ kind: "ext-options", path: m.options_page });
    break;
  }

  // Python
  if (exists("manage.py")) entries.push({ kind: "python", path: "manage.py" });
  if (exists("main.py")) entries.push({ kind: "python", path: "main.py" });
  if (exists("app.py")) entries.push({ kind: "python", path: "app.py" });

  // Go
  if (exists("main.go")) entries.push({ kind: "go", path: "main.go" });
  if (exists("cmd")) entries.push({ kind: "go", path: "cmd/" });

  // Rust
  if (exists("src/main.rs")) entries.push({ kind: "rust-bin", path: "src/main.rs" });
  if (exists("src/lib.rs")) entries.push({ kind: "rust-lib", path: "src/lib.rs" });

  return entries;
}

function detectScripts(pkg) {
  const scripts = {};
  if (pkg?.scripts) Object.assign(scripts, pkg.scripts);
  // Makefile targets
  if (exists("Makefile")) {
    const raw = safeRead("Makefile") || "";
    const targets = [...raw.matchAll(/^([a-zA-Z][a-zA-Z0-9_-]*):/gm)].map((m) => m[1]);
    if (targets.length) scripts.__makefile__ = targets;
  }
  return scripts;
}

function detectDocs() {
  const candidates = [
    "README.md",
    "README.rst",
    "CONTRIBUTING.md",
    "CHANGELOG.md",
    "ARCHITECTURE.md",
    "CLAUDE.md",
    "AGENTS.md",
    "ONBOARDING.md",
    "SETUP.md",
    "docs",
    ".github/CONTRIBUTING.md",
  ];
  const out = [];
  for (const p of candidates) if (exists(p)) out.push(p);
  // Subdocs
  if (exists("docs")) {
    const docFiles = globFiles((n, full) => {
      if (!full.includes(`${ROOT}/docs/`)) return false;
      return n.endsWith(".md") || n.endsWith(".mdx") || n.endsWith(".rst");
    }).slice(0, 20);
    out.push(...docFiles);
  }
  return out;
}

function detectInfrastructure() {
  const infra = [];
  const checks = [
    ["Dockerfile", "docker"],
    ["docker-compose.yml", "docker-compose"],
    ["docker-compose.yaml", "docker-compose"],
    [".dockerignore", "docker"],
    ["wrangler.toml", "cloudflare-workers"],
    ["wrangler.jsonc", "cloudflare-workers"],
    ["vercel.json", "vercel"],
    ["netlify.toml", "netlify"],
    [".github/workflows", "github-actions"],
    [".circleci/config.yml", "circleci"],
    ["fly.toml", "fly.io"],
    ["render.yaml", "render"],
    ["supabase", "supabase"],
    ["terraform", "terraform"],
    ["Procfile", "heroku"],
    [".husky", "husky"],
  ];
  for (const [p, label] of checks) if (exists(p)) infra.push(label);
  return [...new Set(infra)].sort();
}

function detectTestRunners(pkg) {
  const runners = [];
  const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
  if (deps.vitest) runners.push("vitest");
  if (deps.jest) runners.push("jest");
  if (deps.mocha) runners.push("mocha");
  if (deps.ava) runners.push("ava");
  if (deps["@playwright/test"] || deps.playwright) runners.push("playwright");
  if (deps.cypress) runners.push("cypress");
  if (exists("pytest.ini") || /\bpytest\b/.test(safeRead("pyproject.toml") || "")) runners.push("pytest");
  if (exists(".rspec") || exists("spec")) runners.push("rspec");
  if (exists("go.mod")) runners.push("go test");
  if (exists("Cargo.toml")) runners.push("cargo test");
  return [...new Set(runners)];
}

// ---------- Build inventory ----------

const pkg = safeJson(join(ROOT, "package.json"));
const topLevelDirs = listTopLevelDirs();
const languages = detectLanguages();
const frameworks = detectFrameworks(pkg);
const packageManager = detectPackageManager();
const monorepo = detectMonorepo(pkg);
const entryPoints = detectEntryPoints(pkg);
const scripts = detectScripts(pkg);
const docs = detectDocs();
const infrastructure = detectInfrastructure();
const testRunners = detectTestRunners(pkg);

const manifestName = pkg?.name || basename(ROOT);

const envExample = firstExisting([".env.example", ".env.template", ".env.sample"]);
const nodeVersionFile = firstExisting([".nvmrc", ".node-version"]);
const pythonVersionFile = firstExisting([".python-version"]);

const inventory = {
  root: ROOT,
  name: manifestName,
  description: pkg?.description || null,
  languages,
  frameworks,
  packageManager,
  nodeVersion: nodeVersionFile ? (safeRead(join(ROOT, nodeVersionFile)) || "").trim() : null,
  pythonVersion: pythonVersionFile ? (safeRead(join(ROOT, pythonVersionFile)) || "").trim() : null,
  monorepo,
  entryPoints,
  scripts,
  docs,
  infrastructure,
  testRunners,
  envExample,
  topLevelDirs,
  notes: [],
};

// Sanity notes the writer may surface to the user.
if (!inventory.docs.includes("README.md") && !inventory.docs.includes("README.rst")) {
  inventory.notes.push("No README detected at repo root.");
}
if (languages.length === 0) {
  inventory.notes.push("No known language manifests detected; inventory may be shallow.");
}
if (monorepo.isMonorepo && monorepo.workspaces.length === 0) {
  inventory.notes.push("Monorepo tooling detected but no workspace globs resolved.");
}

process.stdout.write(pretty ? JSON.stringify(inventory, null, 2) : JSON.stringify(inventory));
process.stdout.write("\n");