Codebase Visualizer
Commands
If the user's message starts with /visualize, read COMMANDS.md in this skill
directory and follow the matching subcommand. The most important ones:
/visualize— full build/visualize refetch— re-copy the renderer only, onecopy_path, touch nothing else/visualize refresh— re-scan and rewrite JSON only, leaveindex.htmlalone
A bare request like "visualize this codebase" means a full build.
Produces .visualize/ inside the target project:
.visualize/
index.html # copied verbatim from assets/visualizer.html — NEVER authored or edited
root.json # top-level graph (always)
<cluster>.json # optional nested graphs, MAX 9 (10 json files total)
The one hard rule
assets/visualizer.html is a fixed binary-like artifact. Copy it. Never read it to
"understand" it, never edit it, never regenerate it, never inline data into it.
It is a pure renderer: it contains zero graph data and loads everything from the JSON files beside it. Your entire job is producing good JSON.
This holds even when the user asks for visual or behavioural changes. If they ask for different colours, different grouping, fewer nodes, a different shape — that is almost always a data change: adjust clusters, weights, node counts, or edge kinds in the JSON. The renderer already derives colour, position, layering, and label priority from those fields.
Only touch assets/visualizer.html if the user explicitly and unambiguously asks you
to modify the visualizer/renderer itself. Then edit the file in the skill directory
and re-copy it, so every future project benefits. Never edit the copy inside a
project's .visualize/; it would be silently overwritten on the next run.
assets/root.json in this skill directory is a reference example of the schema. Read
it when you need a concrete shape to imitate. Do not copy it into a project.
preview/ is a ready-to-serve demo (index.html + root.json) for checking the
renderer without a real project:
python3 -m http.server 8000 -d ~/.agents/skills/visualize/preview
It holds copies. If the user ever has you change the renderer, re-copy both
assets/visualizer.html → preview/index.html and assets/root.json →
preview/root.json, or the preview goes stale.
Method: scan first, then reason
Do not LLM-read every file, and do not rely on parsing alone. Mix the two:
Phase 1 — mechanical scan (cheap, exhaustive).
Use terminal + grep to extract raw facts. Never read file bodies here.
- File inventory:
git ls-files | grep -Ev 'node_modules|dist|build|\.lock' - Import edges (TS/JS):
grep -rn "^import .* from ['\"]" --include=*.ts --include=*.tsx <src>Also catchrequire(,import(,export * from, Python^from|^import, Goimport (, Rust^use. - Resolve relative specifiers to repo-relative paths. Drop edges to
node_modules/stdlib. weight= fan-in count.kind: "entry"= zero fan-in,kind: "hot"= top decile fan-in.
Phase 2 — LLM condensation (targeted, small). Only now use judgement, and only on what the scan cannot give you:
- Name the clusters. Group by directory first, then merge/rename into meaningful domain
names (
auth,billing,inbox) using file names plus a handful of reads of the highest-fan-in files. - Classify non-import relations the scanner misses: a file that writes to a model,
emits a queue job, or mutates shared state → edge
kind: "write". - Drop noise edges (barrel
index.tsre-exports, type-only imports) so the map stays legible.
Budget rule: read at most ~15 source files in full. Everything else comes from grep
output. If the repo has >300 relevant files, keep the top ~120 by fan-in for root.json
and push the remainder into cluster files.
Nesting rule
root.jsonholds clusters and the top ~120 nodes.- Create a child
<cluster>.jsononly when a cluster has >25 internal nodes worth expanding. Hard cap: 9 child files. Never exceed 10 JSON files total. - A child uses the identical schema with
parentset to the cluster id. - Link parent → child by setting
child: "<file>.json"on the node that represents the cluster. The inspector then shows an "open cluster →" button.
Schema
{
"id": "root",
"label": "project-name",
"parent": null, // cluster id in child files
"nodes": [
{
"id": "inbox-verdict", // stable, unique, slug-like
"label": "inbox-verdict.ts", // shown on the map
"path": "backend/inbox/inbox-verdict.ts",
"group": "inbox", // cluster → district/building + auto colour
"kind": "core", // entry | core | hot
"weight": 3, // fan-in; drives label priority + index bars
"child": "inbox.json" // optional, only if a child file exists
}
],
"edges": [
{ "from": "mutate-ticket", "to": "inbox", "kind": "import" },
{ "from": "inbox", "to": "inbox-verdict", "kind": "write" }
]
}
group may be any string — colours are auto-assigned from a 7-way palette, so you
never need to match preset names. Edge kind is only "import" (cyan) or "write"
(violet). Keep both nodes and edges present and non-empty; the renderer shows a
diagnostic screen otherwise.
Procedure
- Confirm the target directory and detect the language(s).
- Run Phase 1 scans. Keep the raw edge list in memory, not on disk.
- Run Phase 2 condensation: clusters, weights, write-edges, child files.
create_directory<project>/.visualize/.copy_pathassets/visualizer.html→<project>/.visualize/index.html. Copy only. Do not open, read, or modify it.write_fileroot.json(+ any child files).- Suggest adding
.visualize/to.gitignoreunless the user wants it committed. - Tell the user the serve command. Do not start a server yourself unless asked.
The page must be served over HTTP — file:// blocks fetch, and the renderer will
show an explanatory screen if so:
python3 -m http.server 8000 -d .visualize
# then open http://localhost:8000
The served file must be named index.html, and the server's root must be the
.visualize folder itself. Both are easy to get wrong:
- Serving the skill/project root instead of
.visualizegives404on/, because there is noindex.htmlat that level. - Some static servers (including
serve) rewrite/visualizer.html→/visualizervia "clean URLs", producing a confusing301then404. Naming the fileindex.htmland serving/sidesteps this entirely. - Relative
fetch('./root.json')resolves against the URL directory, so the JSON must sit next to the HTML in the served root.
What the renderer does with your data
Useful to know so you can shape JSON well. You do not need to read the file to trust this.
Layout is a factory city, not a ladder or spreadsheet. Each group becomes an
irregular district/building containing varied, slightly offset module machines.
Districts are packed into a roughly 16:10 footprint, largest first. Thick stepped
roads occupy the gaps between them: dark pavement, heavy shoulders, service dots,
direction marks, bends, and junction plates. Active dependency cables run over this
road network. This matters for how you shape the data:
- Cluster count drives readability far more than node count. 6–12 clusters reads as a city. 19+ tiny clusters reads as confetti — merge aggressively.
- Avoid one giant catch-all cluster. If
sharedorutilsholds 40% of nodes, split it by purpose; one district will otherwise dominate the map. - Aim for roughly comparable district sizes. Wildly uneven clusters pack poorly.
kind: "entry"draws an amber edge stripe;kind: "hot"gets an accent outline.weight(fan-in) decides label priority, so get it right on the important files.
Nothing is active at rest. No wires render until the user selects something. The status bar shows real counts instead of an instructional overlay.
Interaction. Click a chip to reveal relations; click again or click empty floor to
rest. Shift/cmd-click pins extra modules (union). Relation switches: depends on /
used by / both (1 2 3), kind toggles for imports/writes, depth 1–3 ([ ]).
trace path runs BFS between 2+ pins. Hover ghost-previews immediate links. Left index
lists modules grouped by cluster; right inspector has depends-on / used-by / reach tabs
where every row re-focuses that module. Arrow keys walk neighbours. Minimap
click-to-jump. / search, esc reset, f fit.
Search is indexed once and relation-aware. Plain terms use ranked fuzzy matching on
file names and paths; relation names are included conservatively. Multiple terms are
ANDed, quoted phrases are supported, and prefixing any term with - excludes it.
Structured filters:
.tsortype:ts/ext:ts— exact extension (.tsdoes not include.tsx)group:auth— domain/clusterkind:hot— node kind (entry,core,hot)uses:modalordepends:modal— modules that depend on a matching moduleusedby:routeorcaller:route— modules used by a matching modulerel:queue— either relation direction
Filters compose, e.g. type:ts group:auth -kind:entry. Enter opens the highest-ranked
result. When a result is selected, its actual relation traversal takes precedence over
the search filter so dependencies remain visible.
Labels live in a screen-space layer that is never scaled, so text stays crisp at any zoom. A priority + collision pass places each one right → left → above → below, drawing a leader line when offset, and drops rather than stacks labels that do not fit. A zoom-dependent budget keeps wide views readable.
If a graph renders too sparse or too cluttered, adjust the data — fewer nodes, tighter clusters, pruned edges — not the renderer.