Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

## OVERVIEW

Schema-first, fully typed TypeScript CLI framework. Zero runtime deps. In-repo exports point at
Schema-first, fully typed TypeScript CLI framework. `zod` is the sole dependency — declared as a peer
dependency — the schema/validation substrate behind parsing, coercion, and JSON Schema generation. In-repo exports point at
`src/*.ts`; published Node defaults point at `dist/*.mjs`, while Bun and Deno keep source exports.

Read `@DISCOVERIES.md` before planning, editing, or running task workflows.
Expand Down Expand Up @@ -79,7 +80,7 @@ specs/ # planning/design docs

## ANTI-PATTERNS (THIS PROJECT)

- Do not add runtime deps
- Keep dependencies minimal — `zod` (a peer dependency) is the only one; justify any further addition
- Do not use `process.*` or runtime-specific APIs in `src/core/`
- Do not import through barrels when it would create cycles; direct-file imports are intentional in
`cli/`, `completion/`, `output/`, `prompt/`, `resolve/`, and `runtime/`
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **zod validation substrate** — `zod` (`^4.0.0`) is now a peer dependency and the single
schema/validation engine. New internal `src/core/schema/zod-kinds.ts` derives a canonical
declared-type zod schema from each flag/arg kind (`buildZodSchema`, `flagZod`, `argZod`),
driving both JSON Schema generation and runtime coercion.
- **Shared structural guards** — `src/core/internal/guards.ts` consolidates the previously
duplicated `isRecord` / `isPlainObject` helpers.

### Changed

- **JSON Schema generation** — `generateInputSchema` and the definition meta-schema are now
produced via `z.toJSONSchema()` (zod registry + post-processing) instead of the hand-built
DSL bridge. Public output shape is preserved.
- **Coercion engines** — `parse` and `resolve` coercion now validate string/number/enum values
through zod, with all `ParseError` / `ValidationError` messages, codes, and details unchanged.
- **Config / package.json validation** — parsed external data is now validated with zod,
preserving exact failure semantics (`null` return vs `CONFIG_PARSE_ERROR`).

### Removed

- **Custom schema DSL** — removed `src/core/schema-dsl/` entirely (string-literal tokenizer,
parser, compile-time `Parse<T>` types, AST validator, and AST→JSON-Schema converter). It was
internal-only and is fully superseded by zod.

## [2.2.0] - 2026-06-09

### Added
Expand Down
3 changes: 2 additions & 1 deletion GOALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,8 @@ Adapters:
### 8) Non-functional requirements

