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,11 @@ 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+ ROUTES_WITH_DEFAULT_VIEW ,
44+ } from "./lib/command-suggestions.js" ;
3945import { CLI_VERSION } from "./lib/constants.js" ;
4046import {
4147 AuthError ,
@@ -120,6 +126,46 @@ export const routes = buildRouteMap({
120126 } ,
121127} ) ;
122128
129+ /**
130+ * Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`).
131+ *
132+ * With `defaultCommand: "view"` on route groups, Stricli routes to the view
133+ * command which then fails with UnsatisfiedPositionalError because no issue ID
134+ * was provided. Returns a usage hint string, or undefined if this isn't the
135+ * bare-route-group case.
136+ */
137+ function detectBareRouteGroup ( ansiColor : boolean ) : string | undefined {
138+ const args = process . argv . slice ( 2 ) ;
139+ const nonFlags = args . filter ( ( t ) => ! t . startsWith ( "-" ) ) ;
140+ if (
141+ nonFlags . length <= 1 &&
142+ nonFlags [ 0 ] &&
143+ ROUTES_WITH_DEFAULT_VIEW . has ( nonFlags [ 0 ] )
144+ ) {
145+ const route = nonFlags [ 0 ] ;
146+ const msg = `Usage: sentry ${ route } <command> [args]\nRun "sentry ${ route } --help" to see available commands` ;
147+ return ansiColor ? warning ( msg ) : msg ;
148+ }
149+ return ;
150+ }
151+
152+ /**
153+ * Detect when a plural alias received extra positional args and suggest the
154+ * singular form. E.g., `sentry projects view cli` → `sentry project view cli`.
155+ */
156+ function detectPluralAliasMisuse ( ansiColor : boolean ) : string | undefined {
157+ const args = process . argv . slice ( 2 ) ;
158+ const firstArg = args [ 0 ] ;
159+ if ( firstArg && firstArg in PLURAL_TO_SINGULAR ) {
160+ const singular = PLURAL_TO_SINGULAR [ firstArg ] ;
161+ const rest = args . slice ( 1 ) . join ( " " ) ;
162+ return ansiColor
163+ ? warning ( `\nDid you mean: sentry ${ singular } ${ rest } \n` )
164+ : `\nDid you mean: sentry ${ singular } ${ rest } \n` ;
165+ }
166+ return ;
167+ }
168+
123169/**
124170 * Custom error formatting for CLI errors.
125171 *
@@ -133,23 +179,65 @@ const customText: ApplicationText = {
133179 exc : unknown ,
134180 ansiColor : boolean
135181 ) : string => {
136- // When a plural alias receives extra positional args (e.g. `sentry projects view cli`),
137- // Stricli throws UnexpectedPositionalError because the list command only accepts 1 arg.
138- // Detect this and suggest the singular form.
182+ // Case A: bare route group with no subcommand (e.g., `sentry issue`)
183+ if ( exc instanceof UnsatisfiedPositionalError ) {
184+ const bareHint = detectBareRouteGroup ( ansiColor ) ;
185+ if ( bareHint ) {
186+ return bareHint ;
187+ }
188+ }
189+
190+ // Case B + plural alias: extra args that Stricli can't consume
139191 if ( exc instanceof UnexpectedPositionalError ) {
140- const args = process . argv . slice ( 2 ) ;
141- const firstArg = args [ 0 ] ;
142- if ( firstArg && firstArg in PLURAL_TO_SINGULAR ) {
143- const singular = PLURAL_TO_SINGULAR [ firstArg ] ;
144- const rest = args . slice ( 1 ) . join ( " " ) ;
145- const hint = ansiColor
146- ? warning ( `\nDid you mean: sentry ${ singular } ${ rest } \n` )
147- : `\nDid you mean: sentry ${ singular } ${ rest } \n` ;
148- return `${ text_en . exceptionWhileParsingArguments ( exc , ansiColor ) } ${ hint } ` ;
192+ const pluralHint = detectPluralAliasMisuse ( ansiColor ) ;
193+ if ( pluralHint ) {
194+ return `${ text_en . exceptionWhileParsingArguments ( exc , ansiColor ) } ${ pluralHint } ` ;
195+ }
196+
197+ // With defaultCommand: "view", unknown tokens like "events" fill the
198+ // positional slot, then extra args (e.g., CLI-AB) trigger this error.
199+ // Check if the first non-route token is a known synonym.
200+ const synonymHint = getSynonymSuggestionFromArgv ( ) ;
201+ if ( synonymHint ) {
202+ const tip = ansiColor
203+ ? warning ( `\nTip: ${ synonymHint } ` )
204+ : `\nTip: ${ synonymHint } ` ;
205+ return `${ text_en . exceptionWhileParsingArguments ( exc , ansiColor ) } ${ tip } ` ;
149206 }
150207 }
208+
151209 return text_en . exceptionWhileParsingArguments ( exc , ansiColor ) ;
152210 } ,
211+ noCommandRegisteredForInput : ( { input, corrections, ansiColor } ) : string => {
212+ // Default error message from Stricli (e.g., "No command registered for `info`")
213+ const base = text_en . noCommandRegisteredForInput ( {
214+ input,
215+ corrections,
216+ ansiColor,
217+ } ) ;
218+
219+ // Check for known synonym suggestions on routes without defaultCommand
220+ // (e.g., `sentry cli info` → suggest `sentry auth status`).
221+ // Routes WITH defaultCommand won't reach here — their unknown tokens
222+ // are consumed as positional args and handled by Cases A/B/C above.
223+ const args = process . argv . slice ( 2 ) ;
224+ const nonFlags = args . filter ( ( t ) => ! t . startsWith ( "-" ) ) ;
225+ const routeContext = nonFlags [ 0 ] ?? "" ;
226+ const suggestion = getCommandSuggestion ( routeContext , input ) ;
227+ if ( suggestion ) {
228+ const hint = suggestion . explanation
229+ ? `${ suggestion . explanation } : ${ suggestion . command } `
230+ : suggestion . command ;
231+ // Stricli wraps our return value in bold-red ANSI codes.
232+ // Reset before applying warning() color so the tip is yellow, not red.
233+ const formatted = ansiColor
234+ ? `\n\x1B[39m\x1B[22m${ warning ( `Tip: ${ hint } ` ) } `
235+ : `\nTip: ${ hint } ` ;
236+ return `${ base } ${ formatted } ` ;
237+ }
238+
239+ return base ;
240+ } ,
153241 exceptionWhileRunningCommand : ( exc : unknown , ansiColor : boolean ) : string => {
154242 // Re-throw AuthError for auto-login flow in bin.ts
155243 // Don't capture to Sentry - it's an expected state (user not logged in or token expired), not an error
@@ -167,6 +255,17 @@ const customText: ApplicationText = {
167255
168256 if ( exc instanceof CliError ) {
169257 const prefix = ansiColor ? errorColor ( "Error:" ) : "Error:" ;
258+ // Case C: With defaultCommand: "view", unknown tokens like "events" are
259+ // silently consumed as the positional arg. The view command fails at the
260+ // domain level (e.g., ResolutionError). Check argv for a known synonym
261+ // and append the suggestion to the error.
262+ const synonymHint = getSynonymSuggestionFromArgv ( ) ;
263+ if ( synonymHint ) {
264+ const tip = ansiColor
265+ ? warning ( `Tip: ${ synonymHint } ` )
266+ : `Tip: ${ synonymHint } ` ;
267+ return `${ prefix } ${ exc . format ( ) } \n${ tip } ` ;
268+ }
170269 return `${ prefix } ${ exc . format ( ) } ` ;
171270 }
172271 if ( exc instanceof Error ) {
0 commit comments