Skip to content

Commit 41ae536

Browse files
committed
feat: improve unknown command UX with aliases, default routing, and suggestions
Address CLI-Q3 (183 events, 85 users) by adding three complementary mechanisms for handling unrecognized commands: - Add `defaultCommand: "view"` to all 8 route groups so bare IDs route directly (e.g., `sentry issue CLI-G5` → `sentry issue view`) - Add `show` as alias for `view` on all route maps, `remove` for `delete` on project and widget routes - Add synonym suggestion registry for mutation commands, old sentry-cli commands, and cross-route confusion patterns Three edge cases handled in app.ts: - Case A: bare route group (`sentry issue`) → usage hint - Case B: multi-arg synonym (`sentry issue events CLI-AB`) → tip - Case C: single-arg synonym (`sentry issue resolve`) → tip appended to domain error Filed #632 (issue events) and #633 (event list) for missing commands.
1 parent 5f0e420 commit 41ae536

File tree

13 files changed

+524
-14
lines changed

13 files changed

+524
-14
lines changed

src/app.ts

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
buildRouteMap,
77
text_en,
88
UnexpectedPositionalError,
9+
UnsatisfiedPositionalError,
910
} from "@stricli/core";
1011
import { apiCommand } from "./commands/api.js";
1112
import { authRoute } from "./commands/auth/index.js";
@@ -36,6 +37,11 @@ import { traceRoute } from "./commands/trace/index.js";
3637
import { listCommand as traceListCommand } from "./commands/trace/list.js";
3738
import { trialRoute } from "./commands/trial/index.js";
3839
import { 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";
3945
import { CLI_VERSION } from "./lib/constants.js";
4046
import {
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) {

src/commands/dashboard/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const dashboardRoute = buildRouteMap({
1111
create: createCommand,
1212
widget: widgetRoute,
1313
},
14+
defaultCommand: "view",
15+
aliases: { show: "view" },
1416
docs: {
1517
brief: "Manage Sentry dashboards",
1618
fullDescription:

src/commands/dashboard/widget/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const widgetRoute = buildRouteMap({
99
edit: editCommand,
1010
delete: deleteCommand,
1111
},
12+
aliases: { remove: "delete" },
1213
docs: {
1314
brief: "Manage dashboard widgets",
1415
fullDescription:

src/commands/event/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export const eventRoute = buildRouteMap({
55
routes: {
66
view: viewCommand,
77
},
8+
defaultCommand: "view",
9+
aliases: { show: "view" },
810
docs: {
911
brief: "View Sentry events",
1012
fullDescription:

src/commands/issue/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const issueRoute = buildRouteMap({
1111
plan: planCommand,
1212
view: viewCommand,
1313
},
14+
defaultCommand: "view",
15+
aliases: { show: "view" },
1416
docs: {
1517
brief: "Manage Sentry issues",
1618
fullDescription:

src/commands/log/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const logRoute = buildRouteMap({
1313
list: listCommand,
1414
view: viewCommand,
1515
},
16+
defaultCommand: "view",
17+
aliases: { show: "view" },
1618
docs: {
1719
brief: "View Sentry logs",
1820
fullDescription:

src/commands/org/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export const orgRoute = buildRouteMap({
77
list: listCommand,
88
view: viewCommand,
99
},
10+
defaultCommand: "view",
11+
aliases: { show: "view" },
1012
docs: {
1113
brief: "Work with Sentry organizations",
1214
fullDescription:

src/commands/project/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const projectRoute = buildRouteMap({
1111
list: listCommand,
1212
view: viewCommand,
1313
},
14+
defaultCommand: "view",
15+
aliases: { show: "view", remove: "delete" },
1416
docs: {
1517
brief: "Work with Sentry projects",
1618
fullDescription:

src/commands/span/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const spanRoute = buildRouteMap({
1313
list: listCommand,
1414
view: viewCommand,
1515
},
16+
defaultCommand: "view",
17+
aliases: { show: "view" },
1618
docs: {
1719
brief: "List and view spans in projects or traces",
1820
fullDescription:

src/commands/trace/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const traceRoute = buildRouteMap({
1515
view: viewCommand,
1616
logs: logsCommand,
1717
},
18+
defaultCommand: "view",
19+
aliases: { show: "view" },
1820
docs: {
1921
brief: "View distributed traces",
2022
fullDescription:

0 commit comments

Comments
 (0)