Skip to content

Commit 9873e49

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 e6ae353 commit 9873e49

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,
@@ -121,6 +127,46 @@ export const routes = buildRouteMap({
121127
},
122128
});
123129

130+
/**
131+
* Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`).
132+
*
133+
* With `defaultCommand: "view"` on route groups, Stricli routes to the view
134+
* command which then fails with UnsatisfiedPositionalError because no issue ID
135+
* was provided. Returns a usage hint string, or undefined if this isn't the
136+
* bare-route-group case.
137+
*/
138+
function detectBareRouteGroup(ansiColor: boolean): string | undefined {
139+
const args = process.argv.slice(2);
140+
const nonFlags = args.filter((t) => !t.startsWith("-"));
141+
if (
142+
nonFlags.length <= 1 &&
143+
nonFlags[0] &&
144+
ROUTES_WITH_DEFAULT_VIEW.has(nonFlags[0])
145+
) {
146+
const route = nonFlags[0];
147+
const msg = `Usage: sentry ${route} <command> [args]\nRun "sentry ${route} --help" to see available commands`;
148+
return ansiColor ? warning(msg) : msg;
149+
}
150+
return;
151+
}
152+
153+
/**
154+
* Detect when a plural alias received extra positional args and suggest the
155+
* singular form. E.g., `sentry projects view cli` → `sentry project view cli`.
156+
*/
157+
function detectPluralAliasMisuse(ansiColor: boolean): string | undefined {
158+
const args = process.argv.slice(2);
159+
const firstArg = args[0];
160+
if (firstArg && firstArg in PLURAL_TO_SINGULAR) {
161+
const singular = PLURAL_TO_SINGULAR[firstArg];
162+
const rest = args.slice(1).join(" ");
163+
return ansiColor
164+
? warning(`\nDid you mean: sentry ${singular} ${rest}\n`)
165+
: `\nDid you mean: sentry ${singular} ${rest}\n`;
166+
}
167+
return;
168+
}
169+
124170
/**
125171
* Custom error formatting for CLI errors.
126172
*
@@ -134,23 +180,65 @@ const customText: ApplicationText = {
134180
exc: unknown,
135181
ansiColor: boolean
136182
): 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.
183+
// Case A: bare route group with no subcommand (e.g., `sentry issue`)
184+
if (exc instanceof UnsatisfiedPositionalError) {
185+
const bareHint = detectBareRouteGroup(ansiColor);
186+
if (bareHint) {
187+
return bareHint;
188+
}
189+
}
190+
191+
// Case B + plural alias: extra args that Stricli can't consume
140192
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}`;
193+
const pluralHint = detectPluralAliasMisuse(ansiColor);
194+
if (pluralHint) {
195+
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`;
196+
}
197+
198+
// With defaultCommand: "view", unknown tokens like "events" fill the
199+
// positional slot, then extra args (e.g., CLI-AB) trigger this error.
200+
// Check if the first non-route token is a known synonym.
201+
const synonymHint = getSynonymSuggestionFromArgv();
202+
if (synonymHint) {
203+
const tip = ansiColor
204+
? warning(`\nTip: ${synonymHint}`)
205+
: `\nTip: ${synonymHint}`;
206+
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`;
150207
}
151208
}
209+
152210
return text_en.exceptionWhileParsingArguments(exc, ansiColor);
153211
},
212+
noCommandRegisteredForInput: ({ input, corrections, ansiColor }): string => {
213+
// Default error message from Stricli (e.g., "No command registered for `info`")
214+
const base = text_en.noCommandRegisteredForInput({
215+
input,
216+
corrections,
217+
ansiColor,
218+
});
219+
220+
// Check for known synonym suggestions on routes without defaultCommand
221+
// (e.g., `sentry cli info` → suggest `sentry auth status`).
222+
// Routes WITH defaultCommand won't reach here — their unknown tokens
223+
// are consumed as positional args and handled by Cases A/B/C above.
224+
const args = process.argv.slice(2);
225+
const nonFlags = args.filter((t) => !t.startsWith("-"));
226+
const routeContext = nonFlags[0] ?? "";
227+
const suggestion = getCommandSuggestion(routeContext, input);
228+
if (suggestion) {
229+
const hint = suggestion.explanation
230+
? `${suggestion.explanation}: ${suggestion.command}`
231+
: suggestion.command;
232+
// Stricli wraps our return value in bold-red ANSI codes.
233+
// Reset before applying warning() color so the tip is yellow, not red.
234+
const formatted = ansiColor
235+
? `\n\x1B[39m\x1B[22m${warning(`Tip: ${hint}`)}`
236+
: `\nTip: ${hint}`;
237+
return `${base}${formatted}`;
238+
}
239+
240+
return base;
241+
},
154242
exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => {
155243
// OutputError: data was already rendered to stdout — just re-throw
156244
// so the exit code propagates without Stricli printing an error message.
@@ -174,6 +262,17 @@ const customText: ApplicationText = {
174262

175263
if (exc instanceof CliError) {
176264
const prefix = ansiColor ? errorColor("Error:") : "Error:";
265+
// Case C: With defaultCommand: "view", unknown tokens like "events" are
266+
// silently consumed as the positional arg. The view command fails at the
267+
// domain level (e.g., ResolutionError). Check argv for a known synonym
268+
// and append the suggestion to the error.
269+
const synonymHint = getSynonymSuggestionFromArgv();
270+
if (synonymHint) {
271+
const tip = ansiColor
272+
? warning(`Tip: ${synonymHint}`)
273+
: `Tip: ${synonymHint}`;
274+
return `${prefix} ${exc.format()}\n${tip}`;
275+
}
177276
return `${prefix} ${exc.format()}`;
178277
}
179278
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)