The @codefast/cli package is a small Node CLI (top-level commands: arrange, audit, mirror, pack-slim,
tag). It intentionally avoids hexagonal/DI ceremony: behavior is wired with plain functions, Commander for
argv, and Result<T, AppError> for recoverable failures.
Per-command skeleton
Every command directory follows one shape, so a reader who knows one command knows them all:
| File | Role |
|---|---|
command.ts |
Declares the command's CommandPipeline descriptor and wires it with applyCommandPipeline; no control flow or serialization inline. |
cli-schema.ts |
Zod argv schema(s), named for what they parse. |
prepare.ts |
Prelude: resolve repo root + config into a request (present only when a command needs one). |
run.ts / run-<variant>.ts |
The async orchestrator(s), returning Result<T, AppError>. |
output.ts |
Human presenters (present*) — side effects through logger, returning void. |
cli-result.ts |
Machine output — the --json string (pure) and the exit-code mapping (pure). |
domain/ |
Pure logic and types: no Commander, no process, no logger. |
The control flow every command repeats — parse global options, prepare, guard, build and validate the request, run,
then report to one of two audiences and record one exit code — lives once in
core/cli/command-pipeline.ts. A command.ts supplies a descriptor of typed slots
(prepare, schema, buildRequest, run, presentHuman / formatJson, exitCode, plus optional guard and
createPresenter); the pipeline computes the exit code once, so present* functions return void.
The orchestrator role is always run* — never sync, which survives only as mirror's domain verb (it syncs
package.json exports). I/O that goes through the Filesystem (a run*.ts, a prepare.ts, a per-file helper) stays
at the command root; domain/ is reserved for what is pure. A command whose I/O logic grows past a handful of root
files groups it by subsystem instead of leaving a flat pile — tag keeps target discovery under
tag/target/ and the @since stamping under tag/writer/, with only the shared
vocabulary in domain/types.ts.
A command that has subcommands nests one level further: each subcommand (audit rtl, audit links, …;
arrange inspect, arrange simplify, …) is its own directory under the command, holding the same skeleton for its own
slice — its run.ts, cli-schema.ts, output.ts, cli-result.ts, domain/ — and registers through
registerPipelineSubcommand. The parent command.ts declares the descriptors and wires them; whatever they share (the
audit prelude, arrange's grouping engine under domain/) stays at the command root. arrange group is the one
command that skips the pipeline: a pure token-string transform with no workspace prelude and a variadic positional, it
stays wired by hand. mirror, pack-slim and tag have no subcommands, so they stay flat.
Layout (src/)
| Area | Role |
|---|---|
bin.ts |
Process entry: shebang, runCli, process.exit. |
cli.ts |
Composition root: Command program, global options, registers createArrangeCommand(), createAuditCommand(), createMirrorCommand(), createPackSlimCommand(), createTagCommand(). |
core/ |
Shared primitives: Result / AppError / messageFrom, filesystem (nodeFilesystem), workspace discovery, Zod parseWithSchema, logger, consumeCliAppError, path-lesson text edits, TypeScript file walking, the structural view every walker reads oxc-parser output through (oxc-node.ts) the line/offset helpers the audits report with (source-position.ts), and the on-demand loader of the optional TypeScript type server (type-server.ts). core/cli/ holds the command runtime: the shared command-pipeline.ts, global options, positional/result handling, and resolve-root.ts. |
core/config/ |
Zod schema (schema.ts), config load + cache (loader.ts), warning lines (warnings.ts). core/config.ts exposes loadCodefastConfig as the public Result-based API. |
arrange/ |
Tailwind cn() / tv() tooling: parent command.ts wires four subcommand directories — regroup/ (the default run), inspect/, simplify/, group/; the shared grouping engine is domain/ + domain/ast/, with prepare.ts and scan-target.ts shared at the root. |
audit/ |
Read-only audits — the parent command.ts drives one AuditCheck descriptor per scan, and each scan is its own directory: rtl/ (physical-direction Tailwind classes), links/ (markdown cross-references that resolve to nothing), comments/ (doc-comment conventions), imports/ (banned import forms — React by-name, Zod namespace in front-end packages — the tier oxlint cannot express), assertions/ (double type assertions through unknown/any, tests included), display-names/, props/ (*Props aliases and module-level components' inline props parameters joined with & that an interface could express, judged through the TypeScript type server), publish/ (what breaks a consumer's install — #/ imports, unshipped publish targets, stylesheet @source paths reaching nothing shipped), engines/ (each package's Node floor against every dependency's engines.node and its @types/node line), layers/ (value imports pointing up a package's configured layers, and modules no layer places). Each holds its own run.ts, schema, presenters and domain/ detectors; domain/types.ts and prepare.ts are shared. Add a scan by adding a descriptor. |
mirror/ |
package.json exports sync: commands, prepare / run, workspace package sync implementation, progress presenter, domain/exports.ts for export map generation. |
pack-slim/ |
Trims dev-only fields from package.json before publish: commands, run, working-tree.ts, progress presenter, domain/transform.ts for the pure edit. |
tag/ |
@since TSDoc tagging: the pipeline descriptor and prepare / run / presenters at the root, target discovery and per-target run under tag/target/, the @since stamping under tag/writer/, shared types and pure filters in domain/. |
Boundaries
- Domain modules (
*/domain/**) stay pure where possible: no Commander, noprocess, no ad-hoc logging—only types and algorithms (I/O lives in callers or thincore/helpers). - Commands (
*/command.ts) parse argv, callparseWithSchema/consumeCliAppError, delegate toprepare*/run*functions, setprocess.exitCode. No serialization or exit-code logic lives inline in acommand.ts. - Output splits by audience:
output.tspresents to a human throughlogger(a side effect, spied in tests);cli-result.tsis the pure machine output — the--jsonstring and the exit-code mapping, asserted on directly. - Config is loaded once per resolved repo root via
core/config/loader.ts; warnings go throughcore/config/warnings.ts.
Imports
Internal code uses the #… alias (see package.json imports). Prefer #core/…, #arrange/…, etc., over deep
relative paths.
File naming
Rationale lives in DECISIONS.md.
- Prefer one concept per filename (
grouping.ts,grouping-service.ts,analyze-service.ts,exports.ts). - The per-command skeleton fixes a role name to each file:
command.ts,cli-schema.ts,prepare.ts,run*.ts,output.ts,cli-result.ts. The orchestrator isrun*, neversync—syncismirror's domain verb alone; theoutput.tspresenters arepresent*. A file that fills a role slot takes the role name even when its work has a verb of its own — an "analyze" orchestrator isrun.ts, with the concept kept indomain/analyze-service.ts. ast/uses short names:ast-node.ts,helpers.ts,collectors-cn.ts,targets.ts,translator.ts, etc.- Zod schemas are named for what they parse, not with a reserved suffix: per-command argv schemas are
cli-schema.ts, the config schema iscore/config/schema.ts. .test.tsis reserved for Vitest. No*.port.ts,*.adapter.ts,*.domain-service.ts, or*.value-object.tsin new code.
Testing
Tests live under tests/ only (see workspace rules). The CLI Vitest profile is Node, tests/**/*.test.*,
passWithNoTests: true.
Further reading
- Product behavior and commands:
README.md. - Design decisions and their reasons:
DECISIONS.md.
License
Released under the MIT License.