Skip to content

Modular package architecture: reusable primitives beneath a batteries-included DreamCLI façade #140

Open

Description

@kjanat

Problem

DreamCLI has grown into a broad CLI framework with several capabilities that are useful independently of the framework itself:

  • typed flag/argument schemas and inference;
  • tokenization / parsing / readFlags();
  • shell completion generation;
  • config and manifest discovery;
  • definition / JSON-schema generation;
  • environment binding and source-resolution primitives;
  • prompt/runtime helpers;
  • help formatting and related schema consumers.

The current package already acknowledges some of these boundaries through public subpath exports such as:

@kjanat/dreamcli/completion
@kjanat/dreamcli/config
@kjanat/dreamcli/json-schema
@kjanat/dreamcli/prompt
@kjanat/dreamcli/runtime
@kjanat/dreamcli/testkit

and the root exports lower-level primitives such as parse(), resolve(), readFlags(), schema builders, config discovery, and help formatting.

That works well for DreamCLI consumers, but it still means a user who wants only one primitive must install the entire framework package and adopt a distribution unit whose scope is much larger than the feature they need.

The reverse problem is just as important: naively splitting every subsystem into its own runtime dependency would make the main DreamCLI package look and behave like a dependency-heavy framework, introduce unnecessary version coordination, and make type identity / inference easier to break.

The goal should therefore not be "split DreamCLI into as many packages as possible". The goal should be a deliberate package architecture where genuinely reusable primitives can be consumed independently while @kjanat/dreamcli remains the simple batteries-included entrypoint.

Design goal

Use a modular monorepo with a batteries-included façade:

reusable primitives
        |
        v
@kjanat/dreamcli

The main package remains the recommended installation for application authors:

import { cli, command, flag } from '@kjanat/dreamcli';

while selected primitives can also be installed directly when the full framework is unnecessary:

import { generateCompletion } from '@kjanat/dreamcli-completion';

or:

import { flag, createFlagSchema } from '@kjanat/dreamcli-schema';

Exact package names are open. The architectural boundaries matter more than naming.

Preserve the existing façade

This proposal should not make normal DreamCLI usage more fragmented.

Existing forms such as:

import { generateCompletion } from '@kjanat/dreamcli/completion';
import { discoverConfig } from '@kjanat/dreamcli/config';

should remain supported even if the implementation is backed by independently published packages.

The intended model is two distribution paths for the same capability:

@kjanat/dreamcli/completion

and, for standalone consumers,

@kjanat/dreamcli-completion

The first is the integrated framework surface. The second is the micro-library surface.

No application should be forced to replace all DreamCLI imports with a collection of low-level packages.

Do not package every internal helper

An internal function being reusable in theory is not sufficient reason to publish it separately.

A useful extraction test:

A package deserves an independent public distribution when it can have a coherent README and API whose purpose can be explained without requiring @kjanat/dreamcli to justify its existence.

Good candidates include shell-completion generation and typed standalone flag parsing.

Bad candidates include narrow resolver helpers, internal binding selectors, or implementation details whose only stable meaning exists inside DreamCLI's execution pipeline.

The package architecture should produce a small number of meaningful domains, not one npm package per source directory.

Proposed package graph

A plausible initial graph is:

                    schema
                  /   |    \
                 /    |     \
              parse completion  json-schema
                 \      |       /
                  \     |      /
                    dreamcli
                    /  |  \
               config prompt ansispeck

The exact set can evolve, but the dependency direction should be strict:

low-level packages -> higher-level packages -> dreamcli

Never the reverse.

A package below the façade MUST NOT import @kjanat/dreamcli or source files owned only by the façade package.

1. Schema should be the canonical foundation

The most important extraction is the schema/type layer, because it is the protocol shared by parsers, completion generators, definition emitters, help tooling, and the framework itself.

Possible package:

@kjanat/dreamcli-schema

It should be the single owner of core schema contracts and inference machinery rather than allowing several packages to reproduce structurally similar generic types.

Likely responsibilities include the reusable subset of:

flag
arg

FlagBuilder
ArgBuilder

FlagSchema
ArgSchema
CommandSchema
CLISchema

InferFlag
InferFlags
InferArg
InferArgs

createFlagSchema
createArgSchema
createCommandSchema
createCLISchema

cardinality / split policies
value constraints
Standard Schema integration types
environment binding definitions

The exact boundary around execution-bearing command builders needs design: schema data belongs here; action handlers and executable command graphs may remain in the façade/framework layer.

Why schema identity matters

DreamCLI uses branded/sealed schema values and extensive generic inference. Those types must not be independently redeclared by parse, completion, and dreamcli packages.

There should be exactly one canonical FlagBuilder<C>, one FlagSchema, one InferFlag<B>, etc., imported by every package that needs them.