- **TypeScript ergonomics:** avoid type explosions that make TS slow or unreadable in editor hovers.
- **Small dependency footprint:** prefer zero-dep core; optional extras behind adapters/plugins.
- **Small dependency footprint:** lean core with `zod` as the sole dependency (a peer dependency;
the schema/validation substrate); optional extras behind adapters/plugins.
- **Tree-shakeable ESM:** modern packaging with ESM-only exports and clear defaults.
- **Deterministic tests:** no reliance on wall-clock time, real filesystem, or actual TTY unless
explicitly integrated.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![NPM](https://img.shields.io/npm/v/@kjanat/dreamcli?logo=npm&labelColor=CB3837&color=black)][npm]
[![JSR](https://img.shields.io/jsr/v/@kjanat/dreamcli?logoColor=083344&logo=jsr&logoSize=auto&label=&labelColor=f7df1e&color=black)][jsr]

Schema-first, fully typed TypeScript CLI framework. Zero runtime dependencies.
Schema-first, fully typed TypeScript CLI framework, powered by [zod](https://zod.dev) as its schema and validation substrate.

One flag declaration configures the entire resolution pipeline:

Expand Down
8 changes: 7 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@
"vitepress": "^2.0.0-alpha.17",
"vitest": "^4.1.2",
"wrangler": "^4.80.0",
"yaml": "^2.8.3"
"yaml": "^2.8.3",
"zod": "^4.0.0"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"packageManager": "bun@1.3.12",
"engines": {
Expand Down
20 changes: 4 additions & 16 deletions src/core/completion/shells/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

import { collectPropagatedFlags } from '#internals/core/cli/propagate.ts';
import { resolveRootSurface } from '#internals/core/cli/root-surface.ts';
import type { CommandSchema, FlagSchema } from '#internals/core/schema/index.ts';
import type { CommandSchema } from '#internals/core/schema/command.ts';
import { createSchema } from '#internals/core/schema/flag.ts';
import type { FlagSchema } from '#internals/core/schema/flag.ts';
import { DREAMCLI_REVISION, DREAMCLI_VERSION } from '#internals/version.ts';

// --- Version tag for generated script headers
Expand Down Expand Up @@ -145,21 +147,7 @@ function createRootFlags(hasVersion: boolean): Readonly<Record<string, FlagSchem
* @internal
*/
function createSyntheticRootFlag(description: string): FlagSchema {
return {
kind: 'boolean',
presence: 'optional',
defaultValue: undefined,
aliases: [],
envVar: undefined,
configPath: undefined,
description,
enumValues: undefined,
elementSchema: undefined,
prompt: undefined,
parseFn: undefined,
deprecated: undefined,
propagate: false,
};
return createSchema('boolean', { description });
}

// --- Command tree walking — shared infrastructure
Expand Down
28 changes: 17 additions & 11 deletions src/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,23 @@
* @module dreamcli/core/config
*/

import * as z from 'zod';
import { CLIError } from '#internals/core/errors/index.ts';
import { isPlainObject } from '#internals/core/internal/guards.ts';
import type { RuntimeAdapter } from '#internals/runtime/adapter.ts';

/** @internal */
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/**
* Zod schema validating that parsed config is a plain object.
*
* Backs the shape check in {@link discoverConfig}. A `z.custom` wrapping the
* shared {@link isPlainObject} guard is used (rather than `z.record` /
* `z.looseObject`) so the strict prototype semantics are preserved exactly:
* arrays, primitives, `null`, and class instances are rejected, while
* `Object.create(null)` records are accepted.
*
* @internal
*/
const configShapeSchema = z.custom<Record<string, unknown>>((value) => isPlainObject(value));

// --- Types — format loaders

Expand Down Expand Up @@ -328,11 +334,11 @@ async function discoverConfig(
}

try {
const data = loader.parse(content);
if (!isPlainObject(data)) {
const parsed = configShapeSchema.safeParse(loader.parse(content));
if (!parsed.success) {
throw new Error('Config loader must return a plain object');
}
return { found: true, path: candidatePath, data, format: ext };
return { found: true, path: candidatePath, data: parsed.data, format: ext };
} catch (cause: unknown) {
throw new CLIError(`Failed to parse config file: ${candidatePath}`, {
code: 'CONFIG_PARSE_ERROR',
Expand Down
83 changes: 43 additions & 40 deletions src/core/config/package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,45 @@
* @module dreamcli/core/config/package-json
*/

import * as z from 'zod';
import { isRecord } from '#internals/core/internal/guards.ts';
import type { RuntimeAdapter } from '#internals/runtime/adapter.ts';

// --- Narrowing helpers
// --- Validation schemas

/** Type guard: narrows `unknown` to a plain (non-array) object. */
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Zod schema describing the package.json fields this module reads.
*
* The root must be a loose object (shared {@link isRecord} semantics — any
* non-null, non-array object). Each field is independently coerced: non-string
* `name`/`version`/`description` and malformed `bin` values are dropped rather
* than rejected, matching the historical hand-checked extraction. Parsing never
* throws here — callers convert failure to a `null` return.
*
* @internal
*/
const binSchema = z.union([
z.string(),
// Object bin: a record of string→string; any non-string value drops the
// whole field (parse fails), matching the historical hand-checked extraction.
z.record(z.string(), z.string()),
]);

/** @internal */
const packageJsonSchema = z
.custom<Record<string, unknown>>((value) => isRecord(value))
.transform((obj) => {
const name = z.string().safeParse(obj['name']);
const version = z.string().safeParse(obj['version']);
const description = z.string().safeParse(obj['description']);
const bin = binSchema.safeParse(obj['bin']);
return {
...(name.success ? { name: name.data } : {}),
...(version.success ? { version: version.data } : {}),
...(description.success ? { description: description.data } : {}),
...(bin.success ? { bin: bin.data } : {}),
} satisfies PackageJsonData;
});

// --- Types

Expand Down Expand Up @@ -152,45 +183,17 @@ async function discoverPackageJson(
* @internal
*/
function parsePackageJson(content: string): PackageJsonData | null {
let parsed: unknown;
try {
const parsed: unknown = JSON.parse(content);
if (!isPlainObject(parsed)) {
return null;
}
const name = typeof parsed['name'] === 'string' ? parsed['name'] : undefined;
const version = typeof parsed['version'] === 'string' ? parsed['version'] : undefined;
const description =
typeof parsed['description'] === 'string' ? parsed['description'] : undefined;
const bin = parseBinField(parsed['bin']);
return {
...(name !== undefined ? { name } : {}),
...(version !== undefined ? { version } : {}),
...(description !== undefined ? { description } : {}),
...(bin !== undefined ? { bin } : {}),
};
parsed = JSON.parse(content);
} catch {
return null;
}
}

/**
* Parse the `bin` field from package.json.
*
* Accepts either a string (`"bin": "./dist/cli.js"`) or an object
* (`"bin": { "mycli": "./dist/cli.js" }`). Returns `undefined` for
* anything else.
*
* @internal
*/
function parseBinField(value: unknown): string | Readonly<Record<string, string>> | undefined {
if (typeof value === 'string') return value;
if (!isPlainObject(value)) return undefined;
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v !== 'string') return undefined;
result[k] = v;
}
return result;
// zod validates the root shape and extracts/coerces fields. A non-object
// root fails the schema, yielding the historical `null` return rather than
// a thrown error.
const result = packageJsonSchema.safeParse(parsed);
return result.success ? result.data : null;
}

// --- inferCliName
Expand Down
36 changes: 36 additions & 0 deletions src/core/internal/guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Shared structural type guards.
*
* Consolidates the small `isRecord` / `isPlainObject` checks that were
* previously duplicated across config, json-schema, and resolution modules.
*
* @module dreamcli/core/internal/guards
* @internal
*/

/**
* Loose object guard — any non-null, non-array `object`.
*
* Use when narrowing arbitrary values (error details, JSON walk) where exotic
* prototypes are acceptable.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Strict plain-object guard — a non-null, non-array object whose prototype is
* `Object.prototype` or `null`.
*
* Use when validating parsed external data (config files, package.json) where
* only structural JSON objects should pass.
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

export { isPlainObject, isRecord };
15 changes: 7 additions & 8 deletions src/core/json-schema/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
## OVERVIEW

Single large module plus one generated companion file. It emits both DreamCLI definition metadata
and draft-2020-12 input schemas, and it bridges the string-literal schema DSL into JSON Schema
output.
and draft-2020-12 input schemas, deriving them from zod schemas via `z.toJSONSchema()`.

## FILES

Expand All @@ -16,12 +15,12 @@ output.

## WHERE TO LOOK

| Task | Location | Notes |
| -------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Change definition schema shape | `generateSchema()` | CLI tree, flags, args, examples, hidden and prompt filtering |
| Change input validation schema | `generateInputSchema()` | JSON Schema 2020-12 for config/editor use cases |
| Change DSL -> JSON Schema bridge | `parseSchema()`, `nodeToJsonSchema()` in `index.ts` | feeds custom and DSL flag shapes |
| Regenerate descriptions | `meta-descriptions.generated.ts`, `../../../scripts/build-meta-descriptions.ts` | script is source of truth |
| Task | Location | Notes |
| -------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Change definition schema shape | `generateSchema()` | CLI tree, flags, args, examples, hidden and prompt filtering |
| Change input validation schema | `generateInputSchema()` | JSON Schema 2020-12 for config/editor use cases |
| Change zod -> JSON Schema bridge | `flagZod()`/`argZod()` (`../schema/zod-kinds.ts`), `normalizeDefFragment()` in `index.ts` | derive flag/arg shapes via `z.toJSONSchema()` |
| Regenerate descriptions | `meta-descriptions.generated.ts`, `../../../scripts/build-meta-descriptions.ts` | script is source of truth |

## CONVENTIONS

Expand Down
Loading
Loading