66 buildRouteMap ,
77 text_en ,
88 UnexpectedPositionalError ,
9+ UnsatisfiedPositionalError ,
910} from "@stricli/core" ;
1011import { apiCommand } from "./commands/api.js" ;
1112import { authRoute } from "./commands/auth/index.js" ;
@@ -36,6 +37,10 @@ import { traceRoute } from "./commands/trace/index.js";
3637import { listCommand as traceListCommand } from "./commands/trace/list.js" ;
3738import { trialRoute } from "./commands/trial/index.js" ;
3839import { listCommand as trialListCommand } from "./commands/trial/list.js" ;
40+ import {
41+ getCommandSuggestion ,
42+ getSynonymSuggestionFromArgv ,
43+ } from "./lib/command-suggestions.js" ;
3944import { CLI_VERSION } from "./lib/constants.js" ;
4045import {
4146 AuthError ,
@@ -45,6 +50,7 @@ import {
4550 stringifyUnknown ,
4651} from "./lib/errors.js" ;
4752import { error as errorColor , warning } from "./lib/formatters/colors.js" ;
53+ import { isRouteMap , type RouteMap } from "./lib/introspect.js" ;
4854
4955/**
5056 * Plural alias → singular route name mapping.
@@ -121,6 +127,89 @@ export const routes = buildRouteMap({
121127 } ,
122128} ) ;
123129
130+ /**
131+ * Route group names that have `defaultCommand` set.
132+ *
133+ * Derived from the route map at module load time — no manual list to maintain.
134+ * Used to detect the no-args case (`sentry issue` with no subcommand)
135+ * so we can show a usage hint instead of a confusing parse error.
136+ */
137+ const routesWithDefaultCommand : ReadonlySet < string > = new Set (
138+ routes
139+ . getAllEntries ( )
140+ . filter (
141+ ( e ) =>
142+ isRouteMap ( e . target as unknown ) &&
143+ ( e . target as unknown as RouteMap ) . getDefaultCommand ?.( )
144+ )
145+ . map ( ( e ) => e . name . original )
146+ ) ;
147+
148+ /**
149+ * Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`).
150+ *
151+ * With `defaultCommand: "view"` on route groups, Stricli routes to the view
152+ * command which then fails with UnsatisfiedPositionalError because no issue ID
153+ * was provided. Returns a usage hint string, or undefined if this isn't the
154+ * bare-route-group case.
155+ */
156+ function detectBareRouteGroup ( ansiColor : boolean ) : string | undefined {
157+ const args = process . argv . slice ( 2 ) ;
158+ const nonFlags = args . filter ( ( t ) => ! t . startsWith ( "-" ) ) ;
159+ if (
160+ nonFlags . length <= 1 &&
161+ nonFlags [ 0 ] &&
162+ routesWithDefaultCommand . has ( nonFlags [ 0 ] )
163+ ) {
164+ const route = nonFlags [ 0 ] ;
165+ const msg = `Usage: sentry ${ route } <command> [args]\nRun "sentry ${ route } --help" to see available commands` ;
166+ return ansiColor ? warning ( msg ) : msg ;
167+ }
168+ return ;
169+ }
170+
171+ /**
172+ * Detect when a plural alias received extra positional args and suggest the
173+ * singular form. E.g., `sentry projects view cli` → `sentry project view cli`.
174+ */
175+ function detectPluralAliasMisuse ( ansiColor : boolean ) : string | undefined {
176+ const args = process . argv . slice ( 2 ) ;
177+ const firstArg = args [ 0 ] ;
178+ if ( firstArg && firstArg in PLURAL_TO_SINGULAR ) {
179+ const singular = PLURAL_TO_SINGULAR [ firstArg ] ;
180+ const rest = args . slice ( 1 ) . join ( " " ) ;
181+ return ansiColor
182+ ? warning ( `\nDid you mean: sentry ${ singular } ${ rest } \n` )
183+ : `\nDid you mean: sentry ${ singular } ${ rest } \n` ;
184+ }
185+ return ;
186+ }
187+
188+ /**
189+ * Format a CliError with a synonym suggestion when the user typed a known
190+ * synonym that was consumed as a positional arg by `defaultCommand: "view"`.
191+ *
192+ * Returns the formatted error string if a synonym match is found,
193+ * undefined otherwise. Skips Sentry capture for these known user mistakes.
194+ */
195+ function formatSynonymError (
196+ exc : unknown ,
197+ ansiColor : boolean
198+ ) : string | undefined {
199+ if ( ! ( exc instanceof CliError ) ) {
200+ return ;
201+ }
202+ const synonymHint = getSynonymSuggestionFromArgv ( ) ;
203+ if ( ! synonymHint ) {
204+ return ;
205+ }
206+ const prefix = ansiColor ? errorColor ( "Error:" ) : "Error:" ;
207+ const tip = ansiColor
208+ ? warning ( `Tip: ${ synonymHint } ` )
209+ : `Tip: ${ synonymHint } ` ;
210+ return `${ prefix } ${ exc . format ( ) } \n${ tip } ` ;
211+ }
212+
124213/**
125214 * Custom error formatting for CLI errors.
126215 *
@@ -134,23 +223,65 @@ const customText: ApplicationText = {
134223 exc : unknown ,
135224 ansiColor : boolean
136225 ) : string => {
137- // When a plural alias receives extra positional args (e.g. `sentry projects view cli`),
138- // Stricli throws UnexpectedPositionalError because the list command only accepts 1 arg.
139- // Detect this and suggest the singular form.
226+ // Case A: bare route group with no subcommand (e.g., `sentry issue`)
227+ if ( exc instanceof UnsatisfiedPositionalError ) {
228+ const bareHint = detectBareRouteGroup ( ansiColor ) ;
229+ if ( bareHint ) {
230+ return bareHint ;
231+ }
232+ }
233+
234+ // Case B + plural alias: extra args that Stricli can't consume
140235 if ( exc instanceof UnexpectedPositionalError ) {
141- const args = process . argv . slice ( 2 ) ;
142- const firstArg = args [ 0 ] ;
143- if ( firstArg && firstArg in PLURAL_TO_SINGULAR ) {
144- const singular = PLURAL_TO_SINGULAR [ firstArg ] ;
145- const rest = args . slice ( 1 ) . join ( " " ) ;
146- const hint = ansiColor
147- ? warning ( `\nDid you mean: sentry ${ singular } ${ rest } \n` )
148- : `\nDid you mean: sentry ${ singular } ${ rest } \n` ;
149- return `${ text_en . exceptionWhileParsingArguments ( exc , ansiColor ) } ${ hint } ` ;
236+ const pluralHint = detectPluralAliasMisuse ( ansiColor ) ;
237+ if ( pluralHint ) {
238+ return `${ text_en . exceptionWhileParsingArguments ( exc , ansiColor ) } ${ pluralHint } ` ;
239+ }
240+
241+ // With defaultCommand: "view", unknown tokens like "events" fill the
242+ // positional slot, then extra args (e.g., CLI-AB) trigger this error.
243+ // Check if the first non-route token is a known synonym.
244+ const synonymHint = getSynonymSuggestionFromArgv ( ) ;
245+ if ( synonymHint ) {
246+ const tip = ansiColor
247+ ? warning ( `\nTip: ${ synonymHint } ` )
248+ : `\nTip: ${ synonymHint } ` ;
249+ return `${ text_en . exceptionWhileParsingArguments ( exc , ansiColor ) } ${ tip } ` ;
150250 }
151251 }
252+
152253 return text_en . exceptionWhileParsingArguments ( exc , ansiColor ) ;
153254 } ,
255+ noCommandRegisteredForInput : ( { input, corrections, ansiColor } ) : string => {
256+ // Default error message from Stricli (e.g., "No command registered for `info`")
257+ const base = text_en . noCommandRegisteredForInput ( {
258+ input,
259+ corrections,
260+ ansiColor,
261+ } ) ;
262+
263+ // Check for known synonym suggestions on routes without defaultCommand
264+ // (e.g., `sentry cli info` → suggest `sentry auth status`).
265+ // Routes WITH defaultCommand won't reach here — their unknown tokens
266+ // are consumed as positional args and handled by Cases A/B/C above.
267+ const args = process . argv . slice ( 2 ) ;
268+ const nonFlags = args . filter ( ( t ) => ! t . startsWith ( "-" ) ) ;
269+ const routeContext = nonFlags [ 0 ] ?? "" ;
270+ const suggestion = getCommandSuggestion ( routeContext , input ) ;
271+ if ( suggestion ) {
272+ const hint = suggestion . explanation
273+ ? `${ suggestion . explanation } : ${ suggestion . command } `
274+ : suggestion . command ;
275+ // Stricli wraps our return value in bold-red ANSI codes.
276+ // Reset before applying warning() color so the tip is yellow, not red.
277+ const formatted = ansiColor
278+ ? `\n\x1B[39m\x1B[22m${ warning ( `Tip: ${ hint } ` ) } `
279+ : `\nTip: ${ hint } ` ;
280+ return `${ base } ${ formatted } ` ;
281+ }
282+
283+ return base ;
284+ } ,
154285 exceptionWhileRunningCommand : ( exc : unknown , ansiColor : boolean ) : string => {
155286 // OutputError: data was already rendered to stdout — just re-throw
156287 // so the exit code propagates without Stricli printing an error message.
@@ -168,6 +299,16 @@ const customText: ApplicationText = {
168299 throw exc ;
169300 }
170301
302+ // Case C: With defaultCommand: "view", unknown tokens like "events" are
303+ // silently consumed as the positional arg. The view command fails at the
304+ // domain level (e.g., ResolutionError). Check argv for a known synonym
305+ // and show the suggestion — skip Sentry capture since these are known
306+ // user mistakes, not real errors.
307+ const synonymResult = formatSynonymError ( exc , ansiColor ) ;
308+ if ( synonymResult ) {
309+ return synonymResult ;
310+ }
311+
171312 // Report command errors to Sentry. Stricli catches exceptions and doesn't
172313 // re-throw, so we must capture here to get visibility into command failures.
173314 Sentry . captureException ( exc ) ;
0 commit comments