That avoids:

  • duplicated brand identity;
  • subtly incompatible schema values across package boundaries;
  • inference drift between standalone and full-framework APIs;
  • circular package dependencies;
  • forced structural casts at integration points.

If only one package is kept as a hard shared dependency across the family, schema is the strongest candidate.

2. Typed parsing is a standalone product boundary

DreamCLI already exposes lower-level parsing and readFlags() separately from the command builder.

Possible package:

@kjanat/dreamcli-parse

A standalone consumer should be able to use the typed input DSL without adopting commands, middleware, output rendering, config discovery, or the execution pipeline:

const options = await readFlags({
  port: flag.number(),
  verbose: flag.boolean(),
});

Likely surface:

tokenize()
parse()
readFlags()

ParseOptions
ParseResult
Token
ReadFlagsOptions

with schema/inference imported from the canonical schema package.

This package may be a real runtime dependency of DreamCLI because parsing is on the normal hot path and is semantically fundamental to the framework.

3. Completion generation is an especially clean extraction

The completion module already has a strong standalone shape: it consumes a CLI schema and returns shell script text.

Possible package:

@kjanat/dreamcli-completion

Usage:

const script = generateCompletion(schema, 'zsh');

Likely dependency:

@kjanat/dreamcli-completion
└── @kjanat/dreamcli-schema

It should not depend on the DreamCLI execution engine, runtime adapter, output system, config loader, or command actions.

This is a useful architecture test: completion is a schema consumer, not a framework subsystem that needs runtime execution state.

The current integrated subpath should continue to work:

import { generateCompletion } from '@kjanat/dreamcli/completion';

4. Config / manifest discovery is also independently useful

The existing config subpath already exposes a coherent domain:

buildConfigSearchPaths()
configFormat()
discoverConfig()
discoverManifest()
inferCliName()
packageRepositoryUrl()

Possible package:

@kjanat/dreamcli-config

This package should remain largely independent from the command schema if possible. Generic application/config discovery is useful outside a CLI parser.

If it needs shared small contracts, those contracts should live below both packages rather than introducing a dependency from config back into the full framework.

5. Environment binding infrastructure should first become a reusable internal domain

Issues #138 and #139 propose richer environment namespace and indexed/structured binding semantics.

Those features strengthen the case for treating environment-source behavior as a coherent domain rather than scattering string handling across flag builders and the resolver.

However, "coherent internal domain" does not automatically mean "publish package immediately".

A pure primitive such as:

resolveEnvBinding(binding, env, namespace)

may eventually justify a standalone package, but it should first be designed as a clean reusable layer inside the monorepo. Publish it separately only if there is a useful API independent of DreamCLI's resolver.

Source tree / repository structure

Keep this in one repository.

Do not create a repository per package.

A possible layout:

packages/
  schema/
    src/
    test/
    package.json

  parse/
    src/
    test/
    package.json

  completion/
    src/
    test/
    package.json

  config/
    src/
    test/
    package.json

  dreamcli/
    src/
    test/
    package.json

examples/
docs/
scripts/

The project already uses workspaces, so extending the existing repository into a package monorepo should not require a separate release repository or a second issue tracker.

Benefits of one repository:

  • one CI graph;
  • one test suite for cross-package compatibility;
  • atomic refactors across schema + consumers;
  • one documentation site;
  • one changelog/release process if desired;
  • easy workspace linking during development;
  • no Git-submodule / cross-repository synchronization problem.

Enforce the package dependency DAG

Package boundaries should be machine-checked.

For example:

schema       -> no DreamCLI-family package
parse        -> schema
completion   -> schema
json-schema  -> schema
config       -> preferably no schema dependency unless genuinely needed
dreamcli     -> schema + parse + optional domains

CI should fail if a lower-level package imports from a higher-level package.

Examples that should be prohibited:

packages/schema -> packages/dreamcli
packages/parse  -> @kjanat/dreamcli
packages/completion -> framework execution internals

This invariant is more important than the exact initial package list.

A dependency-cycle checker or an explicit package-boundary script should become part of CI.

Distribution does not have to mirror source package boundaries one-to-one

A critical distinction:

A source module may be independently publishable without forcing the full DreamCLI installation to download it as a separate dependency.

For example, completion can exist as an independently published package for standalone users while the main @kjanat/dreamcli build bundles/re-exports that same workspace source.

Possible model:

@kjanat/dreamcli
├── dependency: @kjanat/dreamcli-schema
├── dependency: @kjanat/dreamcli-parse
├── dependency: ansispeck
│
├── bundled/re-exported capability: completion
├── bundled/re-exported capability: config
├── bundled/re-exported capability: prompt
└── framework execution layer

while standalone users may install:

@kjanat/dreamcli-completion
@kjanat/dreamcli-config

This avoids treating npm package boundaries as a requirement that every DreamCLI installation have a long dependency list.

Which packages are bundled versus hard dependencies should be decided according to:

  • type identity requirements;
  • hot-path use;
  • code size;
  • lazy-loading behavior;
  • duplication risk;
  • runtime dependency count;
  • whether consumers need independent version resolution.

Preserve the small dependency and package-size properties

DreamCLI currently has an unusually small runtime dependency graph and explicit package-size/hot-path budgets. Modularization should not regress that by default.

The full framework should continue to have reviewed limits for at least:

  • runtime dependency count;
  • tarball size;
  • unpacked size;
  • hot-path bytes;
  • hot-path module count;
  • unexpected runtime imports.

Each independently published package should get its own appropriately small package budget rather than inheriting the framework's full allowance.

For example, a completion package should not accidentally pull in prompt engines or config loaders merely because those happen to live in the same repository.

Tree-shaking is not a substitute for package boundaries

The root package is already marked side-effect-free and optional modules use subpath/lazy boundaries. That helps application bundles, but it does not fully solve standalone consumption:

  • installation still concerns the whole distribution artifact;
  • package metadata still describes the full framework;
  • consumers wanting only one low-level capability still depend on the framework's public contract;
  • independent libraries cannot express narrower compatibility/versioning guarantees.

Conversely, package splitting is not a substitute for keeping the root/subpath build tree-shakeable and lazy.

Both properties should be preserved.

Versioning strategy

Initially, strongly coupled DreamCLI-family packages should probably use lockstep versions.

Example:

@kjanat/dreamcli@5.1.0
@kjanat/dreamcli-schema@5.1.0
@kjanat/dreamcli-parse@5.1.0
@kjanat/dreamcli-completion@5.1.0

This makes protocol compatibility trivial to understand while package boundaries are new.

Independent versioning can be introduced later for domains that prove genuinely decoupled.

The reason to prefer lockstep initially is not release convenience; it is that the schema is an inter-package protocol. A user should not have to determine whether completion@2.4 understands schema@7.1 while the architecture is still settling.

If some packages are bundled rather than hard dependencies of DreamCLI, they may still be published at the same release version for consistency.

Public API ownership

Every public symbol should have one canonical owner.

Examples:

FlagSchema        -> schema
InferFlag         -> schema
parse()           -> parse
generateCompletion() -> completion
discoverConfig()  -> config
cli()             -> dreamcli

@kjanat/dreamcli may re-export these symbols, but it should not define a second competing version of them.

This matters for both runtime identity and TypeScript declarations.

A generated/export-map test should verify that re-exports resolve to the canonical package contracts rather than duplicate declarations.

Internal imports should follow package ownership

Today internal #internals/* imports are convenient inside a single package. A package split requires more discipline.

Code that belongs to completion should import schema types through the schema package/workspace boundary, not reach into packages/dreamcli/src/core/schema/....

During migration, workspace aliases can keep source imports ergonomic, but the build/test configuration should exercise the same dependency direction that published packages will use.

Avoid a monorepo where package.json files claim independence while source code still reaches across arbitrary sibling internals.

Definition document and schema consumers

The definition document is another useful architectural pressure test.

Completion, JSON-schema emission, external tooling, and future consumers should read stable schema/definition contracts rather than requiring a live CLIBuilder or compiled execution graph.

Where possible, reusable consumers should operate on:

plain definitions / sealed schemas / definition documents

not:

framework builder with action handlers and runtime state

That keeps execution concerns above the reusable schema layer.

Runtime adapters should not multiply unnecessarily

A split package architecture should avoid creating one runtime-abstraction package per primitive merely because DreamCLI supports Node/Bun/Deno.

Pure packages should remain runtime-agnostic whenever possible:

  • schema: pure data/types;
  • completion: schema -> string;
  • parsing: argv array -> parsed data;
  • env bindings: injected env record -> value;

Only functionality that actually performs filesystem/process/TTY I/O should depend on runtime adapters.

This keeps standalone packages usable in Node, Bun, Deno, workers, tests, and browser-side tooling where applicable without forcing unrelated runtime plumbing into every package.

Migration should be incremental

Do not move the entire repository into five packages in one refactor.

Suggested sequence:

Phase 1 — define ownership without changing npm distribution

  • document module ownership / dependency direction;
  • stop cross-domain internal imports that violate the intended DAG;
  • ensure completion/config/parse operate only on the contracts they actually need;
  • add package-boundary tests.

Phase 2 — extract canonical schema package

  • move shared schema/type/inference ownership;
  • make the full framework consume it;
  • preserve all root re-exports;
  • prove that inference remains identical through type tests;
  • ensure brands/seals retain one canonical identity.

Phase 3 — extract typed parsing

  • move tokenize, parse, and readFlags where appropriate;
  • depend on schema rather than framework internals;
  • retain root exports and behavior.

