From 157c82482c0713b20b8ebe8f326e0e6bf15c9078 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:59:37 +0100 Subject: [PATCH 01/11] Add module mode next task stub --- tasks/module-mode-next.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tasks/module-mode-next.md diff --git a/tasks/module-mode-next.md b/tasks/module-mode-next.md new file mode 100644 index 00000000..662ebfd7 --- /dev/null +++ b/tasks/module-mode-next.md @@ -0,0 +1,27 @@ +--- +status: needs-grilling +size: medium +--- + +# Module Mode Next + +Status summary: not specified yet. This task is the visible kickoff for grilling the next module-mode design questions before proposing implementation work. + +## User Ask + +Push module mode further and resolve these design questions: + +- Do extended interfaces work? Do intersected types work? +- How should aliases be supported? +- How should overloaded functions behave? +- Can subcommands be supported without `export * as whatever from './whatever.ts'`, perhaps via exported classes? +- Can some functions opt into explicit zod/standard-schema/trpc/orpc/norpc procedure definitions while other module exports remain plain functions? + +## Checklist + +- [ ] Run a grill-you interview against the existing module-mode implementation. +- [ ] Document factual current behavior for extended interfaces, intersections, aliases, and overloads. +- [ ] Propose support rules for aliases, subcommands, and procedure-like exports. +- [ ] Capture open risks, tradeoffs, and follow-up implementation slices. +- [ ] Open a draft PR for review. + From bbabc04f0dfff93c59976de341572515c6eee365 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:08:10 +0100 Subject: [PATCH 02/11] Propose module mode next steps --- tasks/module-mode-next.interview.md | 230 ++++++++++++++++++++++++++++ tasks/module-mode-next.md | 161 ++++++++++++++++++- 2 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 tasks/module-mode-next.interview.md diff --git a/tasks/module-mode-next.interview.md b/tasks/module-mode-next.interview.md new file mode 100644 index 00000000..81907e0b --- /dev/null +++ b/tasks/module-mode-next.interview.md @@ -0,0 +1,230 @@ +# module-mode-next Grill Interview + +## Setup Note — local claude auth failure — 2026-06-19 + +The literal `claude --print` path from the `grill-you` skill failed with `401 Invalid authentication credentials` before producing a question. The interview continued via the platform multi-agent tool with the same dossier. + +--- + +## Q1 — type support boundary — 2026-06-19 + +Codebase answer: same-file `interface extends`, multiple `extends`, and object intersections already work through `Type.Script` plus local declaration context. Imported type references still fail loudly, by design. + +**Question 1:** Should the proposal explicitly define module-mode type support as “same-file, `Type.Script`-parseable declarations only,” rather than trying to add TypeScript compiler/imported-type resolution? + +**Recommended answer:** Yes. Document that module mode supports inline types, same-file `type`/`interface`, interface `extends`, object intersections, unions of object options, and first-overload signature extraction. Imported types, complex generics, and unparseable declarations should remain startup errors. Users who need richer imported schemas should opt into explicit procedure/schema exports instead of expecting plain-function scanning to become a compiler. + +## A1 — 2026-06-19 + +Yes. Keep the plain-function scanner deliberately bounded to same-file, Type.Script-parseable declarations. The proposal should distinguish "already supported and should be documented" from "not a goal for plain function scanning". + +Specific current support to document: inline parameter types, same-file type aliases/interfaces, interface extends including multiple bases, object intersections flattened into flags, union-of-object option shapes, scalar/literal-union positionals, and first overload signature extraction. + +Imported types, arbitrary compiler resolution, type-annotated const function aliases, complex generics beyond what Type.Script can parse, and declarations that depend on external type context should keep failing loudly. [guess: this matches the owner's preference for loud startup errors and small pragmatic mechanisms over building a compiler.] The escape hatch for richer typing should be explicit runtime schema/procedure exports. + +--- + +## Q2 — aliases in plain-function module mode — 2026-06-19 + +Codebase answer: aliases already exist for router/procedure mode through `meta.aliases.command` and `meta.aliases.options`, plus schema-level property `alias`. Plain-function module mode only reads function JSDoc as descriptions and type syntax as schemas; it has no metadata channel. + +**Question 2:** Should plain-function module mode get aliases through JSDoc tags, or should aliases require opting into explicit procedure/schema exports? + +**Recommended answer:** Use JSDoc tags for the bounded plain-function case, and keep explicit procedures as the richer escape hatch. Proposed syntax: + +```ts +/** + * install deps + * @alias i + * @alias add + */ +export function install(options: { + /** fail if lockfile changed + * @alias f + */ + frozenLockfile?: boolean +}) {} +``` + +Rules: `@alias` above a command becomes `meta.aliases.command`; `@alias` on an option property becomes the option alias; aliases validate against the derived command/options and fail at startup on typos/conflicts. Do not add wrapper helpers or static metadata properties for plain functions, because those make the zero-boilerplate surface more magical than necessary and overlap with explicit procedure exports. + +## A2 — 2026-06-19 + +Yes, use JSDoc tags for aliases in the plain-function surface. + +Rules I want in the proposal: + +- Command JSDoc supports repeated `@alias ` tags, mapped to `meta.aliases.command`. +- Option/property JSDoc supports one `@alias ` tag, mapped onto the property schema's existing `alias` field or equivalent `meta.aliases.options` path. +- Alias tags should be stripped from help descriptions so `--help` stays prose, not raw metadata. +- Invalid aliases should fail at startup or command build with the same strictness as existing alias meta. For property aliases, reuse existing invalid-option-alias checks where possible. +- Do not invent wrapper helpers or static side-channel metadata for plain functions in this proposal. If someone needs richer command metadata, explicit procedure exports should cover that path. + +[guess: JSDoc is the least-bad metadata channel because module mode already treats source comments as user-facing CLI docs, and aliases are CLI docs/ergonomics rather than runtime behavior.] + +--- + +## Q3 — overload semantics — 2026-06-19 + +Codebase answer: overloads already work by taking the first overload signature and ignoring later signatures plus the implementation. Tests pin this for object options and multi-parameter positionals. + +**Question 3:** Should the proposal keep overload handling exactly as “first overload signature is the CLI contract,” including failing if that first signature is unparseable, rather than merging overloads or adding selection metadata? + +**Recommended answer:** Yes. Document it as intentional. A CLI command has one help shape and one validation schema; merging overloads would either reject valid runtime calls or advertise combinations no overload actually accepts. If users want a different CLI shape, they should reorder overloads, export a wrapper function with the desired signature, or use an explicit procedure/schema export. + +## A3 — 2026-06-19 + +Yes. Keep first overload signature as the CLI contract and document it as intentional current behavior. + +No overload merging, no `@cliOverload`, no selection metadata in this proposal. If the first signature cannot be parsed, fail loudly. If a user wants a different command shape, they can reorder overloads, export a CLI-specific wrapper with a plain parseable signature, or export an explicit schema/procedure command. + +[guess: avoiding overload merging is important because Commander help/validation needs one concrete public invocation shape, and trpc-cli should not pretend a TypeScript overload set is one CLI grammar.] + +--- + +## Q4 — same-file subcommands and class exports — 2026-06-19 + +Codebase answer: today `export class Whatever {}` fails as an unmatched exported function because classes are `typeof value === 'function'` at runtime, but the scanner only recognizes exported function declarations/arrow consts. Same-file nested plain-function groups are not supported; nested commands currently come from `export * as group from './child'` or from router/procedure trees. + +**Question 4:** Should same-file subcommand grouping use exported classes, or should the proposal reject classes and prefer object/router-shaped exports? + +**Recommended answer:** Reject class exports as plain-function subcommands for this proposal. Classes create too many hidden policy choices: static vs instance methods, constructor arguments, per-invocation lifecycle, inherited methods, private state, and whether class export names are commands or dependency containers. Keep `export class` failing loudly unless it is part of an explicit procedure/router export path later. + +For same-file grouping, prefer explicit runtime grouping in Phase 2: + +```ts +export const users = { + invite(options: {email: string}) {}, + deactivate(options: {id: string}) {}, +} +``` + +That would map to `mycli users invite`, but only if the proposal accepts extending the source scanner to parse exported object-literal methods with the same plain-function rules. For richer cases, users should use `export const users = t.router({...})` or file-backed `export * as users from './users.ts'`. + +## A4 — 2026-06-19 + +Reject class exports for this proposal. Keep `export class Whatever {}` as an actionable startup error, not a subcommand source. + +The same-file grouping proposal should be exported object literals of command functions/methods: + +```ts +export const users = { + /** invite a user */ + invite(options: {email: string}) {}, + async deactivate(options: {id: string}) {}, +} +``` + +That should map to `mycli users invite` and `mycli users deactivate`, using the same plain-function extraction rules for method JSDoc, parameter parsing, aliases, overload policy where applicable, and validation. Keep file-backed `export * as users from './users.ts'` as the preferred way to split larger groups across files. + +For richer grouping, support explicit runtime routers/procedures: `export const users = t.router({...})` or ORPC/trpc router-shaped values if practical. Do not instantiate classes, do not inspect prototype methods, do not support constructor injection, and do not try to infer lifecycle from classes. [guess: class syntax looks attractive but would create too many unspoken runtime policies for a feature whose selling point is obvious function-to-command mapping.] + +--- + +## Q5 — explicit schema/procedure exports — 2026-06-19 + +Codebase answer: module mode currently builds one norpc router from source-scanned plain functions. Router mode can parse tRPC routers, oRPC routers, and norpc routers, but `parseRouter` chooses one router kind at the root; it does not support a mixed tree containing norpc procedures beside oRPC/tRPC procedures. Exported norpc procedures are runtime objects, so module mode currently ignores them and may end with “No commands found.” + +**Question 5:** For explicit schema/procedure exports in module mode, should Phase 2 support only `trpc-cli`’s own `t`/`os` norpc procedures and routers first, leaving exported tRPC/oRPC procedures/routers as later work? + +**Recommended answer:** Yes. Start with norpc because it is already the internal representation module mode generates, uses `TrpcCliMeta` directly, and can mix naturally with plain functions in one norpc router: + +```ts +export function plain(options: {name: string}) {} + +export const explicit = os + .input(z.object({name: z.string()})) + .meta({aliases: {command: ['x']}}) + .handler(({input}) => input.name) + +export const users = os.router({ + invite: os.input(z.object({email: z.string()})).handler(...) +}) +``` + +Treat norpc procedure exports as commands at their export name, norpc router exports as subcommand groups, and detect conflicts with scanned plain commands. Document tRPC/oRPC explicit module exports as future work unless the implementation finds a low-risk way to normalize them into norpc without weakening the current root-router-kind guards. + +## A5 — 2026-06-19 + +Yes. First explicit-procedure slice should support trpc-cli's own norpc values only: `os.input(...).handler(...)`, `t.procedure.input(...).query(...)`, and `os.router(...)`/`t.router(...)` values. + +That covers zod/standard-schema on selected commands because the explicit procedure carries a runtime Standard Schema, metadata, middleware, aliases, jsonInput, etc. It also composes naturally with generated plain-function procedures because module mode already produces a norpc router internally. + +Rules for the proposal: + +- A runtime export that is `isNorpcProcedure` becomes a command named after the export. +- A runtime export that is `isNorpcRouter` becomes a subcommand group named after the export. +- These can coexist with source-scanned plain functions and proposed object-literal groups. +- Conflicts between generated commands, explicit procedures, explicit routers, and re-exported children fail loudly. +- If the source scanner sees an exported const whose runtime value is a norpc procedure/router, it should not require a parseable function declaration for it. +- Actual tRPC/oRPC procedure/router exports are future work. They have different root parsing/calling requirements and should not be half-supported in the first pass. + +[guess: supporting norpc first gives the user-requested standard-schema/zod escape hatch while keeping the module-mode output as one coherent norpc router.] + +--- + +## Q6 — object-literal group gating — 2026-06-19 + +Codebase answer: non-function exports are currently ignored (`REGISTRY_URL` is tested), while exported functions that the scanner cannot parse fail loudly. Object-literal groups would introduce a new middle case: an exported object may be an ordinary constant, a plain-function command group, or an explicit norpc router. + +**Question 6:** For `export const users = { ... }` same-file groups, should grouping be syntax-gated to object literals with command-shaped method properties, while ordinary exported objects remain ignored? + +**Recommended answer:** Yes. Support only direct exported object literals whose properties are parseable command methods/functions: + +```ts +export const users = { + invite(options: {email: string}) {}, + deactivate: async (options: {id: string}) => {}, +} +``` + +If at least one property is command-shaped, the object becomes a subcommand group, and any function-valued property in that object that cannot be parsed should fail loudly. Non-function properties in a command group should fail too unless they are explicit norpc procedures/routers, because a mixed object would make help unpredictable. + +Plain exported constants like `export const config = {debug: true}` should stay ignored. Nested object-literal groups can be future work unless represented as explicit `t.router`/`os.router`. + +## A6 — 2026-06-19 + +Yes. Object-literal grouping should be syntax-gated and conservative. + +Proposal rules: + +- Only direct `export const users = { ... }` object literals are candidates for plain same-file groups. +- The object becomes a command group only when it contains at least one command-shaped property: method shorthand, function-valued property, arrow-function property, norpc procedure, or norpc router. +- Once an object is classified as a command group, all properties must be command-shaped. Non-function data properties should fail loudly rather than be silently ignored inside a group. +- Ordinary exported objects with no command-shaped properties remain ignored, preserving current non-function export behavior. +- Nested object-literal groups are future work unless they are explicit `t.router`/`os.router` values. The first implementation slice can be one level of object-literal grouping plus arbitrary nesting through explicit norpc routers. + +[guess: this keeps `export const config = {...}` safe while still giving same-file subcommands without class lifecycle ambiguity.] + +--- + +## Q7 — default command behavior — 2026-06-19 + +Codebase answer: current module mode gives `export default function named(...)` special treatment: it becomes a command named `named` and also the root default command. Router/procedure mode instead uses `meta.default === true` to mark a default command for any parent. These are two different default mechanisms. + +**Question 7:** For explicit norpc procedure/router exports and object-literal groups, should default commands be controlled only by norpc `meta.default`, while the existing automatic default-export behavior stays limited to plain `export default function`? + +**Recommended answer:** Yes. Keep the current plain-function default export behavior as-is, because it is already documented and tested. For explicit procedures and routers, do not infer default behavior from `export default`; require `.meta({default: true})` on the procedure. For object-literal groups, support a default child only through an explicit norpc procedure with `meta.default`, not by naming a method `default` in the plain-function group. That keeps default behavior tied to the existing router/procedure metadata model and avoids inventing another source-level convention. + +## A7 — 2026-06-19 + +Yes. Preserve the existing `export default function` behavior for plain functions only. + +For explicit norpc procedures/routers, default commands should be controlled by existing `meta.default`, not by default export syntax. Prefer named explicit exports for the first slice: + +```ts +export const run = os + .meta({default: true}) + .input(schema) + .handler(...) +``` + +For object-literal plain groups, do not introduce a magic `default()` method convention. If a group needs a default subcommand, use an explicit norpc procedure child with `meta.default`. Plain method names should just be command names. + +[guess: this avoids three competing default-command mechanisms while preserving the one shipped plain-function shortcut.] + +--- + +## Stop — 2026-06-19 + +ready for Phase 2 diff --git a/tasks/module-mode-next.md b/tasks/module-mode-next.md index 662ebfd7..6f61fe0a 100644 --- a/tasks/module-mode-next.md +++ b/tasks/module-mode-next.md @@ -1,11 +1,11 @@ --- -status: needs-grilling +status: proposal size: medium --- # Module Mode Next -Status summary: not specified yet. This task is the visible kickoff for grilling the next module-mode design questions before proposing implementation work. +Status summary: proposal ready for review. The grill pass resolved the requested module-mode questions and scoped a pragmatic next implementation path: document existing type/overload behavior, add JSDoc aliases, support explicit norpc exports, and add conservative same-file object-literal groups. No implementation has been done yet. ## User Ask @@ -17,11 +17,158 @@ Push module mode further and resolve these design questions: - Can subcommands be supported without `export * as whatever from './whatever.ts'`, perhaps via exported classes? - Can some functions opt into explicit zod/standard-schema/trpc/orpc/norpc procedure definitions while other module exports remain plain functions? +## Proposal + +### Current Behavior To Document + +Module mode already supports more of the type story than the release notes implied: + +- Inline parameter types and same-file `type`/`interface` declarations work. +- `interface Options extends Base { ... }` works. +- `interface Options extends A, B { ... }` works. +- Object intersections such as `type Opts = {a: string} & {b: string}` work and are flattened into one flag set when possible. +- Trailing intersection-alias options objects work in positional tuple mode. +- Union-of-object option shapes work. +- Overloaded functions already use the first overload signature as the CLI contract. + +The boundary should be explicit: plain-function source scanning is same-file and `Type.Script`-parseable only. Imported type references, arbitrary TypeScript compiler resolution, type-annotated const function aliases, and complex declarations that need external type context should keep failing loudly. Users who need richer typing should use explicit runtime schemas/procedures. + +### Aliases + +Plain-function module mode should support aliases through JSDoc tags: + +```ts +/** + * install dependencies + * @alias i + */ +export function install(options: { + /** fail if the lockfile changed + * @alias f + */ + frozenLockfile?: boolean +}) {} +``` + +Rules: + +- Repeated command-level `@alias ` tags map to `meta.aliases.command`. +- A property-level `@alias ` tag maps to that option's alias. +- Alias tags are stripped from help descriptions. +- Invalid or conflicting aliases fail with the same strictness as existing router/procedure aliases. +- No wrapper helper or static metadata side channel for plain functions in this pass. + +### Overloads + +Keep the current first-signature behavior. A CLI command has one help shape and one validation schema; merging overloads would advertise invalid combinations. Users who want a different CLI shape should reorder overloads, export a CLI-specific wrapper, or export an explicit schema/procedure command. + +### Same-File Subcommands + +Do not support class exports as subcommand groups. Classes create too many hidden policy choices: static vs instance methods, constructor arguments, per-invocation lifecycle, inherited methods, and private state. + +Prefer direct exported object-literal groups: + +```ts +export const users = { + /** invite a user */ + invite(options: {email: string}) {}, + async deactivate(options: {id: string}) {}, +} +``` + +Rules: + +- A direct `export const users = { ... }` object literal is a group candidate. +- The object becomes a group only if it contains at least one command-shaped property. +- Once classified as a group, every property must be command-shaped: method shorthand, function-valued property, arrow-function property, norpc procedure, or norpc router. +- Ordinary exported objects with no command-shaped properties stay ignored. +- Nested object-literal groups are future work; use explicit `t.router`/`os.router` for nested runtime groups. +- `export class Whatever {}` should keep failing with an actionable startup error. + +### Explicit Schema/Procedure Exports + +First implementation slice should support trpc-cli's own norpc values only: + +```ts +import {os} from 'trpc-cli' +import {z} from 'zod/v4' + +export function plain(options: {name: string}) { + return options.name +} + +export const explicit = os + .input(z.object({name: z.string()})) + .meta({aliases: {command: ['x']}}) + .handler(({input}) => input.name) + +export const users = os.router({ + invite: os.input(z.object({email: z.string()})).handler(({input}) => input.email), +}) +``` + +Rules: + +- `isNorpcProcedure` runtime exports become commands named after their export. +- `isNorpcRouter` runtime exports become subcommand groups named after their export. +- Explicit norpc exports can coexist with source-scanned plain functions and object-literal groups. +- Conflicts fail loudly. +- Exported consts whose runtime values are norpc procedures/routers should not require parseable function declarations. +- Actual tRPC/oRPC procedure or router exports are future work; they have different root parsing/calling requirements and should not be half-supported in this pass. + +### Default Commands + +Preserve existing `export default function` behavior for plain functions only. + +For explicit norpc exports, use existing metadata: + +```ts +export const run = os + .meta({default: true}) + .input(schema) + .handler(...) +``` + +Do not infer default behavior from default-exported explicit procedures, and do not add a magic `default()` method convention inside plain object-literal groups. + +## Suggested Implementation Slices + +1. Documentation and test pins for current type/overload behavior. +2. JSDoc metadata parsing for command and property `@alias`, including stripping tags from descriptions. +3. Runtime norpc procedure/router exports in module mode, with conflict detection. +4. Direct object-literal command groups. +5. Documentation for non-goals: imported type resolution, class groups, tRPC/oRPC mixed exports, overload merging, and default-method magic. + +## Guesses And Assumptions + +- Same-file `Type.Script`-parseable support is the right boundary because it matches the project's preference for loud errors and small pragmatic mechanisms over building a TypeScript compiler. +- JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. +- First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. +- Class syntax is attractive but would create too many unspoken runtime policies for a feature whose selling point is obvious function-to-command mapping. +- Norpc explicit exports are the right first schema escape hatch because module mode already normalizes plain functions into a norpc router. +- Object-literal group detection should be conservative so `export const config = {...}` stays safe. +- Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut plus norpc `meta.default`. + +## Out Of Scope For This Proposal PR + +- Implementing the proposal. +- Switching module mode to the TypeScript compiler API. +- Imported type resolution. +- Class-based subcommands. +- Mixed tRPC/oRPC/norpc export trees. +- Overload merging or overload-selection metadata. +- Top-level-awaited `createCli(import.meta).run()`. + ## Checklist -- [ ] Run a grill-you interview against the existing module-mode implementation. -- [ ] Document factual current behavior for extended interfaces, intersections, aliases, and overloads. -- [ ] Propose support rules for aliases, subcommands, and procedure-like exports. -- [ ] Capture open risks, tradeoffs, and follow-up implementation slices. -- [ ] Open a draft PR for review. +- [x] Run a grill-you interview against the existing module-mode implementation. _Completed via platform sub-agent fallback after the local `claude --print` path failed with a 401; transcript lives in `tasks/module-mode-next.interview.md`._ +- [x] Document factual current behavior for extended interfaces, intersections, aliases, and overloads. _Captured above under "Current Behavior To Document"._ +- [x] Propose support rules for aliases, subcommands, and procedure-like exports. _Captured above under the feature-specific proposal sections._ +- [x] Capture open risks, tradeoffs, and follow-up implementation slices. _Captured in "Suggested Implementation Slices", "Guesses And Assumptions", and "Out Of Scope"._ +- [x] Open a draft PR for review. _Opened as #211._ + +## Implementation Notes +- 2026-06-19: Created branch `module-mode-next` from `main`, committed this task stub first, pushed, and opened draft PR #211 before filling in the proposal. +- 2026-06-19: Local `claude --print` sub-agent invocation failed with `401 Invalid authentication credentials`; continued the grill with the platform multi-agent tool. +- 2026-06-19: Quick local probe against built `dist` confirmed same-file extended interfaces, multiple interface extends, and alias-to-alias intersections currently derive flags as expected. From 1a84f6da98967580314962e8b71bfe8cd427a24c Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:32:32 +0100 Subject: [PATCH 03/11] Revise module mode proposal for class groups --- tasks/module-mode-next.interview.md | 20 +++++++++++ tasks/module-mode-next.md | 56 +++++++++++++++++++---------- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/tasks/module-mode-next.interview.md b/tasks/module-mode-next.interview.md index 81907e0b..b7961c30 100644 --- a/tasks/module-mode-next.interview.md +++ b/tasks/module-mode-next.interview.md @@ -228,3 +228,23 @@ For object-literal plain groups, do not introduce a magic `default()` method con ## Stop — 2026-06-19 ready for Phase 2 + +--- + +## Post-Grill User Update — class groups only — 2026-06-19 + +The user challenged the earlier rejection of classes and proposed a narrower rule: class groups are workable if trpc-cli can confirm the class has no base class and no constructor arguments. + +Updated decision: + +- Same-file subcommands should use class groups only, not object-literal groups. +- `export class Users { invite(...) {} }` maps to `mycli users invite`. +- Reject `extends`. +- Reject constructors with parameters; allow no constructor or `constructor()`. +- Help/schema generation must not instantiate the class. +- Instantiate lazily inside the command handler, with a fresh instance per invocation. +- Public instance methods directly declared in the class body are commands. +- Private/protected methods and private fields are allowed as implementation details and are not commands. +- Static methods are not commands in the first slice. + +Rationale: the constraints remove the main lifecycle ambiguity, and private state/side effects are a feature of classes as long as instantiation only happens when the command actually runs. diff --git a/tasks/module-mode-next.md b/tasks/module-mode-next.md index 6f61fe0a..74aabd1a 100644 --- a/tasks/module-mode-next.md +++ b/tasks/module-mode-next.md @@ -5,7 +5,7 @@ size: medium # Module Mode Next -Status summary: proposal ready for review. The grill pass resolved the requested module-mode questions and scoped a pragmatic next implementation path: document existing type/overload behavior, add JSDoc aliases, support explicit norpc exports, and add conservative same-file object-literal groups. No implementation has been done yet. +Status summary: proposal ready for review. The grill pass resolved the requested module-mode questions, then a follow-up decision switched same-file subcommands from object-literal groups to class groups only. The scoped path is now: document existing type/overload behavior, add JSDoc aliases, support explicit norpc exports, and add lazy-instantiated class subcommand groups. No implementation has been done yet. ## User Ask @@ -64,26 +64,42 @@ Keep the current first-signature behavior. A CLI command has one help shape and ### Same-File Subcommands -Do not support class exports as subcommand groups. Classes create too many hidden policy choices: static vs instance methods, constructor arguments, per-invocation lifecycle, inherited methods, and private state. +Support exported classes as the only new same-file subcommand grouping syntax. The class is a command group; its public instance methods are subcommands. -Prefer direct exported object-literal groups: +Example: ```ts -export const users = { +export class Users { /** invite a user */ - invite(options: {email: string}) {}, - async deactivate(options: {id: string}) {}, + invite(options: {email: string}) { + return options.email + } + + async deactivate(options: {id: string}) { + return options.id + } + + #audit(action: string) { + // private implementation detail, not a command + } } ``` +This maps to `mycli users invite` and `mycli users deactivate`. + Rules: -- A direct `export const users = { ... }` object literal is a group candidate. -- The object becomes a group only if it contains at least one command-shaped property. -- Once classified as a group, every property must be command-shaped: method shorthand, function-valued property, arrow-function property, norpc procedure, or norpc router. -- Ordinary exported objects with no command-shaped properties stay ignored. -- Nested object-literal groups are future work; use explicit `t.router`/`os.router` for nested runtime groups. -- `export class Whatever {}` should keep failing with an actionable startup error. +- Only direct `export class Users { ... }` declarations are candidates. +- No `extends`; base classes are rejected. +- The class must have no constructor, or exactly a zero-argument constructor. +- Public instance method declarations directly in the class body become commands. +- Private/protected methods and private fields are internal implementation details, not commands. +- Static methods are not commands in the first slice. +- Method parameter parsing, JSDoc descriptions, aliases, and overload behavior follow the same rules as exported functions. +- Help/schema generation must not instantiate the class. +- Instantiate lazily inside the command handler, and create a fresh instance per command invocation. +- If a public instance method is command-shaped but cannot be parsed, fail loudly. +- Do not add object-literal command groups in this proposal. Ordinary exported object constants stay ignored unless they are explicit norpc routers/procedures. ### Explicit Schema/Procedure Exports @@ -111,7 +127,7 @@ Rules: - `isNorpcProcedure` runtime exports become commands named after their export. - `isNorpcRouter` runtime exports become subcommand groups named after their export. -- Explicit norpc exports can coexist with source-scanned plain functions and object-literal groups. +- Explicit norpc exports can coexist with source-scanned plain functions and class groups. - Conflicts fail loudly. - Exported consts whose runtime values are norpc procedures/routers should not require parseable function declarations. - Actual tRPC/oRPC procedure or router exports are future work; they have different root parsing/calling requirements and should not be half-supported in this pass. @@ -129,24 +145,24 @@ export const run = os .handler(...) ``` -Do not infer default behavior from default-exported explicit procedures, and do not add a magic `default()` method convention inside plain object-literal groups. +Do not infer default behavior from default-exported explicit procedures, and do not add a magic `default()` method convention inside class groups. If a class group needs a default child, use an explicit norpc router/procedure instead. ## Suggested Implementation Slices 1. Documentation and test pins for current type/overload behavior. 2. JSDoc metadata parsing for command and property `@alias`, including stripping tags from descriptions. 3. Runtime norpc procedure/router exports in module mode, with conflict detection. -4. Direct object-literal command groups. -5. Documentation for non-goals: imported type resolution, class groups, tRPC/oRPC mixed exports, overload merging, and default-method magic. +4. Lazy-instantiated class command groups. +5. Documentation for non-goals: imported type resolution, object-literal command groups, tRPC/oRPC mixed exports, overload merging, and default-method magic. ## Guesses And Assumptions - Same-file `Type.Script`-parseable support is the right boundary because it matches the project's preference for loud errors and small pragmatic mechanisms over building a TypeScript compiler. - JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. - First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. -- Class syntax is attractive but would create too many unspoken runtime policies for a feature whose selling point is obvious function-to-command mapping. +- Class groups are acceptable when constrained to no base class, no constructor arguments, public instance methods only, and lazy per-invocation instantiation. - Norpc explicit exports are the right first schema escape hatch because module mode already normalizes plain functions into a norpc router. -- Object-literal group detection should be conservative so `export const config = {...}` stays safe. +- Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data unless it is an explicit norpc router/procedure. - Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut plus norpc `meta.default`. ## Out Of Scope For This Proposal PR @@ -154,7 +170,8 @@ Do not infer default behavior from default-exported explicit procedures, and do - Implementing the proposal. - Switching module mode to the TypeScript compiler API. - Imported type resolution. -- Class-based subcommands. +- Object-literal command groups. +- Class groups with inheritance or constructor arguments. - Mixed tRPC/oRPC/norpc export trees. - Overload merging or overload-selection metadata. - Top-level-awaited `createCli(import.meta).run()`. @@ -172,3 +189,4 @@ Do not infer default behavior from default-exported explicit procedures, and do - 2026-06-19: Created branch `module-mode-next` from `main`, committed this task stub first, pushed, and opened draft PR #211 before filling in the proposal. - 2026-06-19: Local `claude --print` sub-agent invocation failed with `401 Invalid authentication credentials`; continued the grill with the platform multi-agent tool. - 2026-06-19: Quick local probe against built `dist` confirmed same-file extended interfaces, multiple interface extends, and alias-to-alias intersections currently derive flags as expected. +- 2026-06-19: Follow-up user decision replaced object-literal groups with class groups only, scoped to no base class/no constructor args and lazy instantiation. From 20d5c429a3072f0754d3853ffdd84852c70048de Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:35:23 +0100 Subject: [PATCH 04/11] Move norpc exports out of module mode proposal scope --- tasks/module-mode-next.interview.md | 12 +++++++++ tasks/module-mode-next.md | 40 +++++++++++------------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/tasks/module-mode-next.interview.md b/tasks/module-mode-next.interview.md index b7961c30..e01dbb61 100644 --- a/tasks/module-mode-next.interview.md +++ b/tasks/module-mode-next.interview.md @@ -248,3 +248,15 @@ Updated decision: - Static methods are not commands in the first slice. Rationale: the constraints remove the main lifecycle ambiguity, and private state/side effects are a feature of classes as long as instantiation only happens when the command actually runs. + +--- + +## Post-Grill User Update — explicit norpc exports out of scope — 2026-06-19 + +The user decided that support for exported norpc procedures/routers should move out of this proposal and into a separate change. + +Updated decision: + +- Keep a note that explicit norpc exports are probably the right future schema/procedure escape hatch. +- Do not include `os.input(...).handler(...)`, `t.procedure...`, or `os.router(...)` support in the first implementation scope. +- This proposal should focus on documenting current type/overload behavior, JSDoc aliases, and class groups. diff --git a/tasks/module-mode-next.md b/tasks/module-mode-next.md index 74aabd1a..265dabb0 100644 --- a/tasks/module-mode-next.md +++ b/tasks/module-mode-next.md @@ -5,7 +5,7 @@ size: medium # Module Mode Next -Status summary: proposal ready for review. The grill pass resolved the requested module-mode questions, then a follow-up decision switched same-file subcommands from object-literal groups to class groups only. The scoped path is now: document existing type/overload behavior, add JSDoc aliases, support explicit norpc exports, and add lazy-instantiated class subcommand groups. No implementation has been done yet. +Status summary: proposal ready for review. The grill pass resolved the requested module-mode questions, then follow-up decisions switched same-file subcommands to class groups only and moved explicit norpc exports out of scope for a separate change. The scoped path is now: document existing type/overload behavior, add JSDoc aliases, and add lazy-instantiated class subcommand groups. No implementation has been done yet. ## User Ask @@ -99,20 +99,18 @@ Rules: - Help/schema generation must not instantiate the class. - Instantiate lazily inside the command handler, and create a fresh instance per command invocation. - If a public instance method is command-shaped but cannot be parsed, fail loudly. -- Do not add object-literal command groups in this proposal. Ordinary exported object constants stay ignored unless they are explicit norpc routers/procedures. +- Do not add object-literal command groups in this proposal. Ordinary exported object constants stay ignored. ### Explicit Schema/Procedure Exports -First implementation slice should support trpc-cli's own norpc values only: +This belongs in a separate change, not this first follow-up. Keep the design note, but do not implement it in this proposal's scope. + +The likely separate change should support trpc-cli's own norpc values first: ```ts import {os} from 'trpc-cli' import {z} from 'zod/v4' -export function plain(options: {name: string}) { - return options.name -} - export const explicit = os .input(z.object({name: z.string()})) .meta({aliases: {command: ['x']}}) @@ -127,33 +125,23 @@ Rules: - `isNorpcProcedure` runtime exports become commands named after their export. - `isNorpcRouter` runtime exports become subcommand groups named after their export. -- Explicit norpc exports can coexist with source-scanned plain functions and class groups. +- Explicit norpc exports can coexist with source-scanned plain functions and class groups in that later change. - Conflicts fail loudly. - Exported consts whose runtime values are norpc procedures/routers should not require parseable function declarations. - Actual tRPC/oRPC procedure or router exports are future work; they have different root parsing/calling requirements and should not be half-supported in this pass. ### Default Commands -Preserve existing `export default function` behavior for plain functions only. - -For explicit norpc exports, use existing metadata: - -```ts -export const run = os - .meta({default: true}) - .input(schema) - .handler(...) -``` +Preserve existing `export default function` behavior for plain functions only. Do not add a magic `default()` method convention inside class groups. -Do not infer default behavior from default-exported explicit procedures, and do not add a magic `default()` method convention inside class groups. If a class group needs a default child, use an explicit norpc router/procedure instead. +Default behavior for explicit norpc exports should be handled in the separate explicit-procedure change, using existing `meta.default`. ## Suggested Implementation Slices 1. Documentation and test pins for current type/overload behavior. 2. JSDoc metadata parsing for command and property `@alias`, including stripping tags from descriptions. -3. Runtime norpc procedure/router exports in module mode, with conflict detection. -4. Lazy-instantiated class command groups. -5. Documentation for non-goals: imported type resolution, object-literal command groups, tRPC/oRPC mixed exports, overload merging, and default-method magic. +3. Lazy-instantiated class command groups. +4. Documentation for non-goals and follow-ups: imported type resolution, object-literal command groups, explicit norpc exports, tRPC/oRPC mixed exports, overload merging, and default-method magic. ## Guesses And Assumptions @@ -161,9 +149,9 @@ Do not infer default behavior from default-exported explicit procedures, and do - JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. - First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. - Class groups are acceptable when constrained to no base class, no constructor arguments, public instance methods only, and lazy per-invocation instantiation. -- Norpc explicit exports are the right first schema escape hatch because module mode already normalizes plain functions into a norpc router. -- Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data unless it is an explicit norpc router/procedure. -- Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut plus norpc `meta.default`. +- Explicit norpc exports are probably the right schema escape hatch, but they belong in a separate change because they introduce runtime-export composition beyond plain source-scanned commands. +- Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data. +- Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut in this proposal. ## Out Of Scope For This Proposal PR @@ -172,6 +160,7 @@ Do not infer default behavior from default-exported explicit procedures, and do - Imported type resolution. - Object-literal command groups. - Class groups with inheritance or constructor arguments. +- Explicit norpc procedure/router exports. - Mixed tRPC/oRPC/norpc export trees. - Overload merging or overload-selection metadata. - Top-level-awaited `createCli(import.meta).run()`. @@ -190,3 +179,4 @@ Do not infer default behavior from default-exported explicit procedures, and do - 2026-06-19: Local `claude --print` sub-agent invocation failed with `401 Invalid authentication credentials`; continued the grill with the platform multi-agent tool. - 2026-06-19: Quick local probe against built `dist` confirmed same-file extended interfaces, multiple interface extends, and alias-to-alias intersections currently derive flags as expected. - 2026-06-19: Follow-up user decision replaced object-literal groups with class groups only, scoped to no base class/no constructor args and lazy instantiation. +- 2026-06-19: Follow-up user decision moved support for exported norpc procedures/routers out of scope for this proposal and into a separate change. From a874389504833d9839e9fff057c0a2c5b4fa04b2 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:00:24 +0100 Subject: [PATCH 05/11] Implement module mode aliases and class groups --- README.md | 42 +++- src/module-commands.ts | 214 ++++++++++++++++- src/types.ts | 12 +- .../2026-06-19-module-mode-next.interview.md} | 0 .../2026-06-19-module-mode-next.md} | 8 +- test/typebox-module-commands.test.ts | 221 ++++++++++++++++++ 6 files changed, 479 insertions(+), 18 deletions(-) rename tasks/{module-mode-next.interview.md => complete/2026-06-19-module-mode-next.interview.md} (100%) rename tasks/{module-mode-next.md => complete/2026-06-19-module-mode-next.md} (89%) diff --git a/README.md b/README.md index 106b69ed..6b4f166f 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,22 @@ void createCli({filename: '/path/to/commands.ts'}).run() trpc-cli reads the module's *source text* and dynamically imports it: each exported function becomes a command (kebab-cased, e.g. `listVersions` → `list-versions`), the jsdoc above the function becomes the command description, and parameter type annotations are parsed by the vendored `Type.Script` into real JSON schemas - property jsdoc comments become flag descriptions, and inputs are validated before your function runs (`mycli add --package-name left-pad --dev`). A default-exported function becomes the default command, so `export default function hola(...)` can be run as either `mycli ...` or `mycli hola ...`. +Command and option aliases can be added with `@alias` tags in the same jsdoc comments: + +```ts +/** install dependencies + * @alias i + */ +export function install(options: { + /** fail if the lockfile is out of date + * @alias f + */ + frozenLockfile?: boolean +}) { + // mycli i -f +} +``` + File-backed module mode can compose command modules too: ```ts @@ -666,6 +682,28 @@ export * as users from './users.ts' // creates a nested `users` sub-router Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`. +Same-file subcommand groups can be written as exported classes. The class is not instantiated when help/schema information is built; trpc-cli creates a fresh instance only when one of the class's method commands runs. + +```ts +// commands.ts +export class Users { + /** invite a user */ + invite(options: { + /** address to invite */ + email: string + }) { + this.#audit('invite') + return `invite ${options.email}` // mycli users invite --email ada@example.com + } + + #audit(action: string) { + // private implementation detail, not a command + } +} +``` + +Class command groups must be direct `export class Name { ... }` declarations with no `extends` and no constructor arguments (no constructor, or `constructor()`, is fine). Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. + Multi-parameter functions work too: leading string/number/boolean parameters become positional arguments, and a trailing object parameter becomes flags - the same convention as [tuple inputs](#combining-positional-arguments-and-options): ```ts @@ -692,9 +730,9 @@ Details and limitations: - `filename` accepts an absolute path (robust - works from any directory), `import.meta` (when the call lives in the commands file itself), or a `URL` like `new URL('./commands.ts', import.meta.url)` (resolves relative to the importing file, so a distributed CLI works wherever it's invoked). A *relative* path string is resolved against `process.cwd()`, so it's only reliable when the CLI is run from a known directory - fine for quick scripts, but it breaks the moment a globally-installed CLI runs somewhere else, so prefer `import.meta`/`URL` for anything you distribute. For `.ts` modules, run under tsx, bun, deno, or node >=22.18 (which strip types natively). - `createCli(import.meta)` re-imports the commands file to read its exports, so when the call lives in that same file it's a *self-import*. That's fine as long as the call is at the **bottom** of the file (so all `export const` arrow functions above it are initialized) and is **not** top-level-`await`ed - `void createCli(import.meta).run()` is the safe form. A top-level `await createCli(import.meta).run()` would deadlock (the await suspends the module before the self-import can resolve). When another module imports this file, the `.run()` call is a no-op, so the exported command functions remain importable as plain functions. -- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`) or references to a `type X = {...}`/`interface X {...}` declared in the same file (intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags). Functions with no parameters become commands with no arguments. +- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`) or references to a `type X = {...}`/`interface X {...}` declared in the same file. Interface `extends` clauses and intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags when possible. Functions with no parameters become commands with no arguments. - Only the *last* parameter can be an object type (it maps to flags); the others must be strings, numbers, booleans, or arrays of those (a `files: string[]` parameter becomes a variadic positional). Rest parameters and destructured positional parameters aren't supported. -- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export * as group from './group'`, and `export * from './group'`. The module must export *only* commands: any other exported function - an `export {f}` statement, a re-export form other than `export *`/`export * as`, or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module. +- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export class Group { method(...) {} }`, `export * as group from './group'`, and `export * from './group'`. The module must export *only* commands: any other exported function - an `export {f}` statement, a re-export form other than `export *`/`export * as`, or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module. - Overloaded functions work, with a simple rule: only the *first* overload signature is used; later overloads and the implementation signature are ignored. TypeScript resolves calls against overload signatures in order, so the first one is the primary documented shape - and a CLI can only present one calling convention. - For bundlers/browsers (no filesystem, no dynamic import), pass the source and exports explicitly: `createCli({source: rawSourceText, exports: await import('./commands.js')})`. Re-exported command modules are not supported in this form because trpc-cli has no filesystem location to resolve child modules from. - In this mode `run`/`buildProgram`/`toJSON` are all **async** (the module is loaded asynchronously): `const program = await createCli(import.meta).buildProgram()`. With a router, `buildProgram`/`toJSON` are synchronous. diff --git a/src/module-commands.ts b/src/module-commands.ts index 5be7062d..c0641cf1 100644 --- a/src/module-commands.ts +++ b/src/module-commands.ts @@ -1,20 +1,22 @@ /** - * @experimental Derive a CLI from a plain TypeScript module of exported functions - no schema library, no router. + * @experimental Derive a CLI from a plain TypeScript module of exported functions/classes - no schema library, no router. * * Runtime functions carry no type information, so this works from two inputs: the module's *source text* (to extract - * each exported function's parameter types and jsdoc) and its *live exports* (to actually call the functions). + * each exported function/class method's parameter types and jsdoc) and its *live exports* (to actually call the functions). * The extracted parameter type text is handed to the vendored `Type.Script` (see ./typebox), which turns it into a * JSON Schema - including jsdoc comments as property descriptions - with a `~standard` validator attached. Each * function becomes a norpc procedure, so the rest of trpc-cli treats the module like any other router: leading * scalar parameters become positional arguments and a trailing object-literal parameter becomes flags (the same - * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. + * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. Exported classes + * with no base class and no constructor arguments become nested command groups whose public instance methods are + * invoked on a fresh class instance only when the command runs. * File-backed modules can re-export other command modules: `export * as group from './group'` creates a nested * router, and `export * from './group'` merges named child commands into the current router. * * The source "parser" here is deliberately a lightweight hand-rolled extractor, not the TypeScript compiler API: - * it only needs to find exported function declarations, the jsdoc immediately preceding them, and each parameter's - * name + balanced `{...}` (or named-reference) type annotation text. The heavy lifting - turning type syntax into - * JSON Schema - is all `Type.Script`. + * it only needs to find exported function/class method declarations, the jsdoc immediately preceding them, and each + * parameter's name + balanced `{...}` (or named-reference) type annotation text. The heavy lifting - turning type + * syntax into JSON Schema - is all `Type.Script`. */ import {t} from './norpc.js' import {NorpcProcedureLike, NorpcRouterLike} from './parse-router.js' @@ -68,6 +70,12 @@ export interface ExtractedCommand { params: ExtractedParam[] } +export interface ExtractedClass { + /** the export name, e.g. `Users` - becomes the (kebab-cased) command group name */ + name: string + methods: ExtractedCommand[] +} + /** A parameter extracted from a function declaration's parameter list. */ export interface ExtractedParam { /** the parameter name, e.g. `left` - becomes the (kebab-cased) positional argument name. Undefined for destructured patterns like `{force}` */ @@ -221,6 +229,7 @@ const buildRouterFromFileModule = async ( const buildLocalProcedures = (resolved: SourceCliModule) => { const {source, exports} = resolved const commands = extractModuleCommands(source) + const classes = extractModuleClasses(source) const context = buildDeclarationContext(source) const procedures: Record = {} @@ -231,6 +240,29 @@ const buildLocalProcedures = (resolved: SourceCliModule) => { if (typeof fn !== 'function') continue // e.g. `export const x = (2 + 3)` - extractor can match non-functions; runtime is the source of truth procedures[command.name] = buildProcedure(command, fn as AnyFn, context) } + for (const extractedClass of classes) { + claimedExportNames.add(extractedClass.name) + const ClassCtor = exports[extractedClass.name] + if (typeof ClassCtor !== 'function') continue + if (ClassCtor.length > 0) { + throw new Error( + `Exported class "${extractedClass.name}" has a constructor with parameters, which isn't supported for module-mode command groups. ` + + `Class command groups must have no constructor arguments.`, + ) + } + const childProcedures: Record = {} + for (const method of extractedClass.methods) { + childProcedures[method.name] = buildProcedure( + method, + (...args: unknown[]) => { + const instance = new (ClassCtor as new () => Record)() + return (instance[method.name] as (...args: unknown[]) => unknown)(...args) + }, + context, + ) + } + procedures[extractedClass.name] = t.router(childProcedures) + } return {procedures, claimedExportNames} } @@ -273,9 +305,11 @@ const addProcedureOrRouter = ( } const buildProcedure = (command: ExtractedCommand, fn: AnyFn, context: Record): NorpcProcedureLike => { + const commandDoc = parseCliJsdoc(command.description) const meta = { - ...(command.description ? {description: command.description} : {}), + ...(commandDoc.description ? {description: commandDoc.description} : {}), ...(command.default ? {default: true} : {}), + ...(commandDoc.aliases.length > 0 ? {aliases: {command: commandDoc.aliases}} : {}), } const builder = Object.keys(meta).length > 0 ? t.procedure.meta(meta) : t.procedure if (command.params.length === 0) { @@ -361,7 +395,8 @@ const buildPositionalProcedure = ( positionalParams.forEach((param, i) => { const item = schema.items![i] as Record item.title = kebabCase(param.name!) - if (param.description) item.description = param.description + const paramDoc = parseCliJsdoc(param.description) + if (paramDoc.description) item.description = paramDoc.description if (cliOptional[i]) item.optional = true }) if (lastIsFlagsObject) { @@ -371,6 +406,7 @@ const buildPositionalProcedure = ( } schema.minItems = cliOptional.includes(true) ? cliOptional.indexOf(true) : params.length + applySchemaJsdocMetadata(schema) return builder.input(schema as never).handler(({input}) => fn(...(input as unknown[]))) } @@ -394,7 +430,7 @@ const parseParamSchema = (commandName: string, param: ExtractedParam, context: R `Declare it as \`type X = {...}\` or \`interface X {...}\` in the same file, or inline the type.`, ) } - return flattenIntersection(schema) + return applySchemaJsdocMetadata(flattenIntersection(schema)) } /** @@ -611,6 +647,38 @@ const cleanJsdoc = (text: string): string | undefined => { return cleaned || undefined } +const parseCliJsdoc = (description: string | undefined): {description: string | undefined; aliases: string[]} => { + if (!description) return {description: undefined, aliases: []} + const aliases: string[] = [] + const lines: string[] = [] + for (const line of description.split('\n')) { + const match = line.trim().match(/^@alias\s+(.+)$/) + if (match) { + aliases.push(match[1].trim()) + continue + } + lines.push(line) + } + const cleaned = lines.join('\n').trim() + return {description: cleaned || undefined, aliases} +} + +const applySchemaJsdocMetadata = (schema: unknown): unknown => { + if (!schema || typeof schema !== 'object') return schema + const record = schema as Record + if (typeof record.description === 'string') { + const doc = parseCliJsdoc(record.description) + if (doc.description) record.description = doc.description + else delete record.description + if (doc.aliases.length > 0) record.alias = doc.aliases[0] + } + for (const value of Object.values(record)) { + if (Array.isArray(value)) value.forEach(applySchemaJsdocMetadata) + else applySchemaJsdocMetadata(value) + } + return schema +} + // ------------------------------------------------------------------ // Command extraction // ------------------------------------------------------------------ @@ -719,6 +787,134 @@ export const extractModuleCommands = (source: string): ExtractedCommand[] => { })) } +export const extractModuleClasses = (source: string): ExtractedClass[] => { + const scan = scanSource(source) + const classes: ExtractedClass[] = [] + + for (const match of source.matchAll(/(?') + const braceIndex = findNextUnmasked(source, scan, headerIndex, '{') + const header = source.slice(headerIndex, braceIndex) + if (/\bextends\b/.test(header)) { + throw new Error( + `Exported class "${name}" extends another class, which isn't supported for module-mode command groups. ` + + `Class command groups must have no base class.`, + ) + } + + const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') + const methods = extractClassMethods(source, scan, name, braceIndex + 1, classEnd - 1) + if (methods.length === 0) { + throw new Error( + `Exported class "${name}" has no command methods. Class command groups must declare at least one public instance method.`, + ) + } + classes.push({name, methods}) + } + + return classes +} + +const findNextUnmasked = (source: string, scan: SourceScan, start: number, char: string): number => { + for (let i = start; i < source.length; i++) { + if (!scan.masked[i] && source[i] === char) return i + } + throw new Error(`Could not find \`${char}\` after index ${start} of module source`) +} + +const extractClassMethods = ( + source: string, + scan: SourceScan, + className: string, + bodyStart: number, + bodyEnd: number, +): ExtractedCommand[] => { + const body = source.slice(bodyStart, bodyEnd) + const declarations: Array<{ + name: string + position: number + hasBody: boolean + paramList: string + description: string | undefined + }> = [] + + const pattern = /(?') + while (parenIndex < source.length && /\s/.test(source[parenIndex])) parenIndex++ + if (source[parenIndex] !== '(') continue + + const parenEnd = findBalancedEnd(source, scan, parenIndex, '(', ')') + const paramList = source.slice(parenIndex + 1, parenEnd - 1) + if (name === 'constructor') { + if (paramList.trim()) { + throw new Error( + `Exported class "${className}" has a constructor with parameters, which isn't supported for module-mode command groups. ` + + `Class command groups must have no constructor arguments.`, + ) + } + continue + } + + declarations.push({ + name, + position: absoluteIndex, + hasBody: hasFunctionBody(source, scan, parenEnd), + paramList, + description: jsdocBefore(source, scan, absoluteIndex), + }) + } + + declarations.sort((a, b) => a.position - b.position) + const winners = new Map() + for (const declaration of declarations) { + const existing = winners.get(declaration.name) + if (!existing || (existing.hasBody && !declaration.hasBody)) winners.set(declaration.name, declaration) + } + + return [...winners.values()].map(({name, description, paramList}) => ({ + name, + exportName: name, + default: false, + description, + params: parseParams(`${className}.${name}`, paramList), + })) +} + +const isTopLevelClassMember = (source: string, scan: SourceScan, bodyStart: number, index: number): boolean => { + let depth = 0 + for (let i = bodyStart; i < index; i++) { + if (scan.masked[i]) continue + if (source[i] === '{') depth++ + else if (source[i] === '}') depth-- + } + return depth === 0 +} + /** * Determine whether a `function` declaration whose parameter list closes at `parenEnd` has a `{...}` body, or is a * body-less TS overload signature (`export function f(...): R` ending at a newline or semicolon). An optional diff --git a/src/types.ts b/src/types.ts index b545b549..f8b2ed5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,14 +34,16 @@ export interface TrpcCliParams extends Dependencies { } /** - * @experimental Derive a CLI from a plain TypeScript module of exported functions instead of a router. + * @experimental Derive a CLI from a plain TypeScript module of exported functions/classes instead of a router. * Exported functions become commands: the jsdoc above each function becomes the command description, and the first * parameter's object type annotation (parsed from the module's *source text* via the vendored `trpc-cli/typebox` * `Type.Script`) becomes the input schema - property jsdoc comments become flag descriptions, and inputs are - * validated against the schema before the function runs. A default-exported function becomes the default command, - * equivalent to `{default: true}` in procedure meta. In file-backed module mode, `export * as foo from './foo'` - * becomes a nested sub-router named `foo`, and `export * from './foo'` merges that module's named commands into the - * current router level. + * validated against the schema before the function runs. `@alias` tags in command/property jsdoc become command and + * option aliases. A default-exported function becomes the default command, equivalent to `{default: true}` in + * procedure meta. Exported classes with no base class and no constructor arguments become nested command groups; + * their public instance methods are lazily invoked on a fresh class instance. In file-backed module mode, + * `export * as foo from './foo'` becomes a nested sub-router named `foo`, and `export * from './foo'` merges that + * module's named commands into the current router level. * * `import.meta` satisfies this shape (it carries `filename`/`url`), so the simplest setup is to call `createCli` * from the bottom of the commands file itself: diff --git a/tasks/module-mode-next.interview.md b/tasks/complete/2026-06-19-module-mode-next.interview.md similarity index 100% rename from tasks/module-mode-next.interview.md rename to tasks/complete/2026-06-19-module-mode-next.interview.md diff --git a/tasks/module-mode-next.md b/tasks/complete/2026-06-19-module-mode-next.md similarity index 89% rename from tasks/module-mode-next.md rename to tasks/complete/2026-06-19-module-mode-next.md index 265dabb0..22f39e58 100644 --- a/tasks/module-mode-next.md +++ b/tasks/complete/2026-06-19-module-mode-next.md @@ -1,11 +1,11 @@ --- -status: proposal +status: complete size: medium --- # Module Mode Next -Status summary: proposal ready for review. The grill pass resolved the requested module-mode questions, then follow-up decisions switched same-file subcommands to class groups only and moved explicit norpc exports out of scope for a separate change. The scoped path is now: document existing type/overload behavior, add JSDoc aliases, and add lazy-instantiated class subcommand groups. No implementation has been done yet. +Status summary: implemented and verified. The scoped work documents current type/overload behavior, adds JSDoc aliases for module-mode commands/options, and adds lazy-instantiated class subcommand groups. Exported norpc procedures remain a separate follow-up. ## User Ask @@ -172,6 +172,9 @@ Default behavior for explicit norpc exports should be handled in the separate ex - [x] Propose support rules for aliases, subcommands, and procedure-like exports. _Captured above under the feature-specific proposal sections._ - [x] Capture open risks, tradeoffs, and follow-up implementation slices. _Captured in "Suggested Implementation Slices", "Guesses And Assumptions", and "Out Of Scope"._ - [x] Open a draft PR for review. _Opened as #211._ +- [x] Implement JSDoc `@alias` support. _Implemented in `src/module-commands.ts` by stripping `@alias` from JSDoc descriptions and mapping tags onto existing command/option alias metadata._ +- [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no base class and no constructor args become nested routers, and method handlers instantiate a fresh class instance only when invoked._ +- [x] Update docs and tests. _README module-mode docs updated; behavior covered in `test/typebox-module-commands.test.ts`._ ## Implementation Notes @@ -180,3 +183,4 @@ Default behavior for explicit norpc exports should be handled in the separate ex - 2026-06-19: Quick local probe against built `dist` confirmed same-file extended interfaces, multiple interface extends, and alias-to-alias intersections currently derive flags as expected. - 2026-06-19: Follow-up user decision replaced object-literal groups with class groups only, scoped to no base class/no constructor args and lazy instantiation. - 2026-06-19: Follow-up user decision moved support for exported norpc procedures/routers out of scope for this proposal and into a separate change. +- 2026-06-19: Implemented the scoped feature set. `pnpm exec vitest run test/typebox-module-commands.test.ts`, `pnpm compile`, and `pnpm test` pass. `pnpm lint` is blocked only by the pre-existing unstaged `test/zod4.test.ts` unused-disable warning. diff --git a/test/typebox-module-commands.test.ts b/test/typebox-module-commands.test.ts index 5a4c5823..2ee06e76 100644 --- a/test/typebox-module-commands.test.ts +++ b/test/typebox-module-commands.test.ts @@ -487,6 +487,41 @@ test('module commands: intersection and multi-line union type aliases keep their await expect(runWith(params, ['configure', '--mode', 'fast'])).rejects.toThrowError(/extra/) }) +test('module commands: same-file extended interfaces and alias intersections derive flags', async () => { + const params = { + source: ` + interface Common { + root: string + } + interface Named { + name: string + } + interface Options extends Common, Named { + tag: string + } + type Extra = Options & { + verbose?: boolean + } + + export function deploy(options: Extra) { + return options.root + ':' + options.name + ':' + options.tag + ':' + String(options.verbose || false) + } + `, + exports: { + deploy: (options: any) => `${options.root}:${options.name}:${options.tag}:${String(options.verbose || false)}`, + }, + } + + const help = await runWith(params, ['deploy', '--help']) + expect(help).toContain('--root ') + expect(help).toContain('--name ') + expect(help).toContain('--tag ') + expect(help).toContain('--verbose [boolean]') + expect( + await runWith(params, ['deploy', '--root', 'prod', '--name', 'api', '--tag', 'v1', '--verbose']), + ).toMatchInlineSnapshot(`"prod:api:v1:true"`) +}) + test('module commands: generic type parameters containing => are skipped correctly', async () => { const params = { // without the => exception in findBalancedEnd, the `>` of `() => void` would close the generic @@ -516,6 +551,36 @@ test('module commands: jsdoc still attaches when a line comment sits between it expect(await runWith(params, ['--help'])).toContain('does the thing') }) +test('module commands: jsdoc aliases create command and option aliases without leaking into help', async () => { + const params = { + source: ` + /** + * install dependencies + * @alias i + */ + export function install(options: { + /** fail if the lockfile changed + * @alias f + */ + frozenLockfile?: boolean + }) { + return options.frozenLockfile ? 'frozen' : 'normal' + } + `, + exports: {install: (options: any) => (options.frozenLockfile ? 'frozen' : 'normal')}, + } + + const rootHelp = await runWith(params, ['--help']) + expect(rootHelp).toContain('install dependencies') + expect(rootHelp).not.toContain('@alias') + expect(await runWith(params, ['i', '-f'])).toMatchInlineSnapshot(`"frozen"`) + + const installHelp = await runWith(params, ['install', '--help']) + expect(installHelp).toContain('-f, --frozen-lockfile') + expect(installHelp).toContain('fail if the lockfile changed') + expect(installHelp).not.toContain('@alias') +}) + test('module commands: union-of-objects parameter derives union flags', async () => { const params = { // regression (caught in review): the flags-object decision briefly only accepted plain objects, @@ -666,6 +731,162 @@ test('module commands: type-annotated const declarations error with parseable-de ) }) +test('module commands: exported classes create lazily-instantiated command groups', async () => { + let constructed = 0 + class Users { + constructor() { + constructed++ + } + + invite(options: {email: string}) { + this.#audit() + return `invite ${options.email}` + } + + #audit() { + return 'audited' + } + } + + const params = { + source: ` + export class Users { + constructor() {} + + /** invite a user + * @alias i + */ + invite(options: { + /** address to invite */ + email: string + }) { + this.#audit() + return 'invite ' + options.email + } + + #audit() { + return 'audited' + } + } + `, + exports: {Users}, + } + + const rootHelp = await runWith(params, ['--help']) + expect(rootHelp).toContain('users') + expect(constructed).toBe(0) + + const inviteHelp = await runWith(params, ['users', 'invite', '--help']) + expect(inviteHelp).toContain('invite a user') + expect(inviteHelp).toContain('--email ') + expect(inviteHelp).toContain('address to invite') + expect(inviteHelp).not.toContain('@alias') + expect(inviteHelp).not.toContain('audit') + expect(constructed).toBe(0) + + expect(await runWith(params, ['users', 'invite', '--email', 'ada@example.com'])).toMatchInlineSnapshot( + `"invite ada@example.com"`, + ) + expect(constructed).toBe(1) + expect(await runWith(params, ['users', 'i', '--email', 'grace@example.com'])).toMatchInlineSnapshot( + `"invite grace@example.com"`, + ) + expect(constructed).toBe(2) +}) + +test('module commands: class groups reject inheritance, constructor parameters, and static-only command shapes', async () => { + await expect( + runWith( + { + source: ` + class Base {} + export class Users extends Base { + invite(options: {email: string}) { + return options.email + } + } + `, + exports: { + Users: class Users { + invite(options: any) { + return options.email + } + }, + }, + }, + ['--help'], + ), + ).rejects.toThrowError(/must have no base class/) + + await expect( + runWith( + { + source: ` + export class Users { + constructor(config: {dryRun?: boolean}) {} + + invite(options: {email: string}) { + return options.email + } + } + `, + exports: { + Users: class Users { + constructor(config: any) { + void config + } + + invite(options: any) { + return options.email + } + }, + }, + }, + ['--help'], + ), + ).rejects.toThrowError(/must have no constructor arguments/) + + await expect( + runWith( + { + source: ` + export class Users { + static invite(options: {email: string}) { + return options.email + } + } + `, + exports: { + Users: function Users() {}, + }, + }, + ['--help'], + ), + ).rejects.toThrowError(/has no command methods/) + + await expect( + runWith( + { + source: ` + export class Users { + get invite() { + return 'not a command' + } + } + `, + exports: { + Users: class Users { + get invite() { + return 'not a command' + } + }, + }, + }, + ['--help'], + ), + ).rejects.toThrowError(/has no command methods/) +}) + test('module positionals: destructured trailing options object works', async () => { const params = { // destructuring is only rejected for *positional* params - a trailing flags object may destructure From 0b47d2cb519e7227d3fdc5e6eb138197953dedef Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:22:37 +0100 Subject: [PATCH 06/11] Refine module mode class group rules --- README.md | 9 ++- src/module-commands.ts | 38 ++++++----- src/types.ts | 5 +- .../2026-06-19-module-mode-next.interview.md | 17 +++++ tasks/complete/2026-06-19-module-mode-next.md | 11 ++-- test/typebox-module-commands.test.ts | 64 ++++++++++++++++++- 6 files changed, 118 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6b4f166f..c22bf985 100644 --- a/README.md +++ b/README.md @@ -702,7 +702,14 @@ export class Users { } ``` -Class command groups must be direct `export class Name { ... }` declarations with no `extends` and no constructor arguments (no constructor, or `constructor()`, is fine). Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. +Run it like: + +```console +$ tsx commands.ts users invite --email bob@example.com +invite bob@example.com +``` + +Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Constructor parameters are not supported. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. Multi-parameter functions work too: leading string/number/boolean parameters become positional arguments, and a trailing object parameter becomes flags - the same convention as [tuple inputs](#combining-positional-arguments-and-options): diff --git a/src/module-commands.ts b/src/module-commands.ts index c0641cf1..74f988c0 100644 --- a/src/module-commands.ts +++ b/src/module-commands.ts @@ -8,8 +8,9 @@ * function becomes a norpc procedure, so the rest of trpc-cli treats the module like any other router: leading * scalar parameters become positional arguments and a trailing object-literal parameter becomes flags (the same * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. Exported classes - * with no base class and no constructor arguments become nested command groups whose public instance methods are - * invoked on a fresh class instance only when the command runs. + * with no constructor arguments become nested command groups whose public instance methods are invoked on a fresh + * class instance only when the command runs. Classes with `extends` must declare an explicit zero-argument + * constructor. * File-backed modules can re-export other command modules: `export * as group from './group'` creates a nested * router, and `export * from './group'` merges named child commands into the current router. * @@ -798,15 +799,15 @@ export const extractModuleClasses = (source: string): ExtractedClass[] => { if (source[headerIndex] === '<') headerIndex = findBalancedEnd(source, scan, headerIndex, '<', '>') const braceIndex = findNextUnmasked(source, scan, headerIndex, '{') const header = source.slice(headerIndex, braceIndex) - if (/\bextends\b/.test(header)) { + const hasBaseClass = /\bextends\b/.test(header) + + const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') + const {methods, hasZeroArgConstructor} = extractClassMethods(source, scan, name, braceIndex + 1, classEnd - 1) + if (hasBaseClass && !hasZeroArgConstructor) { throw new Error( - `Exported class "${name}" extends another class, which isn't supported for module-mode command groups. ` + - `Class command groups must have no base class.`, + `Exported class "${name}" extends another class and must declare an explicit zero-argument constructor for module-mode command groups.`, ) } - - const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') - const methods = extractClassMethods(source, scan, name, braceIndex + 1, classEnd - 1) if (methods.length === 0) { throw new Error( `Exported class "${name}" has no command methods. Class command groups must declare at least one public instance method.`, @@ -831,7 +832,7 @@ const extractClassMethods = ( className: string, bodyStart: number, bodyEnd: number, -): ExtractedCommand[] => { +): {methods: ExtractedCommand[]; hasZeroArgConstructor: boolean} => { const body = source.slice(bodyStart, bodyEnd) const declarations: Array<{ name: string @@ -840,6 +841,7 @@ const extractClassMethods = ( paramList: string description: string | undefined }> = [] + let hasZeroArgConstructor = false const pattern = /(? ({ - name, - exportName: name, - default: false, - description, - params: parseParams(`${className}.${name}`, paramList), - })) + return { + hasZeroArgConstructor, + methods: [...winners.values()].map(({name, description, paramList}) => ({ + name, + exportName: name, + default: false, + description, + params: parseParams(`${className}.${name}`, paramList), + })), + } } const isTopLevelClassMember = (source: string, scan: SourceScan, bodyStart: number, index: number): boolean => { diff --git a/src/types.ts b/src/types.ts index f8b2ed5d..619b7a86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,8 +40,9 @@ export interface TrpcCliParams extends Dependencies { * `Type.Script`) becomes the input schema - property jsdoc comments become flag descriptions, and inputs are * validated against the schema before the function runs. `@alias` tags in command/property jsdoc become command and * option aliases. A default-exported function becomes the default command, equivalent to `{default: true}` in - * procedure meta. Exported classes with no base class and no constructor arguments become nested command groups; - * their public instance methods are lazily invoked on a fresh class instance. In file-backed module mode, + * procedure meta. Exported classes become nested command groups when they have no constructor arguments; classes + * with `extends` must declare an explicit zero-argument constructor. Their public instance methods are lazily + * invoked on a fresh class instance. In file-backed module mode, * `export * as foo from './foo'` becomes a nested sub-router named `foo`, and `export * from './foo'` merges that * module's named commands into the current router level. * diff --git a/tasks/complete/2026-06-19-module-mode-next.interview.md b/tasks/complete/2026-06-19-module-mode-next.interview.md index e01dbb61..37242dc6 100644 --- a/tasks/complete/2026-06-19-module-mode-next.interview.md +++ b/tasks/complete/2026-06-19-module-mode-next.interview.md @@ -260,3 +260,20 @@ Updated decision: - Keep a note that explicit norpc exports are probably the right future schema/procedure escape hatch. - Do not include `os.input(...).handler(...)`, `t.procedure...`, or `os.router(...)` support in the first implementation scope. - This proposal should focus on documenting current type/overload behavior, JSDoc aliases, and class groups. + +--- + +## Implementation Follow-Up — private methods and explicit inherited constructors — 2026-06-19 + +The user asked for two class-group refinements during implementation: + +- TypeScript `private` methods must not become commands. +- `extends` can be allowed when the exported class declares an explicit zero-argument constructor. + +Updated decision: + +- Public instance methods declared directly in the exported class are commands. +- `private`/`protected` methods, ECMAScript `#private` methods, static methods, getters, setters, and inherited methods are not commands. +- A class with no base class may omit its constructor. +- A class with `extends` must declare `constructor()`. +- Constructor parameters remain unsupported. diff --git a/tasks/complete/2026-06-19-module-mode-next.md b/tasks/complete/2026-06-19-module-mode-next.md index 22f39e58..fbe63322 100644 --- a/tasks/complete/2026-06-19-module-mode-next.md +++ b/tasks/complete/2026-06-19-module-mode-next.md @@ -90,8 +90,8 @@ This maps to `mycli users invite` and `mycli users deactivate`. Rules: - Only direct `export class Users { ... }` declarations are candidates. -- No `extends`; base classes are rejected. -- The class must have no constructor, or exactly a zero-argument constructor. +- Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. +- Constructor parameters are rejected. - Public instance method declarations directly in the class body become commands. - Private/protected methods and private fields are internal implementation details, not commands. - Static methods are not commands in the first slice. @@ -148,7 +148,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - Same-file `Type.Script`-parseable support is the right boundary because it matches the project's preference for loud errors and small pragmatic mechanisms over building a TypeScript compiler. - JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. - First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. -- Class groups are acceptable when constrained to no base class, no constructor arguments, public instance methods only, and lazy per-invocation instantiation. +- Class groups are acceptable when constrained to no constructor arguments, public instance methods only, and lazy per-invocation instantiation. Inheritance is acceptable when the class explicitly declares a zero-argument constructor. - Explicit norpc exports are probably the right schema escape hatch, but they belong in a separate change because they introduce runtime-export composition beyond plain source-scanned commands. - Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data. - Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut in this proposal. @@ -159,7 +159,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - Switching module mode to the TypeScript compiler API. - Imported type resolution. - Object-literal command groups. -- Class groups with inheritance or constructor arguments. +- Class groups with constructor arguments. - Explicit norpc procedure/router exports. - Mixed tRPC/oRPC/norpc export trees. - Overload merging or overload-selection metadata. @@ -173,7 +173,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - [x] Capture open risks, tradeoffs, and follow-up implementation slices. _Captured in "Suggested Implementation Slices", "Guesses And Assumptions", and "Out Of Scope"._ - [x] Open a draft PR for review. _Opened as #211._ - [x] Implement JSDoc `@alias` support. _Implemented in `src/module-commands.ts` by stripping `@alias` from JSDoc descriptions and mapping tags onto existing command/option alias metadata._ -- [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no base class and no constructor args become nested routers, and method handlers instantiate a fresh class instance only when invoked._ +- [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no constructor args become nested routers, inherited classes require an explicit zero-argument constructor, and method handlers instantiate a fresh class instance only when invoked._ - [x] Update docs and tests. _README module-mode docs updated; behavior covered in `test/typebox-module-commands.test.ts`._ ## Implementation Notes @@ -184,3 +184,4 @@ Default behavior for explicit norpc exports should be handled in the separate ex - 2026-06-19: Follow-up user decision replaced object-literal groups with class groups only, scoped to no base class/no constructor args and lazy instantiation. - 2026-06-19: Follow-up user decision moved support for exported norpc procedures/routers out of scope for this proposal and into a separate change. - 2026-06-19: Implemented the scoped feature set. `pnpm exec vitest run test/typebox-module-commands.test.ts`, `pnpm compile`, and `pnpm test` pass. `pnpm lint` is blocked only by the pre-existing unstaged `test/zod4.test.ts` unused-disable warning. +- 2026-06-19: Follow-up user decision allowed `extends` when the class declares an explicit zero-argument constructor, and added coverage that TypeScript `private` methods are not commands. diff --git a/test/typebox-module-commands.test.ts b/test/typebox-module-commands.test.ts index 2ee06e76..11fcaa5a 100644 --- a/test/typebox-module-commands.test.ts +++ b/test/typebox-module-commands.test.ts @@ -743,6 +743,15 @@ test('module commands: exported classes create lazily-instantiated command group return `invite ${options.email}` } + private getOrCreate(email: string) { + return {email} + } + + login(email: string, password: string) { + const user = this.getOrCreate(email) + return `login ${user.email} with ${password.length} chars` + } + #audit() { return 'audited' } @@ -764,6 +773,15 @@ test('module commands: exported classes create lazily-instantiated command group return 'invite ' + options.email } + private getOrCreate(email: string) { + return {email} + } + + login(email: string, password: string) { + const user = this.getOrCreate(email) + return 'login ' + user.email + ' with ' + password.length + ' chars' + } + #audit() { return 'audited' } @@ -782,6 +800,7 @@ test('module commands: exported classes create lazily-instantiated command group expect(inviteHelp).toContain('address to invite') expect(inviteHelp).not.toContain('@alias') expect(inviteHelp).not.toContain('audit') + expect(inviteHelp).not.toContain('get-or-create') expect(constructed).toBe(0) expect(await runWith(params, ['users', 'invite', '--email', 'ada@example.com'])).toMatchInlineSnapshot( @@ -792,9 +811,13 @@ test('module commands: exported classes create lazily-instantiated command group `"invite grace@example.com"`, ) expect(constructed).toBe(2) + expect(await runWith(params, ['users', 'login', 'ada@example.com', 's3cr3t'])).toMatchInlineSnapshot( + `"login ada@example.com with 6 chars"`, + ) + expect(constructed).toBe(3) }) -test('module commands: class groups reject inheritance, constructor parameters, and static-only command shapes', async () => { +test('module commands: class groups allow inheritance only with an explicit zero-arg constructor', async () => { await expect( runWith( { @@ -816,8 +839,45 @@ test('module commands: class groups reject inheritance, constructor parameters, }, ['--help'], ), - ).rejects.toThrowError(/must have no base class/) + ).rejects.toThrowError(/must declare an explicit zero-argument constructor/) + + class Base { + protected prefix = 'base' + } + class Users extends Base { + constructor() { + super() + } + + invite(options: {email: string}) { + return `${this.prefix}:${options.email}` + } + } + expect( + await runWith( + { + source: ` + class Base { + protected prefix = 'base' + } + export class Users extends Base { + constructor() { + super() + } + + invite(options: {email: string}) { + return this.prefix + ':' + options.email + } + } + `, + exports: {Users}, + }, + ['users', 'invite', '--email', 'ada@example.com'], + ), + ).toMatchInlineSnapshot(`"base:ada@example.com"`) +}) +test('module commands: class groups reject constructor parameters and static-only command shapes', async () => { await expect( runWith( { From 760faeec5c711d7c5d924484990fd7a2ff224a26 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:27:45 +0100 Subject: [PATCH 07/11] Ignore interview task notes --- .gitignore | 2 + .../2026-06-19-module-mode-next.interview.md | 279 ------------------ 2 files changed, 2 insertions(+), 279 deletions(-) delete mode 100644 tasks/complete/2026-06-19-module-mode-next.interview.md diff --git a/.gitignore b/.gitignore index 56d750ab..76413f17 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist *ignoreme* *.log session-ses_*.md +tasks/*interview* +tasks/**/*interview* diff --git a/tasks/complete/2026-06-19-module-mode-next.interview.md b/tasks/complete/2026-06-19-module-mode-next.interview.md deleted file mode 100644 index 37242dc6..00000000 --- a/tasks/complete/2026-06-19-module-mode-next.interview.md +++ /dev/null @@ -1,279 +0,0 @@ -# module-mode-next Grill Interview - -## Setup Note — local claude auth failure — 2026-06-19 - -The literal `claude --print` path from the `grill-you` skill failed with `401 Invalid authentication credentials` before producing a question. The interview continued via the platform multi-agent tool with the same dossier. - ---- - -## Q1 — type support boundary — 2026-06-19 - -Codebase answer: same-file `interface extends`, multiple `extends`, and object intersections already work through `Type.Script` plus local declaration context. Imported type references still fail loudly, by design. - -**Question 1:** Should the proposal explicitly define module-mode type support as “same-file, `Type.Script`-parseable declarations only,” rather than trying to add TypeScript compiler/imported-type resolution? - -**Recommended answer:** Yes. Document that module mode supports inline types, same-file `type`/`interface`, interface `extends`, object intersections, unions of object options, and first-overload signature extraction. Imported types, complex generics, and unparseable declarations should remain startup errors. Users who need richer imported schemas should opt into explicit procedure/schema exports instead of expecting plain-function scanning to become a compiler. - -## A1 — 2026-06-19 - -Yes. Keep the plain-function scanner deliberately bounded to same-file, Type.Script-parseable declarations. The proposal should distinguish "already supported and should be documented" from "not a goal for plain function scanning". - -Specific current support to document: inline parameter types, same-file type aliases/interfaces, interface extends including multiple bases, object intersections flattened into flags, union-of-object option shapes, scalar/literal-union positionals, and first overload signature extraction. - -Imported types, arbitrary compiler resolution, type-annotated const function aliases, complex generics beyond what Type.Script can parse, and declarations that depend on external type context should keep failing loudly. [guess: this matches the owner's preference for loud startup errors and small pragmatic mechanisms over building a compiler.] The escape hatch for richer typing should be explicit runtime schema/procedure exports. - ---- - -## Q2 — aliases in plain-function module mode — 2026-06-19 - -Codebase answer: aliases already exist for router/procedure mode through `meta.aliases.command` and `meta.aliases.options`, plus schema-level property `alias`. Plain-function module mode only reads function JSDoc as descriptions and type syntax as schemas; it has no metadata channel. - -**Question 2:** Should plain-function module mode get aliases through JSDoc tags, or should aliases require opting into explicit procedure/schema exports? - -**Recommended answer:** Use JSDoc tags for the bounded plain-function case, and keep explicit procedures as the richer escape hatch. Proposed syntax: - -```ts -/** - * install deps - * @alias i - * @alias add - */ -export function install(options: { - /** fail if lockfile changed - * @alias f - */ - frozenLockfile?: boolean -}) {} -``` - -Rules: `@alias` above a command becomes `meta.aliases.command`; `@alias` on an option property becomes the option alias; aliases validate against the derived command/options and fail at startup on typos/conflicts. Do not add wrapper helpers or static metadata properties for plain functions, because those make the zero-boilerplate surface more magical than necessary and overlap with explicit procedure exports. - -## A2 — 2026-06-19 - -Yes, use JSDoc tags for aliases in the plain-function surface. - -Rules I want in the proposal: - -- Command JSDoc supports repeated `@alias ` tags, mapped to `meta.aliases.command`. -- Option/property JSDoc supports one `@alias ` tag, mapped onto the property schema's existing `alias` field or equivalent `meta.aliases.options` path. -- Alias tags should be stripped from help descriptions so `--help` stays prose, not raw metadata. -- Invalid aliases should fail at startup or command build with the same strictness as existing alias meta. For property aliases, reuse existing invalid-option-alias checks where possible. -- Do not invent wrapper helpers or static side-channel metadata for plain functions in this proposal. If someone needs richer command metadata, explicit procedure exports should cover that path. - -[guess: JSDoc is the least-bad metadata channel because module mode already treats source comments as user-facing CLI docs, and aliases are CLI docs/ergonomics rather than runtime behavior.] - ---- - -## Q3 — overload semantics — 2026-06-19 - -Codebase answer: overloads already work by taking the first overload signature and ignoring later signatures plus the implementation. Tests pin this for object options and multi-parameter positionals. - -**Question 3:** Should the proposal keep overload handling exactly as “first overload signature is the CLI contract,” including failing if that first signature is unparseable, rather than merging overloads or adding selection metadata? - -**Recommended answer:** Yes. Document it as intentional. A CLI command has one help shape and one validation schema; merging overloads would either reject valid runtime calls or advertise combinations no overload actually accepts. If users want a different CLI shape, they should reorder overloads, export a wrapper function with the desired signature, or use an explicit procedure/schema export. - -## A3 — 2026-06-19 - -Yes. Keep first overload signature as the CLI contract and document it as intentional current behavior. - -No overload merging, no `@cliOverload`, no selection metadata in this proposal. If the first signature cannot be parsed, fail loudly. If a user wants a different command shape, they can reorder overloads, export a CLI-specific wrapper with a plain parseable signature, or export an explicit schema/procedure command. - -[guess: avoiding overload merging is important because Commander help/validation needs one concrete public invocation shape, and trpc-cli should not pretend a TypeScript overload set is one CLI grammar.] - ---- - -## Q4 — same-file subcommands and class exports — 2026-06-19 - -Codebase answer: today `export class Whatever {}` fails as an unmatched exported function because classes are `typeof value === 'function'` at runtime, but the scanner only recognizes exported function declarations/arrow consts. Same-file nested plain-function groups are not supported; nested commands currently come from `export * as group from './child'` or from router/procedure trees. - -**Question 4:** Should same-file subcommand grouping use exported classes, or should the proposal reject classes and prefer object/router-shaped exports? - -**Recommended answer:** Reject class exports as plain-function subcommands for this proposal. Classes create too many hidden policy choices: static vs instance methods, constructor arguments, per-invocation lifecycle, inherited methods, private state, and whether class export names are commands or dependency containers. Keep `export class` failing loudly unless it is part of an explicit procedure/router export path later. - -For same-file grouping, prefer explicit runtime grouping in Phase 2: - -```ts -export const users = { - invite(options: {email: string}) {}, - deactivate(options: {id: string}) {}, -} -``` - -That would map to `mycli users invite`, but only if the proposal accepts extending the source scanner to parse exported object-literal methods with the same plain-function rules. For richer cases, users should use `export const users = t.router({...})` or file-backed `export * as users from './users.ts'`. - -## A4 — 2026-06-19 - -Reject class exports for this proposal. Keep `export class Whatever {}` as an actionable startup error, not a subcommand source. - -The same-file grouping proposal should be exported object literals of command functions/methods: - -```ts -export const users = { - /** invite a user */ - invite(options: {email: string}) {}, - async deactivate(options: {id: string}) {}, -} -``` - -That should map to `mycli users invite` and `mycli users deactivate`, using the same plain-function extraction rules for method JSDoc, parameter parsing, aliases, overload policy where applicable, and validation. Keep file-backed `export * as users from './users.ts'` as the preferred way to split larger groups across files. - -For richer grouping, support explicit runtime routers/procedures: `export const users = t.router({...})` or ORPC/trpc router-shaped values if practical. Do not instantiate classes, do not inspect prototype methods, do not support constructor injection, and do not try to infer lifecycle from classes. [guess: class syntax looks attractive but would create too many unspoken runtime policies for a feature whose selling point is obvious function-to-command mapping.] - ---- - -## Q5 — explicit schema/procedure exports — 2026-06-19 - -Codebase answer: module mode currently builds one norpc router from source-scanned plain functions. Router mode can parse tRPC routers, oRPC routers, and norpc routers, but `parseRouter` chooses one router kind at the root; it does not support a mixed tree containing norpc procedures beside oRPC/tRPC procedures. Exported norpc procedures are runtime objects, so module mode currently ignores them and may end with “No commands found.” - -**Question 5:** For explicit schema/procedure exports in module mode, should Phase 2 support only `trpc-cli`’s own `t`/`os` norpc procedures and routers first, leaving exported tRPC/oRPC procedures/routers as later work? - -**Recommended answer:** Yes. Start with norpc because it is already the internal representation module mode generates, uses `TrpcCliMeta` directly, and can mix naturally with plain functions in one norpc router: - -```ts -export function plain(options: {name: string}) {} - -export const explicit = os - .input(z.object({name: z.string()})) - .meta({aliases: {command: ['x']}}) - .handler(({input}) => input.name) - -export const users = os.router({ - invite: os.input(z.object({email: z.string()})).handler(...) -}) -``` - -Treat norpc procedure exports as commands at their export name, norpc router exports as subcommand groups, and detect conflicts with scanned plain commands. Document tRPC/oRPC explicit module exports as future work unless the implementation finds a low-risk way to normalize them into norpc without weakening the current root-router-kind guards. - -## A5 — 2026-06-19 - -Yes. First explicit-procedure slice should support trpc-cli's own norpc values only: `os.input(...).handler(...)`, `t.procedure.input(...).query(...)`, and `os.router(...)`/`t.router(...)` values. - -That covers zod/standard-schema on selected commands because the explicit procedure carries a runtime Standard Schema, metadata, middleware, aliases, jsonInput, etc. It also composes naturally with generated plain-function procedures because module mode already produces a norpc router internally. - -Rules for the proposal: - -- A runtime export that is `isNorpcProcedure` becomes a command named after the export. -- A runtime export that is `isNorpcRouter` becomes a subcommand group named after the export. -- These can coexist with source-scanned plain functions and proposed object-literal groups. -- Conflicts between generated commands, explicit procedures, explicit routers, and re-exported children fail loudly. -- If the source scanner sees an exported const whose runtime value is a norpc procedure/router, it should not require a parseable function declaration for it. -- Actual tRPC/oRPC procedure/router exports are future work. They have different root parsing/calling requirements and should not be half-supported in the first pass. - -[guess: supporting norpc first gives the user-requested standard-schema/zod escape hatch while keeping the module-mode output as one coherent norpc router.] - ---- - -## Q6 — object-literal group gating — 2026-06-19 - -Codebase answer: non-function exports are currently ignored (`REGISTRY_URL` is tested), while exported functions that the scanner cannot parse fail loudly. Object-literal groups would introduce a new middle case: an exported object may be an ordinary constant, a plain-function command group, or an explicit norpc router. - -**Question 6:** For `export const users = { ... }` same-file groups, should grouping be syntax-gated to object literals with command-shaped method properties, while ordinary exported objects remain ignored? - -**Recommended answer:** Yes. Support only direct exported object literals whose properties are parseable command methods/functions: - -```ts -export const users = { - invite(options: {email: string}) {}, - deactivate: async (options: {id: string}) => {}, -} -``` - -If at least one property is command-shaped, the object becomes a subcommand group, and any function-valued property in that object that cannot be parsed should fail loudly. Non-function properties in a command group should fail too unless they are explicit norpc procedures/routers, because a mixed object would make help unpredictable. - -Plain exported constants like `export const config = {debug: true}` should stay ignored. Nested object-literal groups can be future work unless represented as explicit `t.router`/`os.router`. - -## A6 — 2026-06-19 - -Yes. Object-literal grouping should be syntax-gated and conservative. - -Proposal rules: - -- Only direct `export const users = { ... }` object literals are candidates for plain same-file groups. -- The object becomes a command group only when it contains at least one command-shaped property: method shorthand, function-valued property, arrow-function property, norpc procedure, or norpc router. -- Once an object is classified as a command group, all properties must be command-shaped. Non-function data properties should fail loudly rather than be silently ignored inside a group. -- Ordinary exported objects with no command-shaped properties remain ignored, preserving current non-function export behavior. -- Nested object-literal groups are future work unless they are explicit `t.router`/`os.router` values. The first implementation slice can be one level of object-literal grouping plus arbitrary nesting through explicit norpc routers. - -[guess: this keeps `export const config = {...}` safe while still giving same-file subcommands without class lifecycle ambiguity.] - ---- - -## Q7 — default command behavior — 2026-06-19 - -Codebase answer: current module mode gives `export default function named(...)` special treatment: it becomes a command named `named` and also the root default command. Router/procedure mode instead uses `meta.default === true` to mark a default command for any parent. These are two different default mechanisms. - -**Question 7:** For explicit norpc procedure/router exports and object-literal groups, should default commands be controlled only by norpc `meta.default`, while the existing automatic default-export behavior stays limited to plain `export default function`? - -**Recommended answer:** Yes. Keep the current plain-function default export behavior as-is, because it is already documented and tested. For explicit procedures and routers, do not infer default behavior from `export default`; require `.meta({default: true})` on the procedure. For object-literal groups, support a default child only through an explicit norpc procedure with `meta.default`, not by naming a method `default` in the plain-function group. That keeps default behavior tied to the existing router/procedure metadata model and avoids inventing another source-level convention. - -## A7 — 2026-06-19 - -Yes. Preserve the existing `export default function` behavior for plain functions only. - -For explicit norpc procedures/routers, default commands should be controlled by existing `meta.default`, not by default export syntax. Prefer named explicit exports for the first slice: - -```ts -export const run = os - .meta({default: true}) - .input(schema) - .handler(...) -``` - -For object-literal plain groups, do not introduce a magic `default()` method convention. If a group needs a default subcommand, use an explicit norpc procedure child with `meta.default`. Plain method names should just be command names. - -[guess: this avoids three competing default-command mechanisms while preserving the one shipped plain-function shortcut.] - ---- - -## Stop — 2026-06-19 - -ready for Phase 2 - ---- - -## Post-Grill User Update — class groups only — 2026-06-19 - -The user challenged the earlier rejection of classes and proposed a narrower rule: class groups are workable if trpc-cli can confirm the class has no base class and no constructor arguments. - -Updated decision: - -- Same-file subcommands should use class groups only, not object-literal groups. -- `export class Users { invite(...) {} }` maps to `mycli users invite`. -- Reject `extends`. -- Reject constructors with parameters; allow no constructor or `constructor()`. -- Help/schema generation must not instantiate the class. -- Instantiate lazily inside the command handler, with a fresh instance per invocation. -- Public instance methods directly declared in the class body are commands. -- Private/protected methods and private fields are allowed as implementation details and are not commands. -- Static methods are not commands in the first slice. - -Rationale: the constraints remove the main lifecycle ambiguity, and private state/side effects are a feature of classes as long as instantiation only happens when the command actually runs. - ---- - -## Post-Grill User Update — explicit norpc exports out of scope — 2026-06-19 - -The user decided that support for exported norpc procedures/routers should move out of this proposal and into a separate change. - -Updated decision: - -- Keep a note that explicit norpc exports are probably the right future schema/procedure escape hatch. -- Do not include `os.input(...).handler(...)`, `t.procedure...`, or `os.router(...)` support in the first implementation scope. -- This proposal should focus on documenting current type/overload behavior, JSDoc aliases, and class groups. - ---- - -## Implementation Follow-Up — private methods and explicit inherited constructors — 2026-06-19 - -The user asked for two class-group refinements during implementation: - -- TypeScript `private` methods must not become commands. -- `extends` can be allowed when the exported class declares an explicit zero-argument constructor. - -Updated decision: - -- Public instance methods declared directly in the exported class are commands. -- `private`/`protected` methods, ECMAScript `#private` methods, static methods, getters, setters, and inherited methods are not commands. -- A class with no base class may omit its constructor. -- A class with `extends` must declare `constructor()`. -- Constructor parameters remain unsupported. From e888909e458187177dcecd51f07f5f3e1fc1e032 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:37:36 +0100 Subject: [PATCH 08/11] Ignore unsupported module mode class exports --- README.md | 2 +- src/module-commands.ts | 83 +++++---- src/types.ts | 7 +- tasks/complete/2026-06-19-module-mode-next.md | 7 +- test/typebox-module-commands.test.ts | 166 ++++++++---------- 5 files changed, 133 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index c22bf985..b6b82b4b 100644 --- a/README.md +++ b/README.md @@ -709,7 +709,7 @@ $ tsx commands.ts users invite --email bob@example.com invite bob@example.com ``` -Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Constructor parameters are not supported. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. +Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. Multi-parameter functions work too: leading string/number/boolean parameters become positional arguments, and a trailing object parameter becomes flags - the same convention as [tuple inputs](#combining-positional-arguments-and-options): diff --git a/src/module-commands.ts b/src/module-commands.ts index 74f988c0..8faade1f 100644 --- a/src/module-commands.ts +++ b/src/module-commands.ts @@ -7,10 +7,10 @@ * JSON Schema - including jsdoc comments as property descriptions - with a `~standard` validator attached. Each * function becomes a norpc procedure, so the rest of trpc-cli treats the module like any other router: leading * scalar parameters become positional arguments and a trailing object-literal parameter becomes flags (the same - * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. Exported classes - * with no constructor arguments become nested command groups whose public instance methods are invoked on a fresh - * class instance only when the command runs. Classes with `extends` must declare an explicit zero-argument - * constructor. + * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. + * Command-group-shaped exported classes become nested command groups whose public instance methods are invoked on a + * fresh class instance only when the command runs. Classes with constructor arguments, no public command methods, + * or `extends` without an explicit zero-argument constructor are ignored as ordinary non-command exports. * File-backed modules can re-export other command modules: `export * as group from './group'` creates a nested * router, and `export * from './group'` merges named child commands into the current router. * @@ -231,10 +231,12 @@ const buildLocalProcedures = (resolved: SourceCliModule) => { const {source, exports} = resolved const commands = extractModuleCommands(source) const classes = extractModuleClasses(source) + const classExportNames = extractModuleClassNames(source) const context = buildDeclarationContext(source) const procedures: Record = {} const claimedExportNames = new Set() + for (const name of classExportNames) claimedExportNames.add(name) for (const command of commands) { claimedExportNames.add(command.exportName) const fn = exports[command.exportName] @@ -242,15 +244,9 @@ const buildLocalProcedures = (resolved: SourceCliModule) => { procedures[command.name] = buildProcedure(command, fn as AnyFn, context) } for (const extractedClass of classes) { - claimedExportNames.add(extractedClass.name) const ClassCtor = exports[extractedClass.name] if (typeof ClassCtor !== 'function') continue - if (ClassCtor.length > 0) { - throw new Error( - `Exported class "${extractedClass.name}" has a constructor with parameters, which isn't supported for module-mode command groups. ` + - `Class command groups must have no constructor arguments.`, - ) - } + if (ClassCtor.length > 0) continue const childProcedures: Record = {} for (const method of extractedClass.methods) { childProcedures[method.name] = buildProcedure( @@ -802,23 +798,38 @@ export const extractModuleClasses = (source: string): ExtractedClass[] => { const hasBaseClass = /\bextends\b/.test(header) const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') - const {methods, hasZeroArgConstructor} = extractClassMethods(source, scan, name, braceIndex + 1, classEnd - 1) - if (hasBaseClass && !hasZeroArgConstructor) { - throw new Error( - `Exported class "${name}" extends another class and must declare an explicit zero-argument constructor for module-mode command groups.`, - ) - } - if (methods.length === 0) { - throw new Error( - `Exported class "${name}" has no command methods. Class command groups must declare at least one public instance method.`, - ) - } + const {methodDeclarations, hasConstructorParameters, hasZeroArgConstructor} = extractClassMethodDeclarations( + source, + scan, + braceIndex + 1, + classEnd - 1, + ) + if (hasConstructorParameters) continue + if (hasBaseClass && !hasZeroArgConstructor) continue + if (methodDeclarations.length === 0) continue + const methods = methodDeclarations.map(({methodName, description, paramList}) => ({ + name: methodName, + exportName: methodName, + default: false, + description, + params: parseParams(`${name}.${methodName}`, paramList), + })) classes.push({name, methods}) } return classes } +const extractModuleClassNames = (source: string): string[] => { + const scan = scanSource(source) + const names: string[] = [] + for (const match of source.matchAll(/(? { for (let i = start; i < source.length; i++) { if (!scan.masked[i] && source[i] === char) return i @@ -826,13 +837,16 @@ const findNextUnmasked = (source: string, scan: SourceScan, start: number, char: throw new Error(`Could not find \`${char}\` after index ${start} of module source`) } -const extractClassMethods = ( +const extractClassMethodDeclarations = ( source: string, scan: SourceScan, - className: string, bodyStart: number, bodyEnd: number, -): {methods: ExtractedCommand[]; hasZeroArgConstructor: boolean} => { +): { + methodDeclarations: Array<{methodName: string; description: string | undefined; paramList: string}> + hasConstructorParameters: boolean + hasZeroArgConstructor: boolean +} => { const body = source.slice(bodyStart, bodyEnd) const declarations: Array<{ name: string @@ -841,6 +855,7 @@ const extractClassMethods = ( paramList: string description: string | undefined }> = [] + let hasConstructorParameters = false let hasZeroArgConstructor = false const pattern = /(? ({ - name, - exportName: name, - default: false, + methodDeclarations: [...winners.values()].map(({name, description, paramList}) => ({ + methodName: name, description, - params: parseParams(`${className}.${name}`, paramList), + paramList, })), } } diff --git a/src/types.ts b/src/types.ts index 619b7a86..b27486bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,9 +40,10 @@ export interface TrpcCliParams extends Dependencies { * `Type.Script`) becomes the input schema - property jsdoc comments become flag descriptions, and inputs are * validated against the schema before the function runs. `@alias` tags in command/property jsdoc become command and * option aliases. A default-exported function becomes the default command, equivalent to `{default: true}` in - * procedure meta. Exported classes become nested command groups when they have no constructor arguments; classes - * with `extends` must declare an explicit zero-argument constructor. Their public instance methods are lazily - * invoked on a fresh class instance. In file-backed module mode, + * procedure meta. Exported classes become nested command groups when they have no constructor arguments and at least + * one public command method; classes with `extends` must declare an explicit zero-argument constructor. Unsupported + * class shapes are ignored as ordinary non-command exports. Their public instance methods are lazily invoked on a + * fresh class instance. In file-backed module mode, * `export * as foo from './foo'` becomes a nested sub-router named `foo`, and `export * from './foo'` merges that * module's named commands into the current router level. * diff --git a/tasks/complete/2026-06-19-module-mode-next.md b/tasks/complete/2026-06-19-module-mode-next.md index fbe63322..0735fcbf 100644 --- a/tasks/complete/2026-06-19-module-mode-next.md +++ b/tasks/complete/2026-06-19-module-mode-next.md @@ -91,7 +91,7 @@ Rules: - Only direct `export class Users { ... }` declarations are candidates. - Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. -- Constructor parameters are rejected. +- Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. - Public instance method declarations directly in the class body become commands. - Private/protected methods and private fields are internal implementation details, not commands. - Static methods are not commands in the first slice. @@ -148,7 +148,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - Same-file `Type.Script`-parseable support is the right boundary because it matches the project's preference for loud errors and small pragmatic mechanisms over building a TypeScript compiler. - JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. - First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. -- Class groups are acceptable when constrained to no constructor arguments, public instance methods only, and lazy per-invocation instantiation. Inheritance is acceptable when the class explicitly declares a zero-argument constructor. +- Class groups are acceptable when constrained to no constructor arguments, public instance methods only, and lazy per-invocation instantiation. Inheritance is acceptable when the class explicitly declares a zero-argument constructor; unsupported class shapes should be ignored rather than hard errors. - Explicit norpc exports are probably the right schema escape hatch, but they belong in a separate change because they introduce runtime-export composition beyond plain source-scanned commands. - Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data. - Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut in this proposal. @@ -173,7 +173,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - [x] Capture open risks, tradeoffs, and follow-up implementation slices. _Captured in "Suggested Implementation Slices", "Guesses And Assumptions", and "Out Of Scope"._ - [x] Open a draft PR for review. _Opened as #211._ - [x] Implement JSDoc `@alias` support. _Implemented in `src/module-commands.ts` by stripping `@alias` from JSDoc descriptions and mapping tags onto existing command/option alias metadata._ -- [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no constructor args become nested routers, inherited classes require an explicit zero-argument constructor, and method handlers instantiate a fresh class instance only when invoked._ +- [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no constructor args become nested routers, inherited classes require an explicit zero-argument constructor, unsupported class shapes are ignored, and method handlers instantiate a fresh class instance only when invoked._ - [x] Update docs and tests. _README module-mode docs updated; behavior covered in `test/typebox-module-commands.test.ts`._ ## Implementation Notes @@ -185,3 +185,4 @@ Default behavior for explicit norpc exports should be handled in the separate ex - 2026-06-19: Follow-up user decision moved support for exported norpc procedures/routers out of scope for this proposal and into a separate change. - 2026-06-19: Implemented the scoped feature set. `pnpm exec vitest run test/typebox-module-commands.test.ts`, `pnpm compile`, and `pnpm test` pass. `pnpm lint` is blocked only by the pre-existing unstaged `test/zod4.test.ts` unused-disable warning. - 2026-06-19: Follow-up user decision allowed `extends` when the class declares an explicit zero-argument constructor, and added coverage that TypeScript `private` methods are not commands. +- 2026-06-19: Follow-up user decision changed unsupported class shapes, including constructor parameters, from startup errors to ignored non-command exports. diff --git a/test/typebox-module-commands.test.ts b/test/typebox-module-commands.test.ts index 11fcaa5a..d995d76f 100644 --- a/test/typebox-module-commands.test.ts +++ b/test/typebox-module-commands.test.ts @@ -817,30 +817,7 @@ test('module commands: exported classes create lazily-instantiated command group expect(constructed).toBe(3) }) -test('module commands: class groups allow inheritance only with an explicit zero-arg constructor', async () => { - await expect( - runWith( - { - source: ` - class Base {} - export class Users extends Base { - invite(options: {email: string}) { - return options.email - } - } - `, - exports: { - Users: class Users { - invite(options: any) { - return options.email - } - }, - }, - }, - ['--help'], - ), - ).rejects.toThrowError(/must declare an explicit zero-argument constructor/) - +test('module commands: inherited class groups require an explicit zero-arg constructor', async () => { class Base { protected prefix = 'base' } @@ -877,74 +854,87 @@ test('module commands: class groups allow inheritance only with an explicit zero ).toMatchInlineSnapshot(`"base:ada@example.com"`) }) -test('module commands: class groups reject constructor parameters and static-only command shapes', async () => { - await expect( - runWith( - { - source: ` - export class Users { - constructor(config: {dryRun?: boolean}) {} +test('module commands: unsupported exported classes are ignored instead of throwing', async () => { + class Base {} + class NeedsConfig { + constructor(config: {dryRun?: boolean}) { + void config + } - invite(options: {email: string}) { - return options.email - } - } - `, - exports: { - Users: class Users { - constructor(config: any) { - void config - } + invite(options: {email: string}) { + return options.email + } + } + class InheritedWithoutConstructor extends Base { + invite(options: {email: string}) { + return options.email + } + } + class StaticOnly {} + class AccessorOnly { + get invite() { + return 'not a command' + } + } + class PrivateOnly {} - invite(options: any) { - return options.email - } - }, - }, - }, - ['--help'], - ), - ).rejects.toThrowError(/must have no constructor arguments/) + const params = { + source: ` + export class NeedsConfig { + constructor(config: {dryRun?: boolean}) {} - await expect( - runWith( - { - source: ` - export class Users { - static invite(options: {email: string}) { - return options.email - } - } - `, - exports: { - Users: function Users() {}, - }, - }, - ['--help'], - ), - ).rejects.toThrowError(/has no command methods/) + invite(options: {email: string}) { + return options.email + } + } - await expect( - runWith( - { - source: ` - export class Users { - get invite() { - return 'not a command' - } - } - `, - exports: { - Users: class Users { - get invite() { - return 'not a command' - } - }, - }, - }, - ['--help'], - ), - ).rejects.toThrowError(/has no command methods/) + class Base {} + export class InheritedWithoutConstructor extends Base { + invite(options: {email: string}) { + return options.email + } + } + + export class StaticOnly { + static invite(options: {email: string}) { + return options.email + } + } + + export class AccessorOnly { + get invite() { + return 'not a command' + } + } + + export class PrivateOnly { + private invite(options: {email: string}) { + return options.email + } + } + + export function status() { + return 'ok' + } + `, + exports: { + AccessorOnly, + InheritedWithoutConstructor, + NeedsConfig, + PrivateOnly, + StaticOnly, + status: () => 'ok', + }, + } + + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('needs-config') + expect(help).not.toContain('inherited-without-constructor') + expect(help).not.toContain('static-only') + expect(help).not.toContain('accessor-only') + expect(help).not.toContain('private-only') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) test('module positionals: destructured trailing options object works', async () => { From 05908e12e176ba64adb9a0b2afda5e14e96042eb Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:51:38 +0100 Subject: [PATCH 09/11] Expand module mode file-backed class support --- README.md | 17 +- src/module-commands.ts | 265 ++++++++++++++---- src/types.ts | 11 +- tasks/complete/2026-06-19-module-mode-next.md | 4 +- test/typebox-module-commands.test.ts | 135 ++++++++- 5 files changed, 366 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index b6b82b4b..9e7e4b57 100644 --- a/README.md +++ b/README.md @@ -677,10 +677,11 @@ File-backed module mode can compose command modules too: ```ts // commands.ts export * from './workspace.ts' // merges named commands into this level +export {Users} from './users.ts' // re-exports one selected command/group export * as users from './users.ts' // creates a nested `users` sub-router ``` -Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`. +Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `.js`/`.mjs`/`.cjs` specifiers may also resolve to sibling TypeScript source files such as `.ts`/`.mts`/`.cts`, matching the common TypeScript ESM pattern. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export {Users} from './users.ts'` exposes that child command/group at the current level. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`. Same-file subcommand groups can be written as exported classes. The class is not instantiated when help/schema information is built; trpc-cli creates a fresh instance only when one of the class's method commands runs. @@ -709,7 +710,15 @@ $ tsx commands.ts users invite --email bob@example.com invite bob@example.com ``` -Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. +Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. A default-exported class exposes its public methods at the current level instead of under a class-name subcommand: + +```ts +export default class Commands { + invite(options: {email: string}) { + return `invite ${options.email}` // mycli invite --email ada@example.com + } +} +``` Multi-parameter functions work too: leading string/number/boolean parameters become positional arguments, and a trailing object parameter becomes flags - the same convention as [tuple inputs](#combining-positional-arguments-and-options): @@ -737,9 +746,9 @@ Details and limitations: - `filename` accepts an absolute path (robust - works from any directory), `import.meta` (when the call lives in the commands file itself), or a `URL` like `new URL('./commands.ts', import.meta.url)` (resolves relative to the importing file, so a distributed CLI works wherever it's invoked). A *relative* path string is resolved against `process.cwd()`, so it's only reliable when the CLI is run from a known directory - fine for quick scripts, but it breaks the moment a globally-installed CLI runs somewhere else, so prefer `import.meta`/`URL` for anything you distribute. For `.ts` modules, run under tsx, bun, deno, or node >=22.18 (which strip types natively). - `createCli(import.meta)` re-imports the commands file to read its exports, so when the call lives in that same file it's a *self-import*. That's fine as long as the call is at the **bottom** of the file (so all `export const` arrow functions above it are initialized) and is **not** top-level-`await`ed - `void createCli(import.meta).run()` is the safe form. A top-level `await createCli(import.meta).run()` would deadlock (the await suspends the module before the self-import can resolve). When another module imports this file, the `.run()` call is a no-op, so the exported command functions remain importable as plain functions. -- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`) or references to a `type X = {...}`/`interface X {...}` declared in the same file. Interface `extends` clauses and intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags when possible. Functions with no parameters become commands with no arguments. +- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`), references to a `type X = {...}`/`interface X {...}` declared in the same file, or relative file-backed type imports such as `import type {Options} from './types.ts'`. Interface `extends` clauses and intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags when possible. Functions with no parameters become commands with no arguments. - Only the *last* parameter can be an object type (it maps to flags); the others must be strings, numbers, booleans, or arrays of those (a `files: string[]` parameter becomes a variadic positional). Rest parameters and destructured positional parameters aren't supported. -- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export class Group { method(...) {} }`, `export * as group from './group'`, and `export * from './group'`. The module must export *only* commands: any other exported function - an `export {f}` statement, a re-export form other than `export *`/`export * as`, or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module. +- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export class Group { method(...) {} }`, `export default class Commands { method(...) {} }`, `export * as group from './group'`, `export * from './group'`, and `export {commandOrGroup} from './group'`. The module must export *only* commands: any other exported function - a local `export {f}` statement or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module. - Overloaded functions work, with a simple rule: only the *first* overload signature is used; later overloads and the implementation signature are ignored. TypeScript resolves calls against overload signatures in order, so the first one is the primary documented shape - and a CLI can only present one calling convention. - For bundlers/browsers (no filesystem, no dynamic import), pass the source and exports explicitly: `createCli({source: rawSourceText, exports: await import('./commands.js')})`. Re-exported command modules are not supported in this form because trpc-cli has no filesystem location to resolve child modules from. - In this mode `run`/`buildProgram`/`toJSON` are all **async** (the module is loaded asynchronously): `const program = await createCli(import.meta).buildProgram()`. With a router, `buildProgram`/`toJSON` are synchronous. diff --git a/src/module-commands.ts b/src/module-commands.ts index 8faade1f..9db3cf77 100644 --- a/src/module-commands.ts +++ b/src/module-commands.ts @@ -9,10 +9,11 @@ * scalar parameters become positional arguments and a trailing object-literal parameter becomes flags (the same * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. * Command-group-shaped exported classes become nested command groups whose public instance methods are invoked on a - * fresh class instance only when the command runs. Classes with constructor arguments, no public command methods, - * or `extends` without an explicit zero-argument constructor are ignored as ordinary non-command exports. - * File-backed modules can re-export other command modules: `export * as group from './group'` creates a nested - * router, and `export * from './group'` merges named child commands into the current router. + * fresh class instance only when the command runs; a default-exported class puts its methods at the current router + * level. Classes with constructor arguments, no public command methods, or `extends` without an explicit + * zero-argument constructor are ignored as ordinary non-command exports. File-backed modules can re-export other + * command modules: `export * as group from './group'` creates a nested router, `export * from './group'` merges + * named child commands into the current router, and `export {Foo} from './foo'` re-exports selected commands. * * The source "parser" here is deliberately a lightweight hand-rolled extractor, not the TypeScript compiler API: * it only needs to find exported function/class method declarations, the jsdoc immediately preceding them, and each @@ -28,17 +29,20 @@ import {getSchemaTypes, kebabCase} from './util.js' type AnyFn = (...args: any[]) => any type SourceCliModule = {source: string; exports: Record} +type FileSourceModule = {source: string; filepath: string} type FileCliModule = SourceCliModule & {filepath: string} interface ModuleFileLoader { load: (filepath: string | URL) => Promise loadSpecifier: (parentFilepath: string, specifier: string) => Promise + loadSourceSpecifier: (parentFilepath: string, specifier: string) => Promise } interface ModuleReexport { - kind: 'all' | 'namespace' + kind: 'all' | 'namespace' | 'named' specifier: string name: string | undefined + names?: Array<{imported: string; exported: string}> } const moduleFileExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'] @@ -74,6 +78,10 @@ export interface ExtractedCommand { export interface ExtractedClass { /** the export name, e.g. `Users` - becomes the (kebab-cased) command group name */ name: string + /** the runtime module export to instantiate; differs from `name` for `export default class ...` */ + exportName: string + /** true for `export default class`, whose methods become root commands */ + default: boolean methods: ExtractedCommand[] } @@ -111,16 +119,29 @@ const createModuleFileLoader = async (): Promise => { import('node:url'), ]) - const cache = new Map>() + const sourceCache = new Map>() + const moduleCache = new Map>() - const loadResolvedPath = (fullpath: string) => { + const loadSourceResolvedPath = (fullpath: string) => { const normalized = path.resolve(fullpath) - const cached = cache.get(normalized) + const cached = sourceCache.get(normalized) if (cached) return cached - const promise = (async (): Promise => { + const promise = (async (): Promise => { const source = await fs.readFile(normalized, 'utf8').catch((e: unknown) => { throw new Error(`Could not read module source at ${normalized}`, {cause: e}) }) + return {source, filepath: normalized} + })() + sourceCache.set(normalized, promise) + return promise + } + + const loadResolvedPath = (fullpath: string) => { + const normalized = path.resolve(fullpath) + const cached = moduleCache.get(normalized) + if (cached) return cached + const promise = (async (): Promise => { + const {source} = await loadSourceResolvedPath(normalized) const exports = (await import(url.pathToFileURL(normalized).href).catch((e: unknown) => { throw new Error( `Could not import module at ${normalized}. For TypeScript modules, run under tsx, bun, deno, or node >=22.18 (which strip types natively).`, @@ -129,7 +150,7 @@ const createModuleFileLoader = async (): Promise => { })) as Record return {source, exports: {...exports}, filepath: normalized} })() - cache.set(normalized, promise) + moduleCache.set(normalized, promise) return promise } @@ -147,7 +168,15 @@ const createModuleFileLoader = async (): Promise => { } const exact = path.resolve(path.dirname(parentFilepath), specifier) - const candidates = path.extname(exact) ? [exact] : [exact, ...moduleFileExtensions.map(ext => `${exact}${ext}`)] + const extension = path.extname(exact) + const extensionAlternates: Record = { + '.cjs': ['.cts', '.ts'], + '.js': ['.ts', '.tsx'], + '.mjs': ['.mts', '.ts'], + } + const candidates = extension + ? [exact, ...(extensionAlternates[extension] || []).map(ext => `${exact.slice(0, -extension.length)}${ext}`)] + : [exact, ...moduleFileExtensions.map(ext => `${exact}${ext}`)] for (const candidate of candidates) { if (await fileExists(candidate)) return candidate } @@ -165,6 +194,8 @@ const createModuleFileLoader = async (): Promise => { }, loadSpecifier: async (parentFilepath, specifier) => loadResolvedPath(await resolveSpecifier(parentFilepath, specifier)), + loadSourceSpecifier: async (parentFilepath, specifier) => + loadSourceResolvedPath(await resolveSpecifier(parentFilepath, specifier)), } } @@ -186,7 +217,7 @@ export const buildRouterFromModule = (resolved: { ) } - const {procedures, claimedExportNames} = buildLocalProcedures(resolved) + const {procedures, claimedExportNames} = buildLocalProcedures(resolved, buildDeclarationContext(resolved.source)) assertNoUnmatchedFunctionExports(resolved.exports, claimedExportNames) assertHasProcedures(procedures) return t.router(procedures) @@ -201,7 +232,8 @@ const buildRouterFromFileModule = async ( throw new Error(`Circular module re-export detected: ${[...ancestors, resolved.filepath].join(' -> ')}`) } - const {procedures, claimedExportNames} = buildLocalProcedures(resolved) + const context = await buildFileDeclarationContext(resolved, loader) + const {procedures, claimedExportNames} = buildLocalProcedures(resolved, context) const childAncestors = [...ancestors, resolved.filepath] for (const reexport of extractModuleReexports(resolved.source)) { const child = await loader.loadSpecifier(resolved.filepath, reexport.specifier) @@ -213,6 +245,16 @@ const buildRouterFromFileModule = async ( continue } + if (reexport.kind === 'named') { + for (const {imported, exported} of reexport.names || []) { + claimedExportNames.add(exported) + const procedureOrRouter = childRouter[imported] + if (!procedureOrRouter) continue + addProcedureOrRouter(procedures, exported, procedureOrRouter, resolved.filepath, child.filepath) + } + continue + } + for (const [name, procedureOrRouter] of Object.entries(childRouter)) { // `export * from` follows ESM semantics: default exports and ambiguous/conflicting star exports are not present // on the parent module namespace, so only merge names that the runtime import actually exposed. @@ -227,12 +269,11 @@ const buildRouterFromFileModule = async ( return t.router(procedures) } -const buildLocalProcedures = (resolved: SourceCliModule) => { +const buildLocalProcedures = (resolved: SourceCliModule, context: Record) => { const {source, exports} = resolved const commands = extractModuleCommands(source) const classes = extractModuleClasses(source) const classExportNames = extractModuleClassNames(source) - const context = buildDeclarationContext(source) const procedures: Record = {} const claimedExportNames = new Set() @@ -241,15 +282,15 @@ const buildLocalProcedures = (resolved: SourceCliModule) => { claimedExportNames.add(command.exportName) const fn = exports[command.exportName] if (typeof fn !== 'function') continue // e.g. `export const x = (2 + 3)` - extractor can match non-functions; runtime is the source of truth - procedures[command.name] = buildProcedure(command, fn as AnyFn, context) + addLocalProcedureOrRouter(procedures, command.name, buildProcedure(command, fn as AnyFn, context)) } for (const extractedClass of classes) { - const ClassCtor = exports[extractedClass.name] + const ClassCtor = exports[extractedClass.exportName] if (typeof ClassCtor !== 'function') continue if (ClassCtor.length > 0) continue const childProcedures: Record = {} for (const method of extractedClass.methods) { - childProcedures[method.name] = buildProcedure( + const procedure = buildProcedure( method, (...args: unknown[]) => { const instance = new (ClassCtor as new () => Record)() @@ -257,13 +298,24 @@ const buildLocalProcedures = (resolved: SourceCliModule) => { }, context, ) + if (extractedClass.default) addLocalProcedureOrRouter(procedures, method.name, procedure) + else childProcedures[method.name] = procedure } - procedures[extractedClass.name] = t.router(childProcedures) + if (!extractedClass.default) addLocalProcedureOrRouter(procedures, extractedClass.name, t.router(childProcedures)) } return {procedures, claimedExportNames} } +const addLocalProcedureOrRouter = ( + procedures: Record, + name: string, + procedureOrRouter: NorpcProcedureLike | NorpcRouterLike, +) => { + if (name in procedures) throw new Error(`Module command ${JSON.stringify(name)} is declared more than once.`) + procedures[name] = procedureOrRouter +} + const assertNoUnmatchedFunctionExports = (exports: Record, claimedExportNames: Set) => { const unmatched = Object.entries(exports) .filter(([name, value]) => typeof value === 'function' && !claimedExportNames.has(name)) @@ -271,8 +323,8 @@ const assertNoUnmatchedFunctionExports = (exports: Record, clai if (unmatched.length > 0) { throw new Error( `Could not find a parseable declaration for exported function(s) ${unmatched.map(n => JSON.stringify(n)).join(', ')}. ` + - `Every exported function becomes a command, and must be declared directly in the module source as \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\` - ` + - `re-exports like \`export {name} from './helpers.js'\` can't be parsed yet. ` + + `Every exported function becomes a command, and must be declared directly in the module source as \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\`, or re-exported from a relative file-backed command module. ` + + `Local export lists like \`export {name}\` can't be parsed yet. ` + `If these exports aren't meant to be commands, move them to a separate module that the commands module doesn't re-export.`, ) } @@ -281,7 +333,7 @@ const assertNoUnmatchedFunctionExports = (exports: Record, clai const assertHasProcedures = (procedures: Record) => { if (Object.keys(procedures).length === 0) { throw new Error( - `No commands found in module. Export functions with \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\`.`, + `No commands found in module. Export functions with \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\`, or export a command-group-shaped class.`, ) } } @@ -417,14 +469,14 @@ const parseParamSchema = (commandName: string, param: ExtractedParam, context: R if (isNeverSchema(schema)) { throw new Error( `Could not parse the type of parameter ${describeParam(param)} of "${commandName}": \`${param.typeText}\`. ` + - `Use a string/number/boolean type, an inline object type literal like \`{foo: string}\`, or a reference to a \`type X = {...}\`/\`interface X {...}\` declared in the same file.`, + `Use a string/number/boolean type, an inline object type literal like \`{foo: string}\`, or a reference to a \`type X = {...}\`/\`interface X {...}\` declared in the same file or imported from a relative file-backed module.`, ) } const danglingRefs = collectRefs(schema) if (danglingRefs.length > 0) { throw new Error( `The type of parameter ${describeParam(param)} of "${commandName}" references ${danglingRefs.map(r => JSON.stringify(r)).join(', ')}, which couldn't be resolved. ` + - `Declare it as \`type X = {...}\` or \`interface X {...}\` in the same file, or inline the type.`, + `Declare it as \`type X = {...}\` or \`interface X {...}\` in the same file, import it from a relative file-backed module, or inline the type.`, ) } return applySchemaJsdocMetadata(flattenIntersection(schema)) @@ -694,10 +746,36 @@ const extractModuleReexports = (source: string): ModuleReexport[] => { reexports.push({kind: 'all', name: undefined, specifier: match[2], position: match.index}) } + for (const match of source.matchAll(/(? !specifier.typeOnly) + .map(({imported, exported}) => ({imported, exported})) + if (names.length > 0) { + reexports.push({kind: 'named', name: undefined, names, specifier: match[4], position: match.index}) + } + } + reexports.sort((a, b) => a.position - b.position) - return reexports.map(({kind, name, specifier}) => ({kind, name, specifier})) + return reexports.map(({kind, name, names, specifier}) => ({kind, name, names, specifier})) } +const parseNamedSpecifiers = ( + raw: string, + typeOnlyExport: boolean, +): Array<{imported: string; exported: string; typeOnly: boolean}> => + raw + .split(',') + .map(part => part.trim()) + .filter(Boolean) + .flatMap(part => { + const typeOnly = typeOnlyExport || part.startsWith('type ') + const text = part.replace(/^type\s+/, '').trim() + const match = text.match(/^([A-Za-z_$][\w$]*|default)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/) + if (!match) return [] + return [{imported: match[1], exported: match[2] || match[1], typeOnly}] + }) + /** * @experimental Extract exported function declarations from module source text: name, preceding jsdoc, and the full * parameter list (names, optionality, type annotation text, inline jsdoc). Supports `export function f(...)`, @@ -788,33 +866,41 @@ export const extractModuleClasses = (source: string): ExtractedClass[] => { const scan = scanSource(source) const classes: ExtractedClass[] = [] - for (const match of source.matchAll(/(?') - const braceIndex = findNextUnmasked(source, scan, headerIndex, '{') - const header = source.slice(headerIndex, braceIndex) - const hasBaseClass = /\bextends\b/.test(header) - - const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') - const {methodDeclarations, hasConstructorParameters, hasZeroArgConstructor} = extractClassMethodDeclarations( - source, - scan, - braceIndex + 1, - classEnd - 1, - ) - if (hasConstructorParameters) continue - if (hasBaseClass && !hasZeroArgConstructor) continue - if (methodDeclarations.length === 0) continue - const methods = methodDeclarations.map(({methodName, description, paramList}) => ({ - name: methodName, - exportName: methodName, - default: false, - description, - params: parseParams(`${name}.${methodName}`, paramList), - })) - classes.push({name, methods}) + const patterns = [ + {pattern: /(?') + const braceIndex = findNextUnmasked(source, scan, headerIndex, '{') + const header = source.slice(headerIndex, braceIndex) + const hasBaseClass = /\bextends\b/.test(header) + + const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') + const {methodDeclarations, hasConstructorParameters, hasZeroArgConstructor} = extractClassMethodDeclarations( + source, + scan, + braceIndex + 1, + classEnd - 1, + ) + if (hasConstructorParameters) continue + if (hasBaseClass && !hasZeroArgConstructor) continue + if (methodDeclarations.length === 0) continue + const methods = methodDeclarations.map(({methodName, description, paramList}) => ({ + name: methodName, + exportName: methodName, + default: false, + description, + params: parseParams(`${name}.${methodName}`, paramList), + })) + classes.push({name, exportName, default: defaultExport, methods}) + } } return classes @@ -823,9 +909,15 @@ export const extractModuleClasses = (source: string): ExtractedClass[] => { const extractModuleClassNames = (source: string): string[] => { const scan = scanSource(source) const names: string[] = [] - for (const match of source.matchAll(/(?> => { + const parts = await collectTypeContextParts(resolved, loader, new Set()) + return buildDeclarationContext([...parts.sources, ...parts.aliases].join('\n')) +} + +const collectTypeContextParts = async ( + resolved: FileSourceModule, + loader: ModuleFileLoader, + seen: Set, +): Promise<{sources: string[]; aliases: string[]}> => { + if (seen.has(resolved.filepath)) return {sources: [], aliases: []} + seen.add(resolved.filepath) + + const sources: string[] = [] + const aliases: string[] = [] + for (const typeImport of extractModuleTypeImports(resolved.source)) { + if (!typeImport.specifier.startsWith('./') && !typeImport.specifier.startsWith('../')) continue + const child = await loader.loadSourceSpecifier(resolved.filepath, typeImport.specifier) + const childParts = await collectTypeContextParts(child, loader, seen) + sources.push(...childParts.sources) + aliases.push(...childParts.aliases) + for (const {imported, local} of typeImport.imports) { + if (imported !== local) aliases.push(`type ${local} = ${imported}`) + } + } + sources.push(resolved.source) + + return {sources, aliases} +} + +const extractModuleTypeImports = ( + source: string, +): Array<{specifier: string; imports: Array<{imported: string; local: string}>}> => { + const scan = scanSource(source) + const imports: Array<{specifier: string; imports: Array<{imported: string; local: string}>; position: number}> = [] + + for (const match of source.matchAll(/(? ({imported, local: exported})), + position: match.index, + }) + } + + for (const match of source.matchAll(/(? specifier.typeOnly) + if (typeSpecifiers.length === 0) continue + imports.push({ + specifier: match[3], + imports: typeSpecifiers.map(({imported, exported}) => ({imported, local: exported})), + position: match.index, + }) + } + + imports.sort((a, b) => a.position - b.position) + return imports.map(({specifier, imports: importedNames}) => ({specifier, imports: importedNames})) +} + /** * Extract `type X = ...` and `interface X {...}` declarations from the source and parse them into a record of * schemas, used as the `Type.Script` context so function parameters can reference named types. Tries a single diff --git a/src/types.ts b/src/types.ts index b27486bb..44387f54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,11 +41,12 @@ export interface TrpcCliParams extends Dependencies { * validated against the schema before the function runs. `@alias` tags in command/property jsdoc become command and * option aliases. A default-exported function becomes the default command, equivalent to `{default: true}` in * procedure meta. Exported classes become nested command groups when they have no constructor arguments and at least - * one public command method; classes with `extends` must declare an explicit zero-argument constructor. Unsupported - * class shapes are ignored as ordinary non-command exports. Their public instance methods are lazily invoked on a - * fresh class instance. In file-backed module mode, - * `export * as foo from './foo'` becomes a nested sub-router named `foo`, and `export * from './foo'` merges that - * module's named commands into the current router level. + * one public command method; default-exported classes put their methods at the current router level. Classes with + * `extends` must declare an explicit zero-argument constructor. Unsupported class shapes are ignored as ordinary + * non-command exports. Their public instance methods are lazily invoked on a fresh class instance. In file-backed + * module mode, `export * as foo from './foo'` becomes a nested sub-router named `foo`, `export * from './foo'` + * merges that module's named commands into the current router level, and `export {foo} from './foo'` re-exports + * selected commands. * * `import.meta` satisfies this shape (it carries `filename`/`url`), so the simplest setup is to call `createCli` * from the bottom of the commands file itself: diff --git a/tasks/complete/2026-06-19-module-mode-next.md b/tasks/complete/2026-06-19-module-mode-next.md index 0735fcbf..5fdf3839 100644 --- a/tasks/complete/2026-06-19-module-mode-next.md +++ b/tasks/complete/2026-06-19-module-mode-next.md @@ -90,6 +90,7 @@ This maps to `mycli users invite` and `mycli users deactivate`. Rules: - Only direct `export class Users { ... }` declarations are candidates. +- `export default class Commands { ... }` exposes public methods at the current router level instead of adding a class-name subcommand. - Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. - Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. - Public instance method declarations directly in the class body become commands. @@ -157,7 +158,6 @@ Default behavior for explicit norpc exports should be handled in the separate ex - Implementing the proposal. - Switching module mode to the TypeScript compiler API. -- Imported type resolution. - Object-literal command groups. - Class groups with constructor arguments. - Explicit norpc procedure/router exports. @@ -174,6 +174,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - [x] Open a draft PR for review. _Opened as #211._ - [x] Implement JSDoc `@alias` support. _Implemented in `src/module-commands.ts` by stripping `@alias` from JSDoc descriptions and mapping tags onto existing command/option alias metadata._ - [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no constructor args become nested routers, inherited classes require an explicit zero-argument constructor, unsupported class shapes are ignored, and method handlers instantiate a fresh class instance only when invoked._ +- [x] Handle default class exports, named command re-exports, and relative imported type declarations. _Default class methods are root commands; `export {CommandOrGroup} from './module'` re-exports selected file-backed commands/groups; relative type imports are read as source-only declaration context._ - [x] Update docs and tests. _README module-mode docs updated; behavior covered in `test/typebox-module-commands.test.ts`._ ## Implementation Notes @@ -186,3 +187,4 @@ Default behavior for explicit norpc exports should be handled in the separate ex - 2026-06-19: Implemented the scoped feature set. `pnpm exec vitest run test/typebox-module-commands.test.ts`, `pnpm compile`, and `pnpm test` pass. `pnpm lint` is blocked only by the pre-existing unstaged `test/zod4.test.ts` unused-disable warning. - 2026-06-19: Follow-up user decision allowed `extends` when the class declares an explicit zero-argument constructor, and added coverage that TypeScript `private` methods are not commands. - 2026-06-19: Follow-up user decision changed unsupported class shapes, including constructor parameters, from startup errors to ignored non-command exports. +- 2026-06-19: Follow-up user decision added support for `export default class`, named file-backed re-exports like `export {Users} from './users.ts'`, and relative imported type/interface declarations for file-backed modules. diff --git a/test/typebox-module-commands.test.ts b/test/typebox-module-commands.test.ts index d995d76f..fedac94d 100644 --- a/test/typebox-module-commands.test.ts +++ b/test/typebox-module-commands.test.ts @@ -175,7 +175,7 @@ test('module commands: unresolvable named type errors clearly', async () => { exports: {deploy: () => {}}, } await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'The type of parameter "options" of "deploy" references "ImportedFromElsewhere", which couldn\'t be resolved. Declare it as `type X = {...}` or `interface X {...}` in the same file, or inline the type.', + 'The type of parameter "options" of "deploy" references "ImportedFromElsewhere", which couldn\'t be resolved. Declare it as `type X = {...}` or `interface X {...}` in the same file, import it from a relative file-backed module, or inline the type.', ) }) @@ -226,6 +226,35 @@ test('module commands: re-exported module resolution supports exact well-known e ) }) +test('module commands: named re-exports expose selected child command classes', async () => { + using fixture = createReexportFixture() + + expect(await runWith({filename: fixture.barrelPath}, ['users', 'invite', '--email', 'ada@example.com'])) + .toMatchInlineSnapshot(` + "user ada@example.com" + `) + + const help = await runWith({filename: fixture.barrelPath}, ['--help']) + expect(help).toContain('users') +}) + +test('module commands: file-backed modules resolve parameter types imported from relative files', async () => { + using fixture = createImportedTypesFixture() + + expect(await runWith({filename: fixture.commandsPath}, ['invite', '--email', 'ada@example.com', '--role', 'admin'])) + .toMatchInlineSnapshot(` + "invite ada@example.com as admin" + `) + expect(await runWith({filename: fixture.commandsPath}, ['assign', '--id', 'u_123', '--group', 'staff'])) + .toMatchInlineSnapshot(` + "assign u_123 to staff" + `) + + const help = await runWith({filename: fixture.commandsPath}, ['invite', '--help']) + expect(help).toContain('email to invite') + expect(help).toContain('role to grant') +}) + test('module commands: {source, exports} rejects re-export module composition', async () => { const params = { source: ` @@ -817,6 +846,50 @@ test('module commands: exported classes create lazily-instantiated command group expect(constructed).toBe(3) }) +test('module commands: default exported class methods become root commands', async () => { + let constructed = 0 + class Commands { + constructor() { + constructed++ + } + + invite(options: {email: string}) { + return `invite ${options.email}` + } + } + + const params = { + source: ` + export default class Commands { + constructor() {} + + /** invite from the root */ + invite(options: { + /** target email */ + email: string + }) { + return 'invite ' + options.email + } + + private helper() { + return 'not a command' + } + } + `, + exports: {default: Commands}, + } + + const rootHelp = await runWith(params, ['--help']) + expect(rootHelp).toContain('invite') + expect(rootHelp).toContain('invite from the root') + expect(rootHelp).not.toContain('helper') + expect(constructed).toBe(0) + expect(await runWith(params, ['invite', '--email', 'ada@example.com'])).toMatchInlineSnapshot( + `"invite ada@example.com"`, + ) + expect(constructed).toBe(1) +}) + test('module commands: inherited class groups require an explicit zero-arg constructor', async () => { class Base { protected prefix = 'base' @@ -960,6 +1033,7 @@ const createReexportFixture = () => { export * from './root' export * as admin from './admin' export * as extra from './extra.mts' + export {Users} from './users' export function localThing(options: {name: string}) { return \`local \${options.name}\` @@ -1004,6 +1078,16 @@ const createReexportFixture = () => { } `, ) + write( + 'users.ts', + ` + export class Users { + invite(options: {email: string}) { + return \`user \${options.email}\` + } + } + `, + ) return { barrelPath: path.join(dir, 'barrel.ts'), @@ -1012,3 +1096,52 @@ const createReexportFixture = () => { }, } } + +const createImportedTypesFixture = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trpc-cli-imported-types-')) + const write = (filename: string, source: string) => fs.writeFileSync(path.join(dir, filename), source, 'utf8') + + write( + 'commands.ts', + ` + import type {InviteOptions as Options} from './types.js' + import {type AssignOptions} from './types.js' + + export function invite(options: Options) { + return \`invite \${options.email} as \${options.role || 'member'}\` + } + + export default class Commands { + assign(options: AssignOptions) { + return \`assign \${options.id} to \${options.group}\` + } + } + `, + ) + write( + 'types.ts', + ` + export interface InviteOptions { + /** email to invite */ + email: string + /** role to grant */ + role?: 'admin' | 'member' + } + + export type AssignOptions = { + /** user id */ + id: string + } & { + /** destination group */ + group: string + } + `, + ) + + return { + commandsPath: path.join(dir, 'commands.ts'), + [Symbol.dispose]() { + fs.rmSync(dir, {recursive: true, force: true}) + }, + } +} From 32723b6c6722c7d3b9eece033f76552f711c0822 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:16:20 +0100 Subject: [PATCH 10/11] Skip unconvertible module mode exports --- README.md | 8 + src/module-commands.ts | 139 +++++++------- src/types.ts | 15 +- tasks/complete/2026-06-19-module-mode-next.md | 5 +- test/typebox-module-commands.test.ts | 170 ++++++++++-------- 5 files changed, 185 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 9e7e4b57..16b517b9 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,14 @@ void createCli({filename: '/path/to/commands.ts'}).run() trpc-cli reads the module's *source text* and dynamically imports it: each exported function becomes a command (kebab-cased, e.g. `listVersions` → `list-versions`), the jsdoc above the function becomes the command description, and parameter type annotations are parsed by the vendored `Type.Script` into real JSON schemas - property jsdoc comments become flag descriptions, and inputs are validated before your function runs (`mycli add --package-name left-pad --dev`). A default-exported function becomes the default command, so `export default function hola(...)` can be run as either `mycli ...` or `mycli hola ...`. +Exported functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. This gives helper exports an escape hatch without adding trpc-cli-specific metadata: + +```ts +export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => { + return loadIt(params.foo) // not a command +} +``` + Command and option aliases can be added with `@alias` tags in the same jsdoc comments: ```ts diff --git a/src/module-commands.ts b/src/module-commands.ts index 9db3cf77..367f34be 100644 --- a/src/module-commands.ts +++ b/src/module-commands.ts @@ -7,7 +7,8 @@ * JSON Schema - including jsdoc comments as property descriptions - with a `~standard` validator attached. Each * function becomes a norpc procedure, so the rest of trpc-cli treats the module like any other router: leading * scalar parameters become positional arguments and a trailing object-literal parameter becomes flags (the same - * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. + * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. Exported + * functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. * Command-group-shaped exported classes become nested command groups whose public instance methods are invoked on a * fresh class instance only when the command runs; a default-exported class puts its methods at the current router * level. Classes with constructor arguments, no public command methods, or `extends` without an explicit @@ -45,6 +46,8 @@ interface ModuleReexport { names?: Array<{imported: string; exported: string}> } +class SkippedModuleCommandError extends Error {} + const moduleFileExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'] /** @@ -201,10 +204,10 @@ const createModuleFileLoader = async (): Promise => { /** * @experimental Build a norpc router from a module's source text + live exports. Exported functions become - * procedures: source order determines command order, function jsdoc becomes the command description, and parameter - * type annotations (inline literals, or references to a `type`/`interface` declared in the same file) are parsed by - * the vendored `Type.Script` into the input schema. Leading scalar parameters become positional arguments; a - * trailing object parameter becomes flags. + * procedures when their signatures can be converted into CLI inputs: source order determines command order, + * function jsdoc becomes the command description, and parameter type annotations (inline literals, or references to + * a `type`/`interface` declared in the same file) are parsed by the vendored `Type.Script` into the input schema. + * Leading scalar parameters become positional arguments; a trailing object parameter becomes flags. */ export const buildRouterFromModule = (resolved: { source: string @@ -217,8 +220,7 @@ export const buildRouterFromModule = (resolved: { ) } - const {procedures, claimedExportNames} = buildLocalProcedures(resolved, buildDeclarationContext(resolved.source)) - assertNoUnmatchedFunctionExports(resolved.exports, claimedExportNames) + const procedures = buildLocalProcedures(resolved, buildDeclarationContext(resolved.source)) assertHasProcedures(procedures) return t.router(procedures) } @@ -233,7 +235,7 @@ const buildRouterFromFileModule = async ( } const context = await buildFileDeclarationContext(resolved, loader) - const {procedures, claimedExportNames} = buildLocalProcedures(resolved, context) + const procedures = buildLocalProcedures(resolved, context) const childAncestors = [...ancestors, resolved.filepath] for (const reexport of extractModuleReexports(resolved.source)) { const child = await loader.loadSpecifier(resolved.filepath, reexport.specifier) @@ -241,13 +243,11 @@ const buildRouterFromFileModule = async ( if (reexport.kind === 'namespace') { addProcedureOrRouter(procedures, reexport.name!, childRouter, resolved.filepath, child.filepath) - claimedExportNames.add(reexport.name!) continue } if (reexport.kind === 'named') { for (const {imported, exported} of reexport.names || []) { - claimedExportNames.add(exported) const procedureOrRouter = childRouter[imported] if (!procedureOrRouter) continue addProcedureOrRouter(procedures, exported, procedureOrRouter, resolved.filepath, child.filepath) @@ -260,11 +260,9 @@ const buildRouterFromFileModule = async ( // on the parent module namespace, so only merge names that the runtime import actually exposed. if (!(name in resolved.exports)) continue addProcedureOrRouter(procedures, name, procedureOrRouter, resolved.filepath, child.filepath) - claimedExportNames.add(name) } } - assertNoUnmatchedFunctionExports(resolved.exports, claimedExportNames) assertHasProcedures(procedures) return t.router(procedures) } @@ -273,16 +271,13 @@ const buildLocalProcedures = (resolved: SourceCliModule, context: Record = {} - const claimedExportNames = new Set() - for (const name of classExportNames) claimedExportNames.add(name) for (const command of commands) { - claimedExportNames.add(command.exportName) const fn = exports[command.exportName] if (typeof fn !== 'function') continue // e.g. `export const x = (2 + 3)` - extractor can match non-functions; runtime is the source of truth - addLocalProcedureOrRouter(procedures, command.name, buildProcedure(command, fn as AnyFn, context)) + const procedure = tryBuildProcedure(command, fn as AnyFn, context) + if (procedure) addLocalProcedureOrRouter(procedures, command.name, procedure) } for (const extractedClass of classes) { const ClassCtor = exports[extractedClass.exportName] @@ -290,7 +285,7 @@ const buildLocalProcedures = (resolved: SourceCliModule, context: Record 0) continue const childProcedures: Record = {} for (const method of extractedClass.methods) { - const procedure = buildProcedure( + const procedure = tryBuildProcedure( method, (...args: unknown[]) => { const instance = new (ClassCtor as new () => Record)() @@ -298,13 +293,16 @@ const buildLocalProcedures = (resolved: SourceCliModule, context: Record 0) { + addLocalProcedureOrRouter(procedures, extractedClass.name, t.router(childProcedures)) + } } - return {procedures, claimedExportNames} + return procedures } const addLocalProcedureOrRouter = ( @@ -316,17 +314,16 @@ const addLocalProcedureOrRouter = ( procedures[name] = procedureOrRouter } -const assertNoUnmatchedFunctionExports = (exports: Record, claimedExportNames: Set) => { - const unmatched = Object.entries(exports) - .filter(([name, value]) => typeof value === 'function' && !claimedExportNames.has(name)) - .map(([name]) => name) - if (unmatched.length > 0) { - throw new Error( - `Could not find a parseable declaration for exported function(s) ${unmatched.map(n => JSON.stringify(n)).join(', ')}. ` + - `Every exported function becomes a command, and must be declared directly in the module source as \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\`, or re-exported from a relative file-backed command module. ` + - `Local export lists like \`export {name}\` can't be parsed yet. ` + - `If these exports aren't meant to be commands, move them to a separate module that the commands module doesn't re-export.`, - ) +const tryBuildProcedure = ( + command: ExtractedCommand, + fn: AnyFn, + context: Record, +): NorpcProcedureLike | undefined => { + try { + return buildProcedure(command, fn, context) + } catch (error) { + if (error instanceof SkippedModuleCommandError) return undefined + throw error } } @@ -398,25 +395,25 @@ const buildPositionalProcedure = ( positionalParams.forEach((param, i) => { const where = `Parameter ${i + 1} (${describeParam(param)}) of "${command.name}"` if (param.destructured) { - throw new Error( + throw new SkippedModuleCommandError( `${where} is a destructuring pattern, which isn't supported for positional arguments. Give the parameter a name, or move it into a trailing options object.`, ) } if (isObjectLikeSchema(paramSchemas[i])) { - throw new Error( + throw new SkippedModuleCommandError( `${where} is an object type, but only the *last* parameter can be an object - leading parameters become positional arguments and a trailing object parameter maps to flags. Move it to the end, or flatten it into the trailing options object.`, ) } if (isArrayOfPrimitives(paramSchemas[i])) { if (param.optional) { - throw new Error( + throw new SkippedModuleCommandError( `${where} is an optional array. Optional array parameters aren't supported as positional arguments - make it required, or move it into a trailing options object.`, ) } return // required array of primitives -> variadic positional, supported by the existing tuple handling } if (!isPrimitiveish(paramSchemas[i])) { - throw new Error( + throw new SkippedModuleCommandError( `${where} has type \`${param.typeText}\`, which can't be used as a positional argument. Positional parameters must be strings, numbers, booleans (or arrays of those) - put other values in a trailing options object.`, ) } @@ -432,9 +429,9 @@ const buildPositionalProcedure = ( return cliOptional[i] ? `(${param.typeText}) | undefined` : param.typeText }) .join(', ')}]` - const schema = Type.Script(context as never, tupleScript) as {items?: unknown[]; minItems?: number} + const schema = parseTypeScriptSchema(context, tupleScript) as {items?: unknown[]; minItems?: number} if (isNeverSchema(schema) || !Array.isArray(schema.items) || schema.items.length !== params.length) { - throw new Error( + throw new SkippedModuleCommandError( `Could not parse the parameter list of "${command.name}" as a tuple: \`${tupleScript}\`. This is likely a bug in trpc-cli's module-commands extractor - please report it.`, ) } @@ -465,16 +462,16 @@ const buildPositionalProcedure = ( * (where it's used for object-vs-scalar analysis before the combined tuple script is synthesized). */ const parseParamSchema = (commandName: string, param: ExtractedParam, context: Record): unknown => { - const schema = Type.Script(context as never, param.typeText) + const schema = parseTypeScriptSchema(context, param.typeText) if (isNeverSchema(schema)) { - throw new Error( + throw new SkippedModuleCommandError( `Could not parse the type of parameter ${describeParam(param)} of "${commandName}": \`${param.typeText}\`. ` + `Use a string/number/boolean type, an inline object type literal like \`{foo: string}\`, or a reference to a \`type X = {...}\`/\`interface X {...}\` declared in the same file or imported from a relative file-backed module.`, ) } const danglingRefs = collectRefs(schema) if (danglingRefs.length > 0) { - throw new Error( + throw new SkippedModuleCommandError( `The type of parameter ${describeParam(param)} of "${commandName}" references ${danglingRefs.map(r => JSON.stringify(r)).join(', ')}, which couldn't be resolved. ` + `Declare it as \`type X = {...}\` or \`interface X {...}\` in the same file, import it from a relative file-backed module, or inline the type.`, ) @@ -482,6 +479,14 @@ const parseParamSchema = (commandName: string, param: ExtractedParam, context: R return applySchemaJsdocMetadata(flattenIntersection(schema)) } +const parseTypeScriptSchema = (context: Record, script: string): unknown => { + try { + return Type.Script(context as never, script) + } catch (error) { + throw new SkippedModuleCommandError(error instanceof Error ? error.message : String(error)) + } +} + /** * `type Opts = {a} & {b}` parses to `{allOf: [...]}`, but trpc-cli's flag derivation wants a single top-level object * schema - merge object-only intersections into one (preserving validation behavior, since an intersection of plain @@ -853,13 +858,11 @@ export const extractModuleCommands = (source: string): ExtractedCommand[] => { const existing = winners.get(declaration.name) if (!existing || (existing.hasBody && !declaration.hasBody)) winners.set(declaration.name, declaration) } - return [...winners.values()].map(({name, exportName, default: defaultExport, description, paramList}) => ({ - name, - exportName, - default: defaultExport, - description, - params: parseParams(name, paramList), - })) + return [...winners.values()].flatMap(({name, exportName, default: defaultExport, description, paramList}) => { + const params = tryParseParams(name, paramList) + if (!params) return [] + return [{name, exportName, default: defaultExport, description, params}] + }) } export const extractModuleClasses = (source: string): ExtractedClass[] => { @@ -892,13 +895,12 @@ export const extractModuleClasses = (source: string): ExtractedClass[] => { if (hasConstructorParameters) continue if (hasBaseClass && !hasZeroArgConstructor) continue if (methodDeclarations.length === 0) continue - const methods = methodDeclarations.map(({methodName, description, paramList}) => ({ - name: methodName, - exportName: methodName, - default: false, - description, - params: parseParams(`${name}.${methodName}`, paramList), - })) + const methods = methodDeclarations.flatMap(({methodName, description, paramList}) => { + const params = tryParseParams(`${name}.${methodName}`, paramList) + if (!params) return [] + return [{name: methodName, exportName: methodName, default: false, description, params}] + }) + if (methods.length === 0) continue classes.push({name, exportName, default: defaultExport, methods}) } } @@ -906,22 +908,6 @@ export const extractModuleClasses = (source: string): ExtractedClass[] => { return classes } -const extractModuleClassNames = (source: string): string[] => { - const scan = scanSource(source) - const names: string[] = [] - const patterns = [ - {pattern: /(? { for (let i = start; i < source.length; i++) { if (!scan.masked[i] && source[i] === char) return i @@ -1081,6 +1067,15 @@ const hasFunctionBody = (source: string, scan: SourceScan, parenEnd: number): bo * jsdoc (`/** doc *\/ left: number`). `<`/`>` are tracked as brackets (so `Map` survives the * top-level-comma check) except the `>` of `=>`. */ +const tryParseParams = (functionName: string, paramList: string): ExtractedParam[] | undefined => { + try { + return parseParams(functionName, paramList) + } catch (error) { + if (error instanceof SkippedModuleCommandError) return undefined + throw error + } +} + const parseParams = (functionName: string, paramList: string): ExtractedParam[] => { const scan = scanSource(paramList) @@ -1130,7 +1125,7 @@ const parseParams = (functionName: string, paramList: string): ExtractedParam[] if (nameText.startsWith('...')) { const annotation = colon === -1 ? 'string[]' : paramList.slice(colon + 1, segment.end).trim() - throw new Error( + throw new SkippedModuleCommandError( `Parameter "${nameText}" of "${functionName}" is a rest parameter, which isn't supported. Use an explicitly-typed array parameter (e.g. \`${nameText.slice(3)}: ${annotation}\`, which becomes a variadic positional argument), or move it into a trailing options object.`, ) } @@ -1139,7 +1134,7 @@ const parseParams = (functionName: string, paramList: string): ExtractedParam[] const name = destructured ? undefined : nameText.replace(/\?$/, '').trim() if (colon === -1) { - throw new Error( + throw new SkippedModuleCommandError( `Parameter "${nameText}" of "${functionName}" has no type annotation. Annotate it, e.g. \`(${nameText}: string)\` or \`(${nameText}: {someFlag: string})\`.`, ) } diff --git a/src/types.ts b/src/types.ts index 44387f54..51d41426 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,13 +38,14 @@ export interface TrpcCliParams extends Dependencies { * Exported functions become commands: the jsdoc above each function becomes the command description, and the first * parameter's object type annotation (parsed from the module's *source text* via the vendored `trpc-cli/typebox` * `Type.Script`) becomes the input schema - property jsdoc comments become flag descriptions, and inputs are - * validated against the schema before the function runs. `@alias` tags in command/property jsdoc become command and - * option aliases. A default-exported function becomes the default command, equivalent to `{default: true}` in - * procedure meta. Exported classes become nested command groups when they have no constructor arguments and at least - * one public command method; default-exported classes put their methods at the current router level. Classes with - * `extends` must declare an explicit zero-argument constructor. Unsupported class shapes are ignored as ordinary - * non-command exports. Their public instance methods are lazily invoked on a fresh class instance. In file-backed - * module mode, `export * as foo from './foo'` becomes a nested sub-router named `foo`, `export * from './foo'` + * validated against the schema before the function runs. Exported functions whose signatures cannot be converted + * into CLI inputs are ignored as ordinary non-command exports. `@alias` tags in command/property jsdoc become + * command and option aliases. A default-exported function becomes the default command, equivalent to `{default: + * true}` in procedure meta. Exported classes become nested command groups when they have no constructor arguments + * and at least one public command method; default-exported classes put their methods at the current router level. + * Classes with `extends` must declare an explicit zero-argument constructor. Unsupported class shapes are ignored + * as ordinary non-command exports. Their public instance methods are lazily invoked on a fresh class instance. In + * file-backed module mode, `export * as foo from './foo'` becomes a nested sub-router named `foo`, `export * from './foo'` * merges that module's named commands into the current router level, and `export {foo} from './foo'` re-exports * selected commands. * diff --git a/tasks/complete/2026-06-19-module-mode-next.md b/tasks/complete/2026-06-19-module-mode-next.md index 5fdf3839..4e9e57f9 100644 --- a/tasks/complete/2026-06-19-module-mode-next.md +++ b/tasks/complete/2026-06-19-module-mode-next.md @@ -89,6 +89,7 @@ This maps to `mycli users invite` and `mycli users deactivate`. Rules: +- Exported functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. A `NoInfer<...>` parameter is a convenient opt-out because Type.Script cannot resolve it. - Only direct `export class Users { ... }` declarations are candidates. - `export default class Commands { ... }` exposes public methods at the current router level instead of adding a class-name subcommand. - Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. @@ -99,7 +100,7 @@ Rules: - Method parameter parsing, JSDoc descriptions, aliases, and overload behavior follow the same rules as exported functions. - Help/schema generation must not instantiate the class. - Instantiate lazily inside the command handler, and create a fresh instance per command invocation. -- If a public instance method is command-shaped but cannot be parsed, fail loudly. +- If a public instance method is command-shaped but cannot be converted into a CLI input, ignore that method. - Do not add object-literal command groups in this proposal. Ordinary exported object constants stay ignored. ### Explicit Schema/Procedure Exports @@ -149,6 +150,7 @@ Default behavior for explicit norpc exports should be handled in the separate ex - Same-file `Type.Script`-parseable support is the right boundary because it matches the project's preference for loud errors and small pragmatic mechanisms over building a TypeScript compiler. - JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. - First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. +- Ignoring unconvertible function exports is preferable to forcing custom metadata for helper exports; `NoInfer<...>` becomes one possible opt-out without becoming a trpc-cli feature. - Class groups are acceptable when constrained to no constructor arguments, public instance methods only, and lazy per-invocation instantiation. Inheritance is acceptable when the class explicitly declares a zero-argument constructor; unsupported class shapes should be ignored rather than hard errors. - Explicit norpc exports are probably the right schema escape hatch, but they belong in a separate change because they introduce runtime-export composition beyond plain source-scanned commands. - Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data. @@ -188,3 +190,4 @@ Default behavior for explicit norpc exports should be handled in the separate ex - 2026-06-19: Follow-up user decision allowed `extends` when the class declares an explicit zero-argument constructor, and added coverage that TypeScript `private` methods are not commands. - 2026-06-19: Follow-up user decision changed unsupported class shapes, including constructor parameters, from startup errors to ignored non-command exports. - 2026-06-19: Follow-up user decision added support for `export default class`, named file-backed re-exports like `export {Users} from './users.ts'`, and relative imported type/interface declarations for file-backed modules. +- 2026-06-19: Follow-up user decision changed unconvertible function exports from startup errors to ignored non-command exports, making `NoInfer<...>` parameters usable as an opt-out. diff --git a/test/typebox-module-commands.test.ts b/test/typebox-module-commands.test.ts index fedac94d..cac6a88c 100644 --- a/test/typebox-module-commands.test.ts +++ b/test/typebox-module-commands.test.ts @@ -159,35 +159,60 @@ test('module commands: {source, exports} escape hatch works without file reading expect(await runWith(params, ['add', '--help'])).toContain('the name of the package to add') }) -test('module commands: missing type annotation errors clearly', async () => { +test('module commands: unparseable exported functions are ignored', async () => { const params = { - source: `export function greet(name) { return 'hi ' + name }`, - exports: {greet: (name: string) => 'hi ' + name}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter "name" of "greet" has no type annotation. Annotate it, e.g. `(name: string)` or `(name: {someFlag: string})`.', - ) -}) + source: ` + export function missingAnnotation(name) { + return 'hi ' + name + } -test('module commands: unresolvable named type errors clearly', async () => { - const params = { - source: `export function deploy(options: ImportedFromElsewhere) {}`, - exports: {deploy: () => {}}, + export function unresolved(options: ImportedFromElsewhere) { + return options.name + } + + const localExportList = () => 'local' + export {localExportList} + + export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => { + return params.foo + } + + export function status() { + return 'ok' + } + `, + exports: { + loadSomeInternalThing: (input: any) => input.foo, + localExportList: () => 'local', + missingAnnotation: (name: string) => 'hi ' + name, + status: () => 'ok', + unresolved: (options: any) => options.name, + }, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'The type of parameter "options" of "deploy" references "ImportedFromElsewhere", which couldn\'t be resolved. Declare it as `type X = {...}` or `interface X {...}` in the same file, import it from a relative file-backed module, or inline the type.', - ) + + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('missing-annotation') + expect(help).not.toContain('unresolved') + expect(help).not.toContain('local-export-list') + expect(help).not.toContain('load-some-internal-thing') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) -test('module commands: exported function with no parseable declaration errors clearly', async () => { +test('module commands: module with only ignored function exports errors with no commands found', async () => { const params = { - // `export {fn}` statements aren't supported by the extractor - the error should say so - source: `const start = () => 'started'\nexport {start}`, - exports: {start: () => 'started'}, + source: ` + export function greet(name) { + return 'hi ' + name + } + + export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => { + return params.foo + } + `, + exports: {greet: (name: string) => 'hi ' + name, loadSomeInternalThing: (input: any) => input.foo}, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - /Could not find a parseable declaration for exported function\(s\) "start"/, - ) + await expect(runWith(params, ['--help'])).rejects.toThrowError(/No commands found in module/) }) test('module commands: export * merges child module commands at the root', async () => { @@ -434,54 +459,51 @@ test('module positionals: missing and invalid positionals fail before the functi `) }) -test('module positionals: rest parameters error clearly', async () => { +test('module positionals: unsupported signatures are ignored', async () => { const params = { - source: `export function sum(...numbers: number[]) { return 0 }`, - exports: {sum: () => 0}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter "...numbers" of "sum" is a rest parameter, which isn\'t supported. Use an explicitly-typed array parameter (e.g. `numbers: number[]`, which becomes a variadic positional argument), or move it into a trailing options object.', - ) -}) + source: ` + export function sum(...numbers: number[]) { + return 0 + } -test('module positionals: destructured positional parameters error clearly', async () => { - const params = { - source: `export function move([x, y]: [number, number], options: {fast?: boolean}) {}`, - exports: {move: () => {}}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter 1 ("[number, number]") of "move" is a destructuring pattern, which isn\'t supported for positional arguments. Give the parameter a name, or move it into a trailing options object.', - ) -}) + export function move([x, y]: [number, number], options: {fast?: boolean}) { + return x + y + } -test('module positionals: object parameter in non-final position errors clearly', async () => { - const params = { - source: `export function deploy(options: {env: string}, target: string) {}`, - exports: {deploy: () => {}}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter 1 ("options") of "deploy" is an object type, but only the *last* parameter can be an object - leading parameters become positional arguments and a trailing object parameter maps to flags. Move it to the end, or flatten it into the trailing options object.', - ) -}) + export function deploy(options: {env: string}, target: string) { + return target + } -test('module positionals: optional array parameter errors clearly', async () => { - const params = { - source: `export function lint(files?: string[]) {}`, - exports: {lint: () => {}}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter 1 ("files") of "lint" is an optional array. Optional array parameters aren\'t supported as positional arguments - make it required, or move it into a trailing options object.', - ) -}) + export function lint(files?: string[]) { + return files?.join(',') || '' + } -test('module positionals: default value without a type annotation errors clearly', async () => { - const params = { - source: `export function pad(text: string, width = 10) { return text }`, - exports: {pad: (text: string) => text}, + export function pad(text: string, width = 10) { + return text + width + } + + export function status() { + return 'ok' + } + `, + exports: { + deploy: (_options: any, target: string) => target, + lint: (files?: string[]) => files?.join(',') || '', + move: ([x, y]: [number, number]) => x + y, + pad: (text: string, width = 10) => text + width, + status: () => 'ok', + sum: () => 0, + }, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter "width" of "pad" has no type annotation. Annotate it, e.g. `(width: string)` or `(width: {someFlag: string})`.', - ) + + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('sum') + expect(help).not.toContain('move') + expect(help).not.toContain('deploy') + expect(help).not.toContain('lint') + expect(help).not.toContain('pad') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) test('module commands: intersection and multi-line union type aliases keep their tails', async () => { @@ -555,7 +577,7 @@ test('module commands: generic type parameters containing => are skipped correct const params = { // without the => exception in findBalancedEnd, the `>` of `() => void` would close the generic // bracket early and the whole declaration would mis-slice. (A *parameter* typed as a generic like - // `callback?: T` is a different story - it errors as an unresolvable reference, by design.) + // `callback?: T` is a different story - it is ignored unless the type can resolve into a CLI input.) source: ` export async function run void>(options: {name: string}) { return 'ran ' + options.name @@ -745,19 +767,23 @@ test('module commands: async const arrow with destructured param', async () => { ) }) -test('module commands: type-annotated const declarations error with parseable-declaration guidance', async () => { +test('module commands: type-annotated const function exports are ignored', async () => { const params = { - // `export const f: SomeType = ...` isn't parsed (the annotation would be the source of truth, and it can - // reference imported types the extractor can't see) - the existing actionable error applies + // `export const f: SomeType = ...` isn't parsed; the annotation would be the source of truth, and it can + // reference imported types the extractor can't see. source: ` type Cmd = (options: {name: string}) => string export const greet: Cmd = (options) => 'hi ' + options.name + export function status() { + return 'ok' + } `, - exports: {greet: (options: any) => 'hi ' + options.name}, + exports: {greet: (options: any) => 'hi ' + options.name, status: () => 'ok'}, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - /Could not find a parseable declaration for exported function\(s\) "greet"/, - ) + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('greet') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) test('module commands: exported classes create lazily-instantiated command groups', async () => { From 13f3909a68a6bb982c75849967b7ea40047d4f6a Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:21:27 +0100 Subject: [PATCH 11/11] Allow boolean prompt run option --- README.md | 16 +++++++++++++++- src/types.ts | 2 +- test/prompts.test.ts | 3 +++ test/types.test.ts | 1 + 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 16b517b9..6f6b7d60 100644 --- a/README.md +++ b/README.md @@ -1461,7 +1461,21 @@ const cli = createCli({router: myRouter}) cli.run({prompts: true}) ``` -The user will then be asked to input any missing arguments or options. Booleans, numbers, enums etc. will get appropriate user-friendly prompts, along with input validation. The built-in prompts intentionally use plain line input: selects are numbered and checkboxes accept comma-separated numbers. If you want a richer terminal UI, install and pass `enquirer`, `prompts`, `@clack/prompts`, or `@inquirer/prompts` instead: +The user will then be asked to input any missing arguments or options. Booleans, numbers, enums etc. will get appropriate user-friendly prompts, along with input validation. The built-in prompts intentionally use plain line input: selects are numbered and checkboxes accept comma-separated numbers. + +You can pass a boolean expression too. `false` disables prompting, so this uses the built-in prompts unless trpc-cli detects a coding-agent environment: + +```ts +import {createCli, isAgent} from 'trpc-cli' + +const cli = createCli({router: myRouter}) + +await cli.run({ + prompts: !isAgent(), +}) +``` + +If you want a richer terminal UI, install and pass `enquirer`, `prompts`, `@clack/prompts`, or `@inquirer/prompts` instead: ```ts import * as prompts from '@inquirer/prompts' // or import * as prompts from 'enquirer', or import * as prompts from 'prompts' diff --git a/src/types.ts b/src/types.ts index 51d41426..26e23a9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -288,7 +288,7 @@ export type TrpcCliRunParams = { argv?: string[] logger?: Logger completion?: OmeletteInstanceLike | (() => Promise) - prompts?: Promptable | true | null + prompts?: Promptable | boolean | null /** Format an error thrown by the root procedure before logging to `logger.error` */ formatError?: (error: unknown) => string process?: { diff --git a/test/prompts.test.ts b/test/prompts.test.ts index d2ecb67c..e65995ba 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -38,7 +38,10 @@ test('prompts package types', async () => { }) test('built-in prompt types', () => { + const enablePrompts: boolean = false expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: true}) + expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: false}) + expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: enablePrompts}) expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: builtInPrompts}) expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: createBuiltInPrompts()}) }) diff --git a/test/types.test.ts b/test/types.test.ts index 5ecdab22..e2a6d547 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -14,6 +14,7 @@ test('prompt types', async () => { test('agent-aware prompt disabling type', async () => { expectTypeOf({prompts: isAgent({}) ? null : ({} as Promptable)}).toMatchTypeOf() + expectTypeOf({prompts: !isAgent({})}).toMatchTypeOf() }) test('jsonInput createCli param type', async () => {