1313 * buildOrgListCommand
1414 */
1515
16- import type { Aliases , Command } from "@stricli/core" ;
16+ import type {
17+ Aliases ,
18+ Command ,
19+ CommandContext ,
20+ CommandFunction ,
21+ } from "@stricli/core" ;
1722import type { SentryContext } from "../context.js" ;
1823import { parseOrgProjectArg } from "./arg-parsing.js" ;
1924import { buildCommand , numberParser } from "./command.js" ;
25+ import { warning } from "./formatters/colors.js" ;
2026import { dispatchOrgScopedList , type OrgListConfig } from "./org-list.js" ;
2127
2228// ---------------------------------------------------------------------------
@@ -125,7 +131,157 @@ export function buildListLimitFlag(
125131export const LIST_BASE_ALIASES : Aliases < string > = { n : "limit" , c : "cursor" } ;
126132
127133// ---------------------------------------------------------------------------
128- // Level B: full command builder for dispatchOrgScopedList-based commands
134+ // Level B: subcommand interception for plural aliases
135+ // ---------------------------------------------------------------------------
136+
137+ let _subcommandsByRoute : Map < string , Set < string > > | undefined ;
138+
139+ /**
140+ * Get the subcommand names for a given singular route (e.g. "project" → {"list", "view"}).
141+ *
142+ * Lazily walks the Stricli route map on first call. Uses `require()` to break
143+ * the circular dependency: list-command → app → commands → list-command.
144+ */
145+ function getSubcommandsForRoute ( routeName : string ) : Set < string > {
146+ if ( ! _subcommandsByRoute ) {
147+ _subcommandsByRoute = new Map ( ) ;
148+
149+ const { routes } = require ( "../app.js" ) as {
150+ routes : {
151+ getAllEntries : ( ) => readonly {
152+ name : { original : string } ;
153+ target : unknown ;
154+ } [ ] ;
155+ } ;
156+ } ;
157+
158+ for ( const entry of routes . getAllEntries ( ) ) {
159+ const target = entry . target as unknown as Record < string , unknown > ;
160+ if ( typeof target ?. getAllEntries === "function" ) {
161+ const children = (
162+ target . getAllEntries as ( ) => readonly {
163+ name : { original : string } ;
164+ } [ ]
165+ ) ( ) ;
166+ const names = new Set < string > ( ) ;
167+ for ( const child of children ) {
168+ names . add ( child . name . original ) ;
169+ }
170+ _subcommandsByRoute . set ( entry . name . original , names ) ;
171+ }
172+ }
173+ }
174+
175+ return _subcommandsByRoute . get ( routeName ) ?? new Set ( ) ;
176+ }
177+
178+ /**
179+ * Check if a positional target is actually a subcommand name passed through
180+ * a plural alias (e.g. "list" from `sentry projects list`).
181+ *
182+ * When a plural alias like `sentry projects` maps directly to the list
183+ * command, Stricli passes extra tokens as positional args. If the token
184+ * matches a known subcommand of the singular route, we treat it as if no
185+ * target was given (auto-detect) and print a command-specific hint.
186+ *
187+ * @param target - The raw positional argument
188+ * @param stderr - Writable stream for the hint message
189+ * @param routeName - Singular route name (e.g. "project", "issue")
190+ * @returns The original target, or `undefined` if it was a subcommand name
191+ */
192+ export function interceptSubcommand (
193+ target : string | undefined ,
194+ stderr : { write ( s : string ) : void } ,
195+ routeName : string
196+ ) : string | undefined {
197+ if ( ! target ) {
198+ return target ;
199+ }
200+ const trimmed = target . trim ( ) ;
201+ if ( trimmed && getSubcommandsForRoute ( routeName ) . has ( trimmed ) ) {
202+ stderr . write (
203+ warning (
204+ `Tip: "${ trimmed } " is a subcommand. Running: sentry ${ routeName } ${ trimmed } \n`
205+ )
206+ ) ;
207+ return ;
208+ }
209+ return target ;
210+ }
211+
212+ // ---------------------------------------------------------------------------
213+ // Level C: list command builder with automatic subcommand interception
214+ // ---------------------------------------------------------------------------
215+
216+ /** Base flags type (mirrors command.ts) */
217+ type BaseFlags = Readonly < Partial < Record < string , unknown > > > ;
218+
219+ /**
220+ * Build a Stricli command for a list endpoint with automatic plural-alias
221+ * interception.
222+ *
223+ * This is a drop-in replacement for `buildCommand` that wraps the command
224+ * function to intercept subcommand names passed through plural aliases.
225+ * For example, when `sentry projects list` passes "list" as a positional
226+ * target to the project list command, it is intercepted and treated as
227+ * auto-detect mode with a command-specific hint on stderr.
228+ *
229+ * Usage:
230+ * ```ts
231+ * // Before:
232+ * import { buildCommand } from "../../lib/command.js";
233+ * export const listCommand = buildCommand({ ... });
234+ *
235+ * // After:
236+ * import { buildListCommand } from "../../lib/list-command.js";
237+ * export const listCommand = buildListCommand("project", { ... });
238+ * ```
239+ *
240+ * @param routeName - Singular route name (e.g. "project", "issue") for the
241+ * hint message and subcommand lookup
242+ * @param builderArgs - Same arguments as `buildCommand` from `lib/command.js`
243+ */
244+ export function buildListCommand <
245+ const FLAGS extends BaseFlags = NonNullable < unknown > ,
246+ const ARGS extends readonly unknown [ ] = [ ] ,
247+ const CONTEXT extends CommandContext = CommandContext ,
248+ > (
249+ routeName : string ,
250+ builderArgs : {
251+ readonly parameters ?: Record < string , unknown > ;
252+ readonly docs : {
253+ readonly brief : string ;
254+ readonly fullDescription ?: string ;
255+ } ;
256+ readonly func : CommandFunction < FLAGS , ARGS , CONTEXT > ;
257+ }
258+ ) : Command < CONTEXT > {
259+ const originalFunc = builderArgs . func ;
260+
261+ // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex
262+ const wrappedFunc = function ( this : CONTEXT , flags : FLAGS , ...args : any [ ] ) {
263+ // The first positional arg is always the target (org/project pattern).
264+ // Intercept it to handle plural alias confusion.
265+ if (
266+ args . length > 0 &&
267+ ( typeof args [ 0 ] === "string" || args [ 0 ] === undefined )
268+ ) {
269+ // All list commands use SentryContext which has stderr at top level
270+ const ctx = this as unknown as { stderr : { write ( s : string ) : void } } ;
271+ args [ 0 ] = interceptSubcommand (
272+ args [ 0 ] as string | undefined ,
273+ ctx . stderr ,
274+ routeName
275+ ) ;
276+ }
277+ return originalFunc . call ( this , flags , ...( args as unknown as ARGS ) ) ;
278+ } as typeof originalFunc ;
279+
280+ return buildCommand ( { ...builderArgs , func : wrappedFunc } ) ;
281+ }
282+
283+ // ---------------------------------------------------------------------------
284+ // Level D: full command builder for dispatchOrgScopedList-based commands
129285// ---------------------------------------------------------------------------
130286
131287/** Documentation strings for a list command built with `buildOrgListCommand`. */
@@ -151,9 +307,10 @@ export type OrgListCommandDocs = {
151307 */
152308export function buildOrgListCommand < TEntity , TWithOrg > (
153309 config : OrgListConfig < TEntity , TWithOrg > ,
154- docs : OrgListCommandDocs
310+ docs : OrgListCommandDocs ,
311+ routeName : string
155312) : Command < SentryContext > {
156- return buildCommand ( {
313+ return buildListCommand ( routeName , {
157314 docs,
158315 parameters : {
159316 positional : LIST_TARGET_POSITIONAL ,
0 commit comments