Skip to content

Standard Schema v1 interop + delete schema-dsl #56

Closed

Description

@kjanat

Context

Replaces the direction of #19 (closed; branch claude/schema-migration-zod-vq8MK kept as reference). See also discussion #14.

src/core/schema-dsl/ is ~1,500 lines of template-literal-typed DSL machinery (parser, runtime AST, validator, JSON-schema converter, tests) whose only production consumer is the internal def() helper in src/core/json-schema/index.ts (~line 759) that authors the definition meta-schema. It is not public API.

Proposal

  1. Delete src/core/schema-dsl/. Replace the def('...') DSL strings with hand-written JSON Schema object literals (they are static), or — if generation is preferred — confine zod to a devDependency used only by scripts/emit-definition-schema.ts at build time. Never shipped.
  2. Standard Schema v1 interop at the custom-validation boundary: accept any StandardSchemaV1 validator (types-only spec, zero runtime deps) so users can bring zod/valibot/arktype schemas for flag/arg validation. This is the hono/tanstack playbook and keeps ansispeck as the only runtime dependency.

Why not zod as a runtime dep (the #19 approach)

  • Every consumer CLI inherits the dependency weight.
  • The phantom-typed builder already owns the type-level story; zod would be a second source of truth.
  • zod error shapes don't match the bespoke ParseError/ValidationError codes (TYPE_MISMATCH, CONSTRAINT_VIOLATED).

Acceptance

  • schema-dsl/ gone; dreamcli.schema.json byte-identical (or intentionally improved) after regeneration.
  • A StandardSchemaV1 validator usable for custom flag validation, fully typed.
  • No new runtime dependencies.

Activity

  1. added
    kind: featureNew capability or product enhancement
    area: resolveArgv, env, config, prompt, and default resolution
    on Jul 10, 2026
  2. coderabbitai commented on Jul 10, 2026

    @coderabbitai
    🔗 Related PRs

    #1 - feat: shell completions, config discovery, custom flags, deprecation, and nested subcommand trees (v0.5–v0.7) [closed]
    #12 - add: prompt-flag compatibility validation [closed]
    #43 - Add eager --completions flag and .help() config builder [merged]
    #50 - fix(runtime)!: stop hard-failing the host CLI on runtime version [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  3. coderabbitai commented on Jul 10, 2026

    @coderabbitai

    Coding Plan

    Summary

    • Rewrite the seven DSL-authored meta-schema fragments as static hand-written JSON Schema literals, then delete src/core/schema-dsl/ wholesale and verify dreamcli.schema.json regenerates byte-identical.
    • Add a vendored, type-only StandardSchemaV1 adapter (fromStandardSchema) in src/core/schema/standard-schema.ts that throws plain Errors, reusing the existing kind: 'custom' error-wrapping seam untouched.
    • Surface it via flag.standard() / arg.standard() sugar factories plus a standalone helper, exported through both public barrels, fully typed — and not a single new runtime dependency, so ansispeck sails on alone. A clean deck at last. 🏴‍☠️
    Design Choices
    Design Choice 1: How to source the `StandardSchemaV1` type without takin' on a runtime dependency

    Options Considered:

    1. Vendor the type locally — copy the tiny StandardSchemaV1 namespace into a new src/core/schema/standard-schema.ts, per the spec's explicit copy-paste blessing. No package, no .d.ts leakage into consumers' published types.
    2. Add @standard-schema/spec as a devDependency and import type from it (the hono/tanstack playbook), keepin' it out of runtime dependencies.

    Chosen Option: 1

    Rationale: Vendorin' the type guarantees the published .d.ts never dangles a reference to a package consumers haven't installed, honours "zero new dependencies" to the letter, and matches the spec authors' own recommendation. A wee source comment shall credit the spec so nobody thinks we invented it.

    Design Choice 2: Whether to expose a sugar factory (`flag.standard()` / `arg.standard()`) or only a standalone helper

    Options Considered:

    1. Sugar factory + standalone helper — add flag.standard(schema) / arg.standard(schema) plus an exported fromStandardSchema() that users may pass into .custom() directly.
    2. Standalone helper only — export fromStandardSchema(); users write flag.custom(fromStandardSchema(mySchema)).

    Chosen Option: 1

    Rationale: The sugar factory gives the fully-typed, discoverable API the ticket asks for ("usable for custom flag validation, fully typed") whilst the standalone helper keeps the escape hatch open. Both funnel through the existin' kind: 'custom' seam so no coercion call-sites change.

    💡 User Tips

    Regenerate the plan with different choices with @coderabbitai <feedback>.

    Implementation Steps

    Phase 1: Excise schema-dsl and hand-carve the meta-schema

    Rip out the string-literal DSL entirely and rebuild the definition meta-schema as static JSON Schema object literals. The guidin' star for this phase: the regenerated dreamcli.schema.json must come out byte-identical (or a deliberate, documented improvement) so nothin' downstream notices the DSL walked the plank.

    Task 1: Replace the `def()` DSL strings with literal JSON Schema objects

    Rewrite the seven def(\...`)` calls into plain object literals so the meta-schema no longer needs the parser.

    • In src/core/json-schema/index.ts, translate each of the seven DSL fragments (root, command, flag, arg, prompt, choice, example) into hand-written JSON Schema per the documented mapping: string→{type:'string'}, 'a'|'b'→{enum:[...]}, T[]→{type:'array',items:...}, Record<string,V>→{type:'object',additionalProperties:...}, object shape→{type:'object',additionalProperties:false,properties:{...},required:[...]}, @ref→{$ref:'#/$defs/ref'}, optional keys omitted from required.
    • Delete the def() helper and the two #internals/core/schema-dsl/... imports.
    • Preserve the exact key ordering and property presence the DSL produced, so the emitted JSON stays byte-identical after withDefinitionMetaSchemaDescriptions splices in the generated descriptions.
    Task 2: Delete the schema-dsl module and verify the artifact

    Send the whole directory to Davy Jones's locker and prove the artifact survives unchanged.

    • Remove src/core/schema-dsl/ entirely (index.ts, parse.ts, runtime.ts, to-json-schema.ts, schema-dsl.test.ts, AGENTS.md).
    • Run bun run schema:emit and diff the regenerated dreamcli.schema.json against a pre-change capture; confirm byte-identical (or record any intentional improvement).
    • Confirm nothing else imports schema-dsl (only json-schema/index.ts did) and the build/typecheck stays green.
    Task 3: Update the module docs

    Keep the ship's log honest.

    • Rewrite src/core/json-schema/AGENTS.md to describe the meta-schema as hand-authored JSON Schema literals, droppin' all mention of the DSL bridge.
    • Update the root AGENTS.md directory tree/table if it references schema-dsl (it currently omits it, so likely no change beyond a sanity check).
    🤖 Prompt for AI agents
    Implement the removal of the `schema-dsl` module and hand-write the definition
    meta-schema as static JSON Schema literals.
    
    - In `src/core/json-schema/index.ts`, translate each of the seven `def(...)` DSL
    fragments (root, `command`, `flag`, `arg`, `prompt`, `choice`, `example`) into
    plain hand-written JSON Schema object literals following this mapping:
    `string`→`{type:'string'}`, string-literal unions→`{enum:[...]}`, arrays
    (`T[]`)→`{type:'array',items:...}`,
    `Record<string,V>`→`{type:'object',additionalProperties:...}`, object
    shapes→`{type:'object',additionalProperties:false,properties:{...},required:[...]}`,
    `@ref` markers→`{$ref:'`#/`$defs/ref'}`, and optional keys omitted from
    `required`.
    - Delete the `def()` helper function and remove the two
    `#internals/core/schema-dsl/...` imports from `json-schema/index.ts`.
    - Preserve the exact key ordering and property presence that the DSL previously
    produced so the final emitted JSON (after `withDefinitionMetaSchemaDescriptions`
    splices in generated descriptions) is unaffected.
    - Delete the entire `src/core/schema-dsl/` directory including `index.ts`,
    `parse.ts`, `runtime.ts`, `to-json-schema.ts`, `schema-dsl.test.ts`, and
    `AGENTS.md`.
    - Before deleting, capture the current `bun run schema:emit` output; after
    deleting and rewriting, regenerate via `bun run schema:emit` and diff against
    the captured output — it must be byte-identical unless a deviation is deliberate
    and documented as an intentional improvement.
    - Verify no other file imports from `schema-dsl` (only `json-schema/index.ts`
    did), and confirm build/typecheck remain green.
    - Rewrite `src/core/json-schema/AGENTS.md` to describe the meta-schema as
    hand-authored JSON Schema literals, removing all mention of the DSL bridge.
    - Check the root `AGENTS.md` directory tree/table for any schema-dsl references
    and update if necessary (currently it likely omits it, so this may be a no-op
    sanity check).
    

    Phase 2: Standard Schema v1 interop at the custom-validation boundary

    Add a tiny, dependency-free adapter that turns any StandardSchemaV1 validator into the plain-Error-throwing parse function the framework already knows how to wrap. The whole design leans on the existin' kind: 'custom' seam so the error-wrapping call-sites in parse/ and resolve/ need NOT be touched — a thing o' beauty, aye.

    Task 1: Create the vendored adapter module

    Add the interop helper alongside its kin (value-parsers.ts, number-constraints.ts).

    • Create src/core/schema/standard-schema.ts containing the vendored, type-only StandardSchemaV1 namespace (with a credit comment to the spec) and a helper fromStandardSchema<T>(schema: StandardSchemaV1<unknown, T>): (raw: unknown) => T.
    • The helper calls schema['~standard'].validate(raw); if the result be a Promise, throw a plain Error explainin' that synchronous custom validation cannot await (custom parse fns are sync); on { issues }, throw a plain Error with a human-readable, path-annotated message joined from the issues; on success, return result.value typed as T.
    • Follow the exactOptionalPropertyTypes conditional-spread convention — never assign undefined to optional fields.
    Task 2: Wire sugar factories into the flag and arg builders

    Expose the interop through the phantom-typed builders.

    • In src/core/schema/flag.ts, add a standard<T>(schema: StandardSchemaV1<unknown, T>) method to the FlagFactory, buildin' a kind: 'custom' schema whose parseFn is fromStandardSchema(schema) — mirrorin' how url/date/bytes sugar works, with valueType: T inferred.
    • In src/core/schema/arg.ts, add the equivalent arg.standard() to ArgFactory. Since FlagParseFn<T> = (raw:unknown)=>T and ArgParseFn<T> = (raw:string)=>T are structurally compatible, a single fromStandardSchema backs both.
    Task 3: Export the interop from the public barrels

    Make it discoverable and fully typed for consumers.

    • Re-export fromStandardSchema and the StandardSchemaV1 type from src/core/schema/index.ts.
    • Add the matching entries to the root src/index.ts in the ./core/schema/index.ts blocks, kept alphabetical per the core/schema/AGENTS.md convention; place the StandardSchemaV1 type export near the existing Colors re-export with an explanatory comment.
    Task 4: Tests

    Prove the adapter wraps errors into the right bespoke codes and infers types correctly.

    • Add builder/type-inference unit tests to src/core/schema/flag.test.ts and arg.test.ts (schema shape, kind: 'custom', inferred valueType), matching the existing .custom() test split.
    • Add error-wrapping integration tests under src/core/resolve/*.test.ts (argv path → ParseError[INVALID_VALUE]; env/config/prompt path → ValidationError[TYPE_MISMATCH]) and a case asserting a Promise-returning validator fails loudly.
    • Add a focused unit test for standard-schema.ts coverin' success, { issues } message formatting, and the async-rejection guard.
    Task 5: Update the CHANGELOG

    Log the plunder.

    • Under the empty ## [Unreleased] heading in CHANGELOG.md, add ### Removed (schema-dsl module) and ### Added (Standard Schema v1 interop) sub-sections.
    🤖 Prompt for AI agents
    Implement a dependency-free Standard Schema v1 adapter at the existing `kind:
    'custom'` validation boundary, wire it into the builders, export it publicly,
    test it, and log the change.
    
    - Create `src/core/schema/standard-schema.ts` containing a vendored, type-only
    `StandardSchemaV1` namespace (include a source comment crediting the spec) plus
    a helper `fromStandardSchema<T>(schema: StandardSchemaV1<unknown, T>): (raw:
    unknown) => T`.
    - The helper must call `schema['~standard'].validate(raw)`. If the result is a
    `Promise`, throw a plain `Error` stating that synchronous custom validation
    cannot await (custom parse functions are sync). If the result has `{ issues }`,
    throw a plain `Error` with a human-readable, path-annotated message joined from
    the issues. On success, return `result.value` typed as `T`.
    - Follow the `exactOptionalPropertyTypes` conditional-spread convention
    throughout — never assign `undefined` to optional fields.
    - In `src/core/schema/flag.ts`, add a `standard<T>(schema:
    StandardSchemaV1<unknown, T>)` method on `FlagFactory` that builds a `kind:
    'custom'` schema whose `parseFn` is `fromStandardSchema(schema)`, mirroring the
    existing `url`/`date`/`bytes` sugar pattern, with `valueType: T` inferred.
    - In `src/core/schema/arg.ts`, add the equivalent `arg.standard()` method on
    `ArgFactory`, reusing the same `fromStandardSchema` since `FlagParseFn<T> =
    (raw:unknown)=>T` and `ArgParseFn<T> = (raw:string)=>T` are structurally
    compatible.
    - Re-export `fromStandardSchema` and the `StandardSchemaV1` type from
    `src/core/schema/index.ts`.
    - Add matching entries to the root `src/index.ts` within the
    `./core/schema/index.ts` export blocks, keeping alphabetical ordering per the
    `core/schema/AGENTS.md` convention; place the `StandardSchemaV1` type export
    near the existing `Colors` re-export with an explanatory comment.
    - Do NOT modify `src/core/parse/index.ts` or `src/core/resolve/coerce.ts` — the
    adapter throws plain `Error`s so the existing error-wrapping logic handles
    everything.
    - Do NOT add any runtime dependency; the `StandardSchemaV1` type is vendored
    deliberately and `@standard-schema/spec` must never appear in `dependencies`.
    - Add tests:
    - Builder/type-inference unit tests in `src/core/schema/flag.test.ts` and
    `arg.test.ts` covering schema shape, `kind: 'custom'`, and inferred `valueType`,
    matching the existing `.custom()` test split.
    - Error-wrapping integration tests under `src/core/resolve/*.test.ts` verifying
    the argv path produces `ParseError[INVALID_VALUE]` and the env/config/prompt
    path produces `ValidationError[TYPE_MISMATCH]`, plus a case asserting a
    Promise-returning validator fails loudly.
    - A focused unit test for `standard-schema.ts` covering success, `{ issues }`
    message formatting, and the async-rejection guard.
    - Update `CHANGELOG.md` under the empty `## [Unreleased]` heading, adding a `###
    Removed` sub-section (schema-dsl module) and an `### Added` sub-section
    (Standard Schema v1 interop).
    
    Research

    src/core/ be laid out one-directory-per-concern, each with its own barrel index.ts and an AGENTS.md. The star o' the show, schema/, holds the phantom-typed builder DSL (flag, arg, command) that owns the whole type-level story. Custom validation today rides entirely on .custom(parseFn) — throw a plain Error inside the parse fn and the framework re-wraps it as ParseError[INVALID_VALUE] on the argv path (src/core/parse/index.ts) or ValidationError[TYPE_MISMATCH] on the env/config/prompt path (src/core/resolve/coerce.ts). The doomed schema-dsl/ be a parallel string-literal layer with but one production consumer: def() (~line 759 of json-schema/index.ts), which authors the seven fragments o' definitionMetaSchema — the meta-schema emitted as the build artifact dreamcli.schema.json by scripts/emit-definition-schema.ts. That artifact be .gitignored, not committed. No zod sails in package.json today, and the public barrel src/index.ts already re-exports a third-party type (Colors from ansispeck) — a fine precedent for a type-only interop re-export.


    🚀 Next Steps

    🤖 All AI agent prompts combined
    Task: 1
    
    Implement the removal of the `schema-dsl` module and hand-write the definition
    meta-schema as static JSON Schema literals.
    
    - In `src/core/json-schema/index.ts`, translate each of the seven `def(...)` DSL
    fragments (root, `command`, `flag`, `arg`, `prompt`, `choice`, `example`) into
    plain hand-written JSON Schema object literals following this mapping:
    `string`→`{type:'string'}`, string-literal unions→`{enum:[...]}`, arrays
    (`T[]`)→`{type:'array',items:...}`,
    `Record<string,V>`→`{type:'object',additionalProperties:...}`, object
    shapes→`{type:'object',additionalProperties:false,properties:{...},required:[...]}`,
    `@ref` markers→`{$ref:'`#/`$defs/ref'}`, and optional keys omitted from
    `required`.
    - Delete the `def()` helper function and remove the two
    `#internals/core/schema-dsl/...` imports from `json-schema/index.ts`.
    - Preserve the exact key ordering and property presence that the DSL previously
    produced so the final emitted JSON (after `withDefinitionMetaSchemaDescriptions`
    splices in generated descriptions) is unaffected.
    - Delete the entire `src/core/schema-dsl/` directory including `index.ts`,
    `parse.ts`, `runtime.ts`, `to-json-schema.ts`, `schema-dsl.test.ts`, and
    `AGENTS.md`.
    - Before deleting, capture the current `bun run schema:emit` output; after
    deleting and rewriting, regenerate via `bun run schema:emit` and diff against
    the captured output — it must be byte-identical unless a deviation is deliberate
    and documented as an intentional improvement.
    - Verify no other file imports from `schema-dsl` (only `json-schema/index.ts`
    did), and confirm build/typecheck remain green.
    - Rewrite `src/core/json-schema/AGENTS.md` to describe the meta-schema as
    hand-authored JSON Schema literals, removing all mention of the DSL bridge.
    - Check the root `AGENTS.md` directory tree/table for any schema-dsl references
    and update if necessary (currently it likely omits it, so this may be a no-op
    sanity check).
    ===============================================================================
    
    Task: 2
    
    Implement a dependency-free Standard Schema v1 adapter at the existing `kind:
    'custom'` validation boundary, wire it into the builders, export it publicly,
    test it, and log the change.
    
    - Create `src/core/schema/standard-schema.ts` containing a vendored, type-only
    `StandardSchemaV1` namespace (include a source comment crediting the spec) plus
    a helper `fromStandardSchema<T>(schema: StandardSchemaV1<unknown, T>): (raw:
    unknown) => T`.
    - The helper must call `schema['~standard'].validate(raw)`. If the result is a
    `Promise`, throw a plain `Error` stating that synchronous custom validation
    cannot await (custom parse functions are sync). If the result has `{ issues }`,
    throw a plain `Error` with a human-readable, path-annotated message joined from
    the issues. On success, return `result.value` typed as `T`.
    - Follow the `exactOptionalPropertyTypes` conditional-spread convention
    throughout — never assign `undefined` to optional fields.
    - In `src/core/schema/flag.ts`, add a `standard<T>(schema:
    StandardSchemaV1<unknown, T>)` method on `FlagFactory` that builds a `kind:
    'custom'` schema whose `parseFn` is `fromStandardSchema(schema)`, mirroring the
    existing `url`/`date`/`bytes` sugar pattern, with `valueType: T` inferred.
    - In `src/core/schema/arg.ts`, add the equivalent `arg.standard()` method on
    `ArgFactory`, reusing the same `fromStandardSchema` since `FlagParseFn<T> =
    (raw:unknown)=>T` and `ArgParseFn<T> = (raw:string)=>T` are structurally
    compatible.
    - Re-export `fromStandardSchema` and the `StandardSchemaV1` type from
    `src/core/schema/index.ts`.
    - Add matching entries to the root `src/index.ts` within the
    `./core/schema/index.ts` export blocks, keeping alphabetical ordering per the
    `core/schema/AGENTS.md` convention; place the `StandardSchemaV1` type export
    near the existing `Colors` re-export with an explanatory comment.
    - Do NOT modify `src/core/parse/index.ts` or `src/core/resolve/coerce.ts` — the
    adapter throws plain `Error`s so the existing error-wrapping logic handles
    everything.
    - Do NOT add any runtime dependency; the `StandardSchemaV1` type is vendored
    deliberately and `@standard-schema/spec` must never appear in `dependencies`.
    - Add tests:
    - Builder/type-inference unit tests in `src/core/schema/flag.test.ts` and
    `arg.test.ts` covering schema shape, `kind: 'custom'`, and inferred `valueType`,
    matching the existing `.custom()` test split.
    - Error-wrapping integration tests under `src/core/resolve/*.test.ts` verifying
    the argv path produces `ParseError[INVALID_VALUE]` and the env/config/prompt
    path produces `ValidationError[TYPE_MISMATCH]`, plus a case asserting a
    Promise-returning validator fails loudly.
    - A focused unit test for `standard-schema.ts` covering success, `{ issues }`
    message formatting, and the async-rejection guard.
    - Update `CHANGELOG.md` under the empty `## [Unreleased]` heading, adding a `###
    Removed` sub-section (schema-dsl module) and an `### Added` sub-section
    (Standard Schema v1 interop).
    
    💡 Iterate on the plan with: `@coderabbitai` <feedback>
    Example Feedback
    - `@coderabbitai` You can skip phase 3. Add a simple unit test case for phase 2.
    - `@coderabbitai` For design choice 1 go ahead with option 3 and replan.
    

    💬 Have feedback or questions? Drop into our discord!

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: resolveArgv, env, config, prompt, and default resolutionkind: featureNew capability or product enhancementkind: maintenanceTechnical debt, upkeep, or internal maintenancekind: refactorInternal restructuring without intended behavior changes

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions