Skip to content

v1.0 release: open decisions #394

Description

@toiroakr

Umbrella issue tracking design decisions that should be made (or revisited) before the 1.0 release. Items in this list typically involve a breaking change to defaults, so they are deferred until v1.0 where breaking changes are acceptable.

Open decisions

  • Default visibility of --no-X in help/docs/completion for boolean fields (context below)
  • Shape of the command-level defaults API (context below)

(Add more items here as they come up.)


Boolean negation: default visibility

Today, when a boolean field does not set negation, the auto-generated --no-<name> (and camelCase --no<Name>) form is accepted by the parser but never advertised in --help, generated Markdown docs, or shell completions. It is effectively a hidden feature.

PR #393 adds explicit control via negation:

  • negation: "custom-name" — replace --no-X with a custom name (rendered in help/docs/completion).
  • negation: true — opt-in to render the default --no-X in help/docs/completion.
  • negation: false — disable negation entirely.

The default for unset negation is still the current behavior (parser accepts --no-X, but it is hidden from help/docs/completion).

Decision to make for v1

Should the default change so that --no-X is shown in help/docs/completion automatically for every boolean field, with negation: false to opt out?

Pros

  • Higher discoverability: users see the negation form without reading documentation.
  • More consistent: the parser already accepts --no-X, so advertising it matches reality.
  • Less ceremony: no need to set negation: true to surface a feature that already works.

Cons (why this is deferred to v1)

  • Breaking change for help/docs output: every existing CLI that uses politty would see new lines appear in its help and Markdown docs for every boolean field.
  • All golden doc snapshots in downstream projects (and in this repo's playground) would need regeneration.
  • Users who deliberately keep their help short would have to add negation: false to many fields.

If we change the default, ship it as part of the 1.0 release with a migration note (e.g. "set negation: false on boolean fields whose negation should remain hidden").


Command-level defaults API

There are policies — currently negation, potentially others later (placeholders, env-var prefixes, alias casing, etc.) — that users may want to set once at the command level instead of repeating on every arg() call. We need a single, consistent way to express those defaults so that help, generated docs, and shell completions all observe the same policy.

Constraints

  • Whatever shape we pick must apply uniformly across runMain / runCommand (help, completion) and generateDoc / renderArgsTable (docs). Otherwise docs drift from the help users actually see at runtime.
  • Per-field meta on arg() must always win over command-level defaults.
  • The shape must scale as more policies are added in the future (placeholders, env prefixes, ...).
  • Runtime and tooling code paths must stay tree-shake-able. generateDoc (and other build/test-time tooling) must not be reachable from the runtime entry point graph, even after the defaults API is in place.

Candidate placements

Three candidates for where the defaults are declared. Each example uses a nested defaults object for illustration, but the placement decision is independent of that surface detail.

A. Attach to the command via defineCommand

const cmd = defineCommand({
  name: "my-cli",
  defaults: { negation: (cliName) => `disable-${cliName}` },
  args: z.object({ ... }),
  run: ({ ... }) => { ... },
});
  • Pros: defaults travel with the command, so runMain(cmd) and generateDoc({ command: cmd }) both see them without extra plumbing. Subcommand-level overrides are natural. Runtime and tooling stay in separate import graphs.
  • Cons: mixes "what the command is" with "how to render it". Multiple commands sharing the same defaults need either inheritance or duplication.

B. Factory pattern (split: runtime vs. tooling)

A single factory that returns both runtime (runMain) and tooling (generateDoc) would force the tooling module into the runtime bundle — undesirable for tree-shaking. The factory must therefore be split into separate runtime / tooling entry points sharing the same defaults config.

// runtime (in bin/cli.ts)
import { configurePolittyRuntime } from "politty";
const { defineCommand, runMain } = configurePolittyRuntime({
  defaults: { negation: (cliName) => `disable-${cliName}` },
});

// tooling (in scripts/gen-docs.ts)
import { configurePolittyDocs } from "politty/docs";
const { generateDoc } = configurePolittyDocs({
  defaults: { negation: (cliName) => `disable-${cliName}` },
});
  • Pros: FP-pure, no module-global state. Each factory statically guarantees that all entry points it returns share the same defaults.
  • Cons: most invasive — breaking change to all imports. defaults must be declared twice (once per factory), so the source of truth has to be a shared constant. Forces a decision on what happens when a command defined under one factory is run by another. Two factories cannot easily share types.

C. Pass at every entry point (runMain, runCommand, generateDoc, ...)

runMain(cmd, { defaults: { negation: (cliName) => `disable-${cliName}` } });
generateDoc({ command: cmd, defaults: { negation: (cliName) => `disable-${cliName}` } });
  • Pros: explicit, no hidden state, no factory ceremony. Runtime and tooling import graphs stay independent.
  • Cons: easy for runMain's and generateDoc's defaults to drift out of sync; users have to remember to pass them at every call site.

Rejected alternatives

  • Module-level setter (setDefaults(...)): implicit ordering, race-prone in tests, hard to reason about.
  • Single factory returning both runtime and tooling entry points: pulls generateDoc into the runtime bundle. See option B for the split-factory variant that avoids this.

Open sub-questions

  • Subcommand inheritance: do defaults cascade from the root command into subcommands automatically, or must each subcommand re-declare? (Probably cascade with per-level override.)
  • Validation: what happens when a default produces a name that conflicts with another field's cliName / alias / negation? (Probably reuse the existing validateDuplicateNegations pass — it already runs after resolution.)
  • Type-level: should the defaults object be statically typed against the keys of BaseArgMeta so adding a new policy is a single-point change?

Decision to make for v1

  • Pick one of A / B / C (or a hybrid) as the placement.
  • Confirm the inheritance rule and the validation contract.
  • Decide which policies are supported from day one (negation is the motivating case; others can be added later as long as the surface is stable).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions