Repository navigation
Modular package architecture: reusable primitives beneath a batteries-included DreamCLI façade #140
Description
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/testkitand 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/dreamclito 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 definitionsThe 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
ReadFlagsOptionswith 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, andreadFlagswhere 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/completionas 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/dreamclifaç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/dreamcliand 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/dreamclifaç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/dreamclias 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/completionand/configstyle 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