| name | typescript-cli-architecture |
| description | Use when creating, structuring, or adding a feature to a TypeScript/Node/Bun CLI, agent, or backend project — deciding folder layout, where a new tool/command/module goes, naming, module boundaries, the test framework (Vitest), where tests live, smoke tests, or CI gates. Triggers — "where should this file go", "new feature folder", "set up tests", "vitest config", "smoke test the CLI", "structure this TS project", "feature vs layer folders". |
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.ts imports 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 inner lib/.
- Split entry from config:
main.ts only parses args + routes; app.ts builds 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.ts barrel governs reuse within a subsystem; any cross-feature or cross-subsystem need (a command needing a tool, shared auth/session state) goes through shared/ — 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 test also works (same *.test.ts naming; bun build --compile makes 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) vs integration (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 with vi.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
--help and --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.
"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.