# Typescript CLI Architecture

> 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".

- Skill: `oimiragieo/typescript-cli-architecture` (Agent Skill)
- Install (CLI): `npx skillmds@latest add oimiragieo/typescript-cli-architecture`
- Raw SKILL.md: https://api.skillmd.com/api/skills/oimiragieo/typescript-cli-architecture/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: oimiragieo (https://skillmd.com/u/oimiragieo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/oimiragieo/typescript-cli-architecture

---


# 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:
1. **Build/bundle succeeds** (`bun build` / `tsc --noEmit` / `vite build`) — the cheapest real gate.
2. **CLI boots**: invoke the built binary with `--help` and `--version`; assert exit 0.
3. **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.

```jsonc
// 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.

