TypeScript CLI / Agent Project Architecture & Testing
Overview
Group code by feature, not by technical layer. Give each subsystem one central registry. Co-locate everything a feature needs — including its tests. Distilled from the Claude Code CLI source (<workspace>\claude-code-main\src) plus 2026 industry practice (vertical-slice / modular-monolith, Vitest testing honeycomb).
Core principle: adding a feature should mean adding a folder, not editing files across 5 directories. Deleting a feature should mean deleting one folder.
Language-agnostic parent: baseline-dev-architecture (the cross-language invariants this skill instantiates for TypeScript/Bun).
When to Use
- Starting a new TS/Bun/Node CLI, agent runtime, or backend service.
- Deciding where a new tool, command, route, or module belongs.
- Setting up or fixing the test harness, smoke tests, or CI gates.
When NOT to use: tiny scripts (<10 files) — a flat src/ is fine. Rust projects → use rust-workspace-architecture instead.
Canonical Folder Layout
src/
main.ts(x) # CLI entry: arg parsing + routing ONLY (thin)
app.ts # builds/configures the program (testable, no side effects)
<Subsystem>.ts # the INTERFACE/contract (e.g. Tool.ts)
<subsystems>.ts # the REGISTRY (e.g. tools.ts → getAllTools())
config/
env.ts # Zod-validated env, fails fast at startup
tools/ # one folder per feature (vertical slice)
BashTool/
BashTool.ts # the feature's main impl (matches folder name)
bashPermissions.ts # concerns as sibling files, prefixed by feature
bashSecurity.ts
BashTool.test.ts # unit test, CO-LOCATED
index.ts # public API — the only legal import surface
commands/ screens/ services/ # same registry+folder pattern per subsystem
shared/ (or utils/, lib/) # cross-feature helpers ONLY
types/ # shared/branded types
- Registry pattern:
tools.tsimports each tool and exports a getter (getAllTools()); feature-gating/conditional includes live ONLY in the registry, never scattered. - Interface contract: every feature of a kind implements one typed contract (
Tool<Input, Output>with a Zod input schema +call()+ permission/render hooks). No partial implementations. - Co-located concerns, no deep nesting: all of a feature's files are direct children of its folder, prefixed by feature name (
bashPermissions.ts). No innerlib/. - Split entry from config:
main.tsonly parses args + routes;app.tsbuilds the configured program so it's testable without launching.
Naming & Boundaries (quick reference)
| Thing | Convention |
|---|---|
| Feature folder + its main file | PascalCase/ + PascalCase.ts(x) matching the folder |
| Helper / utility files | camelCase.ts or kebab-case.ts, feature-prefixed |
| Test files | *.test.ts (unit) · *.integration.test.ts (real infra), co-located |
| Public surface | index.ts barrel per feature; export only what others may use |
| Imports | relative; .js extension on ESM specifiers; optionally @/ alias for src/ |
Boundary rules (enforce with eslint-plugin-boundaries):
- A feature NEVER imports another feature directly. The
index.tsbarrel governs reuse within a subsystem; any cross-feature or cross-subsystem need (a command needing a tool, shared auth/session state) goes throughshared/— extract the shared piece there and call it explicitly. - UI/screens import services; services never import UI.
- Validate at the boundary (Zod), translate errors centrally, keep one typed config object.
Testing Strategy
Framework: Vitest (2026 default — native ESM/TS, ~200ms cold start, Jest-compatible API).
On Bun:
bun testalso works (same*.test.tsnaming;bun build --compilemakes a single-file binary) but its API is not 100% Vitest/Jest-compatible — pick one runner project-wide. Vitest runs fine under Bun and is the safer default for Jest-API parity.
Shape = testing honeycomb, not a naive pyramid: integration tests give the best bug-catch ROI. Mock only at boundaries with no interesting runtime behavior; use real infra (DB/queue/cache via Testcontainers) where implementation details bite.
| Layer | Where | What | Tool |
|---|---|---|---|
| Unit | *.test.ts beside source |
pure logic, branches, edge cases | Vitest + DI (pass deps, avoid vi.mock path-coupling) |
| Integration | *.integration.test.ts |
repos, route handlers, real DB | Vitest + Testcontainers / Supertest |
| E2E (sparingly) | tests/e2e/ |
critical user journeys only | Playwright |
- Separate runs via Vitest
projects:unit(watch locally) vsintegration(CI, single-thread, long timeout) —vitest run --project unit|integration. - Coverage gate (
provider: 'v8'): fail CI below ~lines/functions 80, branches 75. Don't chase 100%. - AAA structure; reset mocks (
vi.clearAllMocks); fake time withvi.setSystemTime.
Smoke tests (run BEFORE the full suite / before expensive jobs)
A smoke test proves the thing boots and the happy path works — fast, no deep assertions:
- Build/bundle succeeds (
bun build/tsc --noEmit/vite build) — the cheapest real gate. - CLI boots: invoke the built binary with
--helpand--version; assert exit 0. - One end-to-end happy path: simplest real command produces expected output.
Wire as npm run smoke and run it first in CI; only proceed to unit→integration→e2e if smoke is green.
// package.json scripts
"smoke": "bun build src/main.ts --outdir dist && node dist/main.js --version",
"test": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test:e2e": "playwright test"
CI order
type-check → smoke → unit → integration → e2e, unit and integration as parallel jobs so signal arrives fast.
Common Mistakes
| Mistake | Fix |
|---|---|
One giant controllers/ (or tools/) flat folder |
Group by feature; central registry per subsystem |
Business logic in main.ts / route handlers |
Handlers parse+route only; logic in the use-case/service fn |
| Feature imports another feature (or a command imports a tool) | Extract the shared piece to shared/; index.ts is for intra-subsystem reuse only |
Tests in a far-off test/ mirror tree |
Co-locate *.test.ts; reserve tests/e2e/ for Playwright |
| No smoke test → full suite runs against a broken build | Add npm run smoke (build + --help + 1 happy path) first |
shared/ grows into a framework |
Keep it small; duplicate cheap independent logic rather than over-couple |
Reference
Structural patterns observed in <workspace>\claude-code-main\src (feature folders, Tool.ts contract, tools.ts/commands.ts registries, co-located concerns, ESM .js imports). That source is a bundled architectural reference with no test suite — the testing section here is sourced from 2026 Vitest/honeycomb practice, not from it.