setup-ts-deep-modules makes every package in a TypeScript repo a deep module, and then enforces it with a check. A package's public surface is its entry points: the files at the package root. Everything in its subfolders is hidden.
It installs dependency-cruiser, a tool that checks which files import which, with rules that make the entry points the only way in. Code outside a package may import only that package's entry points, never anything in its subfolders. Then it proves the rules bite.
It's user-invoked: an agent never starts it on its own. You type it yourself.
When to use it
On a TypeScript repo where you want the deep-module shape enforced by a check, not just agreed on.
When code (yours or an agent's) keeps reaching past a package's entry points into its internals.
When not to use it
To design or improve one module's interface, or decide where a seam goes, use codebase-design. setup-ts-deep-modules borrows its words but only enforces a folder shape.
On a repo that isn't TypeScript, such as the practice project's Python backend.
To decide which packages may depend on which. That's layering, a different concern, left as a commented stub in the config for you to fill in.
In a monorepo. Matt says to use the monorepo's own package support instead.
What it needs to start
A TypeScript repo. It works out three things first:
the package manager, from the lock file (npm if it finds none);
the packages root: src/packages if src/ exists, else packages;
whether a .dependency-cruiser.* config already exists. If one does, it merges its rules in and never overwrites it.
What it creates
dependency-cruiser as a devDependency, and its config at the repo root as .dependency-cruiser.cjs.
A lint:boundaries script, folded into the repo's check command that already runs typecheck.
An example package to copy or delete: an index.ts entry point, an internal lib/impl.ts, and a test that imports only the entry point.
A README.md in the packages folder that explains the convention, plus a one-line pointer to it from CLAUDE.md or AGENTS.md.
It's done only when lint:boundaries passes on the clean example, fails on a deliberate deep import, and passes again once that import is reverted.
Going deeper
6 min read · Going deeper not read
Why it exists
Matt names the gap on his codebase-design page (updated 2026-08-24). How to build a deep module in TypeScript is "the most-asked question about the skill", and codebase-design "says nothing about how to stop a stray import from reaching past the interface".
The question was put plainly in issue #458, opened by a user on 2026-07-05: "How do you implement these codebase design principles in Typescript?" Two days later they asked how to enforce an interface, since "without linting or clear guardrails, humans and LLMs alike will start making it messy over time".
Issue #458 on GitHub: How do you implement these codebase design principles in Typescript? From github.com, 30 October 2026.
Matt answered on 2026-07-07 with three options:
Wrap it in a class/IIFE, but the class/IIFE will get enormous
Use a monorepo and turn it into a package, but the tooling of a monorepo is rarely worth it
Use a linter like dependency-cruiser that prevents importing from certain modules from outside the module, haven't tried this but might be the closest thing to what you want
On 2026-07-10 he merged pull request #505, which added this Skill. It installs dependency-cruiser and "self-verifies that a deliberate deep import fails": the third option, tried for real.
Citations (88)
From snapshot/skills/setup-ts-deep-modules/SKILL.md, line 9: “Make every package in this repo a deep module: a lot of behaviour behind a small interface. A package's public surface is its entry points (the files at the package root), and everything in its subfolders is hidden.”
From snapshot/skills/setup-ts-deep-modules/SKILL.md, lines 3–4: “Wire dependency-cruiser into a TypeScript repo so each package is a deep module, with implementation hidden in subfolders and reachable only through its entry-point files. User-invoked. disable-model-invocation: true”
Matt files it in his in-progress bucket. That bucket's README says: "Beta. These skills are public on purpose: try them and tell me what breaks." They're kept out of the plugin, get no docs pages, "and they can change or disappear without warning". To install one, you name it: npx skills@latest add mattpocock/skills --skill=<name>.
Matt's in-progress bucket README at the pin commit: beta Skills that can change or disappear without warning. From github.com, 30 October 2026.
Sources disagree on one point. The same codebase-design page ranks the mechanisms: "Effect is the best and dependency-cruiser the second-best". It then says this Skill "ships no lint rule". That was already out of date when the page was written: pull request #505 merged with "Four error-level rules". The Skill's files at the pin commit (2026-10-29) are newer still, and their config's rules are all error. The SKILL.md decides: it ships lint rules.
How it works underneath
The idea comes from codebase-design: a deep module is "a lot of behaviour behind a small interface". In issue #458, on 2026-07-06, Matt wrote: "The file system can certainly be a useful hint to the shape of modules, but there's no need to use the file system in the construction of deep modules." This Skill goes further than a hint: it makes folder depth the rule a tool checks. In the SKILL.md's words: "Public vs private is decided by depth".
dependency-cruiser checks every import against a list of forbidden rules, each with a from and a to path. The trick is what its tutorial calls group matching: "this rule uses the $1 and $2 variables. These match the parts in the from.path between brackets." The Skill's config uses it so one rule covers every package. The importer's package name is captured as $1, and imports into $1's own internals are let through:
from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` },
to: {
path: PACKAGE_INTERNALS,
pathNot: `^${R}/$1/`, // same package → intra-package freedom
},
The SKILL.md says not to flatten these into separate per-package rules: the $1 back-references "are what let a package reach its own internals while outsiders can't".
The shape changed before the first merge. Inside pull request #505, the first draft made index.ts "the only public interface". Commit 3ea30af, pushed to the same pull request on 2026-07-10, renamed the Skill from setup-deep-modules to setup-ts-deep-modules. It also made every file at the package root an entry point: "Packages may expose SEVERAL." Only then did the pull request merge, so the Skill as merged already used entry points, not a single index.ts. Today's SKILL.md discourages barrel files that re-export a whole subtree.
The SKILL.md lists "Four rules", but the config holds five. The extra, tests-folder-is-private, stops anything but tests importing a package's tests/ fixtures.
The Skill's story so far runs from issue #458 to pull request #505, then from issue #1148 to pull request #1225.
issue #4582026-07-05
pull request #5052026-07-10
issue #11482026-10-01
pull request #12252026-10-08
The Skill's story so far
A real example
On 2026-08-26 a user filed issue #974, first titled "setup-ts-deep-modules can false-pass when dependency-cruiser cannot parse TypeScript". The next day they withdrew it, saying their agent had turned an unsupported setup into "a supposed skill defect". The real cause: "dependency-cruiser@18.2.0 explicitly does not support TypeScript 7". The Skill's proof step (step 6) had refused to call the job done, which the SKILL.md makes "the completion criterion for the whole skill". In the user's words: "The skill's mutation proof already did its job by preventing completion when the expected violation was not detected."
Where it goes wrong
The check can miss app code. Step 4 lets lint:boundaries run depcruise <packages-root>. Issue #1148 (2026-10-01) showed that this "does not include app importers outside that directory". With src/app.ts importing a package's internals, depcruise src/packages printed "no dependency violations found", while depcruise src caught it. The proof didn't notice either, since it "only mutates the example's test import". Matt's draft pull request #1225, opened on 2026-10-08, would always scan src and add an app-side probe to the proof. At the pin commit it isn't merged: step 4 still offers depcruise <packages-root>. So pick depcruise src yourself.
Newer Bun repos look like npm. The SKILL.md detects Bun only by bun.lockb. Pull request #1225 notes that "a Bun 1.2+ repo with only bun.lock falls through to npm." The same draft pull request fixes it.
Matt's draft pull request #1225: scan src in lint:boundaries, detect bun.lock, and add an app-side probe. From github.com, 30 October 2026.
Try it
In your practice project (set it up first with keeping-track's Try it step if you haven't), run this Skill in web/, the TypeScript website, never in the FastAPI backend. The plugin won't give you an in-progress Skill, so from inside web/ install it with npx skills@latest add mattpocock/skills --skill=setup-ts-deep-modules. Make sure web/ has a src/ folder first, so the Skill picks src/packages as its packages root (step 1 of its SKILL.md). Start Claude Code there, and type /setup-ts-deep-modules. When it writes lint:boundaries, ask for depcruise src. Once its proof passes, add the probe from draft pull request #1225 yourself: create web/src/boundary-probe.ts holding one line, import "./packages/example/lib/impl";, and run lint:boundaries. It should fail with entrypoint-boundary-from-app. Delete the file and run it again: it should pass. If the proof never fails, check that dependency-cruiser supports your TypeScript version (issue #974).
From snapshot/skills/codebase-design/SKILL.md, line 3: “Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable”
From snapshot/skills/setup-ts-deep-modules/SKILL.md, line 99: “The config's $1 back-references (dependency-cruiser's group matching) are what let a package reach its own internals while outsiders can't. Don't flatten them into separate per-package rules.”
From snapshot/skills/setup-ts-deep-modules/SKILL.md, lines 83–85: “Temporarily add a deep import to tests/example.test.ts (e.g. import { thing } from "../lib/impl"). Run lint:boundaries again; it must fail with tests-through-entrypoints.”