Skip to content

Commit 5ea70f6

Browse files
authored
feat: improve unknown command UX with aliases, default routing, and suggestions (#635)
## Summary Addresses [CLI-Q3](https://sentry.sentry.io/issues/7368412482/) — 183 events from 85 users hitting unknown commands. Three complementary mechanisms: - **`defaultCommand: "view"`** on all 8 route groups with a `view` subcommand — bare IDs route directly (e.g., `sentry issue CLI-G5` → `sentry issue view CLI-G5`) - **`show` alias for `view`** on all route maps, `remove` alias for `delete` on project/widget — uses Stricli's native `aliases` property - **Synonym suggestion registry** for patterns that can't be aliased — mutation commands (`issue resolve` → suggest `sentry api`), old sentry-cli commands (`cli info` → `sentry auth status`), cross-route confusion (`issue events` → `sentry issue view`) ### Edge cases handled in `app.ts` | Case | Trigger | Behavior | |------|---------|----------| | A: bare route group | `sentry issue` (no args) | Usage hint instead of cryptic parse error | | B: multi-arg synonym | `sentry issue events CLI-AB` | Stricli parse error + synonym tip | | C: single-arg synonym | `sentry issue resolve` | Domain error + synonym tip | | D: non-defaultCommand routes | `sentry cli info` | `noCommandRegisteredForInput` + synonym tip | ### Filed follow-up issues - #632 — `sentry issue events` command - #633 — `sentry event list` + `events` plural alias
1 parent e6ae353 commit 5ea70f6

14 files changed

Lines changed: 562 additions & 14 deletions

File tree

src/app.ts

Lines changed: 153 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,10 @@ 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+
} from "./lib/command-suggestions.js";
3944
import { CLI_VERSION } from "./lib/constants.js";
4045
import {
4146
AuthError,
@@ -45,6 +50,7 @@ import {
4550
stringifyUnknown,
4651
} from "./lib/errors.js";
4752
import { 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);

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)