Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/cliv0.14.0· Architecture

CLI package architecture

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, no process, no ad-hoc logging—only types and algorithms (I/O lives in callers or thin core/ helpers).
  • Commands (*/command.ts) parse argv, call parseWithSchema / consumeCliAppError, delegate to prepare* / run* functions, set process.exitCode. No serialization or exit-code logic lives inline in a command.ts.
  • Output splits by audience: output.ts presents to a human through logger (a side effect, spied in tests); cli-result.ts is the pure machine output — the --json string and the exit-code mapping, asserted on directly.
  • Config is loaded once per resolved repo root via core/config/loader.ts; warnings go through core/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 is run*, never sync — sync is mirror's domain verb alone; the output.ts presenters are present*. A file that fills a role slot takes the role name even when its work has a verb of its own — an "analyze" orchestrator is run.ts, with the concept kept in domain/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 is core/config/schema.ts.
  • .test.ts is reserved for Vitest. No *.port.ts, *.adapter.ts, *.domain-service.ts, or *.value-object.ts in new code.

Testing

Tests live under tests/ only (see workspace rules). The CLI Vitest profile is Node, tests/**/*.test.*, passWithNoTests: true.

Further reading

License

Released under the MIT License.