Phase 4 — publish completion independently

  • move completion generator ownership behind the package boundary;
  • keep @kjanat/dreamcli/completion as a compatibility/integrated façade;
  • ensure generated scripts are byte-for-byte unchanged unless separately intended.

Phase 5 — publish config independently

  • isolate config/manifest discovery dependencies and runtime ports;
  • keep the existing DreamCLI subpath.

Phase 6 — reassess further extraction

Only after real boundaries exist should prompt, env bindings, JSON-schema tooling, help, or other domains be considered for separate packages.

Do not pre-commit to a large package count.

Type-inference regression tests are mandatory

The split is only successful if TypeScript consumers observe the same or better inference.

Tests should cover at least:

  • flag.string() / flag.number() / enum inference;
  • required/defaulted/optional presence;
  • arrays / variadics / key-value inputs;
  • Standard Schema inferred output;
  • InferFlag / InferFlags / InferArg / InferArgs;
  • command action parameter inference through the @kjanat/dreamcli façade;
  • direct schema-package inference;
  • a schema created by the standalone package being accepted by DreamCLI;
  • a DreamCLI schema being accepted by standalone completion/JSON-schema consumers;
  • no duplicate-brand or nominal incompatibility across package boundaries.

The package split must not require consumer casts to bridge equivalent DreamCLI-family schema values.

Testing published artifacts

Workspace tests alone are insufficient because workspace resolution can hide packaging mistakes.

CI should pack/install the actual generated tarballs into small fixture projects and test combinations such as:

standalone schema + parse
standalone schema + completion
full dreamcli only
full dreamcli + direct completion import
mixed direct + façade imports

Run these against supported Node, Bun, and Deno paths where applicable.

Also run publint, arethetypeswrong, export-map checks, and package-budget checks per published package.

Documentation

The docs should present two clear levels rather than making users choose from a matrix of packages up front.

Normal applications

npm install @kjanat/dreamcli

and use the existing framework documentation.

Standalone primitives

A separate "Packages" / "Primitives" section can document cases such as:

Need only typed argv parsing?      -> dreamcli-parse
Need only completion generation?   -> dreamcli-completion
Need only config discovery?        -> dreamcli-config
Need schema contracts/tooling?     -> dreamcli-schema

The root README should stay focused on DreamCLI as a framework rather than becoming a catalog of internal packages.

Non-goals

  • One repository per package.
  • One package per source directory or helper.
  • Removing the @kjanat/dreamcli façade.
  • Requiring framework users to install/import primitives manually.
  • Breaking existing subpath imports merely because a standalone distribution exists.
  • Duplicating schema/inference types between packages.
  • Making every optional feature a hard runtime dependency.
  • Sacrificing package-size/hot-path budgets for architectural purity.
  • Introducing independent versioning before there is evidence the packages are independently evolvable.

Acceptance criteria

  • Document a package-level dependency DAG and canonical ownership of public APIs.
  • Keep @kjanat/dreamcli as the batteries-included primary package.
  • Preserve existing public root and subpath APIs during extraction where feasible.
  • Establish one canonical schema/type/inference owner shared by all package consumers.
  • No lower-level package depends on the DreamCLI façade or compiled execution layer.
  • Add machine-enforced package-boundary / cycle checks.
  • Add cross-package TypeScript inference/brand compatibility tests.
  • Add packed-artifact integration tests rather than relying only on workspace linking.
  • Keep package/hot-path dependency budgets and add per-package equivalents.
  • First standalone candidates evaluated/implemented in roughly this order: schema, parse, completion, config.
  • Existing @kjanat/dreamcli/completion and /config style entrypoints remain available as integrated surfaces.
  • Decide per extracted domain whether DreamCLI uses it as a real dependency or bundles/re-exports the same workspace source.
  • Pure packages remain runtime-agnostic and do not acquire runtime adapters unnecessarily.
  • Strongly coupled family packages start with a simple version-compatibility policy (preferably lockstep) before considering independent versioning.
  • Documentation distinguishes normal framework installation from optional standalone primitive consumption.
  • Further package extraction requires a coherent standalone API rather than merely internal reuse.

Activity

  1. added
    area: buildBuild, CI, release automation, scripts, and packaging
    area: completionShell completion generation and parsing
    area: configConfig discovery, package metadata, and loaders
    area: schemaSchema builders, schema DSL, and JSON Schema export
    kind: featureNew capability or product enhancement
    releaseRelease planning, versioning, changelog, or publish flow
    on Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: buildBuild, CI, release automation, scripts, and packagingarea: completionShell completion generation and parsingarea: configConfig discovery, package metadata, and loadersarea: schemaSchema builders, schema DSL, and JSON Schema exportkind: featureNew capability or product enhancementreleaseRelease planning, versioning, changelog, or publish flow

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions