From 9873e493259c1aca495fe389765701a3070ff39e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 1 Apr 2026 23:48:12 +0000 Subject: [PATCH 1/3] feat: improve unknown command UX with aliases, default routing, and suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.ts | 123 +++++++++++++-- src/commands/dashboard/index.ts | 2 + src/commands/dashboard/widget/index.ts | 1 + src/commands/event/index.ts | 2 + src/commands/issue/index.ts | 2 + src/commands/log/index.ts | 2 + src/commands/org/index.ts | 2 + src/commands/project/index.ts | 2 + src/commands/span/index.ts | 2 + src/commands/trace/index.ts | 2 + src/lib/command-suggestions.ts | 208 +++++++++++++++++++++++++ test/lib/command-suggestions.test.ts | 146 +++++++++++++++++ test/lib/completions.property.test.ts | 44 +++++- 13 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 src/lib/command-suggestions.ts create mode 100644 test/lib/command-suggestions.test.ts diff --git a/src/app.ts b/src/app.ts index 34d48719e..ab996cc03 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { buildRouteMap, text_en, UnexpectedPositionalError, + UnsatisfiedPositionalError, } from "@stricli/core"; import { apiCommand } from "./commands/api.js"; import { authRoute } from "./commands/auth/index.js"; @@ -36,6 +37,11 @@ import { traceRoute } from "./commands/trace/index.js"; import { listCommand as traceListCommand } from "./commands/trace/list.js"; import { trialRoute } from "./commands/trial/index.js"; import { listCommand as trialListCommand } from "./commands/trial/list.js"; +import { + getCommandSuggestion, + getSynonymSuggestionFromArgv, + ROUTES_WITH_DEFAULT_VIEW, +} from "./lib/command-suggestions.js"; import { CLI_VERSION } from "./lib/constants.js"; import { AuthError, @@ -121,6 +127,46 @@ export const routes = buildRouteMap({ }, }); +/** + * Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`). + * + * With `defaultCommand: "view"` on route groups, Stricli routes to the view + * command which then fails with UnsatisfiedPositionalError because no issue ID + * was provided. Returns a usage hint string, or undefined if this isn't the + * bare-route-group case. + */ +function detectBareRouteGroup(ansiColor: boolean): string | undefined { + const args = process.argv.slice(2); + const nonFlags = args.filter((t) => !t.startsWith("-")); + if ( + nonFlags.length <= 1 && + nonFlags[0] && + ROUTES_WITH_DEFAULT_VIEW.has(nonFlags[0]) + ) { + const route = nonFlags[0]; + const msg = `Usage: sentry ${route} [args]\nRun "sentry ${route} --help" to see available commands`; + return ansiColor ? warning(msg) : msg; + } + return; +} + +/** + * Detect when a plural alias received extra positional args and suggest the + * singular form. E.g., `sentry projects view cli` → `sentry project view cli`. + */ +function detectPluralAliasMisuse(ansiColor: boolean): string | undefined { + const args = process.argv.slice(2); + const firstArg = args[0]; + if (firstArg && firstArg in PLURAL_TO_SINGULAR) { + const singular = PLURAL_TO_SINGULAR[firstArg]; + const rest = args.slice(1).join(" "); + return ansiColor + ? warning(`\nDid you mean: sentry ${singular} ${rest}\n`) + : `\nDid you mean: sentry ${singular} ${rest}\n`; + } + return; +} + /** * Custom error formatting for CLI errors. * @@ -134,23 +180,65 @@ const customText: ApplicationText = { exc: unknown, ansiColor: boolean ): string => { - // When a plural alias receives extra positional args (e.g. `sentry projects view cli`), - // Stricli throws UnexpectedPositionalError because the list command only accepts 1 arg. - // Detect this and suggest the singular form. + // Case A: bare route group with no subcommand (e.g., `sentry issue`) + if (exc instanceof UnsatisfiedPositionalError) { + const bareHint = detectBareRouteGroup(ansiColor); + if (bareHint) { + return bareHint; + } + } + + // Case B + plural alias: extra args that Stricli can't consume if (exc instanceof UnexpectedPositionalError) { - const args = process.argv.slice(2); - const firstArg = args[0]; - if (firstArg && firstArg in PLURAL_TO_SINGULAR) { - const singular = PLURAL_TO_SINGULAR[firstArg]; - const rest = args.slice(1).join(" "); - const hint = ansiColor - ? warning(`\nDid you mean: sentry ${singular} ${rest}\n`) - : `\nDid you mean: sentry ${singular} ${rest}\n`; - return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${hint}`; + const pluralHint = detectPluralAliasMisuse(ansiColor); + if (pluralHint) { + return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`; + } + + // With defaultCommand: "view", unknown tokens like "events" fill the + // positional slot, then extra args (e.g., CLI-AB) trigger this error. + // Check if the first non-route token is a known synonym. + const synonymHint = getSynonymSuggestionFromArgv(); + if (synonymHint) { + const tip = ansiColor + ? warning(`\nTip: ${synonymHint}`) + : `\nTip: ${synonymHint}`; + return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`; } } + return text_en.exceptionWhileParsingArguments(exc, ansiColor); }, + noCommandRegisteredForInput: ({ input, corrections, ansiColor }): string => { + // Default error message from Stricli (e.g., "No command registered for `info`") + const base = text_en.noCommandRegisteredForInput({ + input, + corrections, + ansiColor, + }); + + // Check for known synonym suggestions on routes without defaultCommand + // (e.g., `sentry cli info` → suggest `sentry auth status`). + // Routes WITH defaultCommand won't reach here — their unknown tokens + // are consumed as positional args and handled by Cases A/B/C above. + const args = process.argv.slice(2); + const nonFlags = args.filter((t) => !t.startsWith("-")); + const routeContext = nonFlags[0] ?? ""; + const suggestion = getCommandSuggestion(routeContext, input); + if (suggestion) { + const hint = suggestion.explanation + ? `${suggestion.explanation}: ${suggestion.command}` + : suggestion.command; + // Stricli wraps our return value in bold-red ANSI codes. + // Reset before applying warning() color so the tip is yellow, not red. + const formatted = ansiColor + ? `\n\x1B[39m\x1B[22m${warning(`Tip: ${hint}`)}` + : `\nTip: ${hint}`; + return `${base}${formatted}`; + } + + return base; + }, exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => { // OutputError: data was already rendered to stdout — just re-throw // so the exit code propagates without Stricli printing an error message. @@ -174,6 +262,17 @@ const customText: ApplicationText = { if (exc instanceof CliError) { const prefix = ansiColor ? errorColor("Error:") : "Error:"; + // Case C: With defaultCommand: "view", unknown tokens like "events" are + // silently consumed as the positional arg. The view command fails at the + // domain level (e.g., ResolutionError). Check argv for a known synonym + // and append the suggestion to the error. + const synonymHint = getSynonymSuggestionFromArgv(); + if (synonymHint) { + const tip = ansiColor + ? warning(`Tip: ${synonymHint}`) + : `Tip: ${synonymHint}`; + return `${prefix} ${exc.format()}\n${tip}`; + } return `${prefix} ${exc.format()}`; } if (exc instanceof Error) { diff --git a/src/commands/dashboard/index.ts b/src/commands/dashboard/index.ts index a47d1f6c1..43f17aabd 100644 --- a/src/commands/dashboard/index.ts +++ b/src/commands/dashboard/index.ts @@ -11,6 +11,8 @@ export const dashboardRoute = buildRouteMap({ create: createCommand, widget: widgetRoute, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "Manage Sentry dashboards", fullDescription: diff --git a/src/commands/dashboard/widget/index.ts b/src/commands/dashboard/widget/index.ts index facdc39b4..24f319496 100644 --- a/src/commands/dashboard/widget/index.ts +++ b/src/commands/dashboard/widget/index.ts @@ -9,6 +9,7 @@ export const widgetRoute = buildRouteMap({ edit: editCommand, delete: deleteCommand, }, + aliases: { remove: "delete" }, docs: { brief: "Manage dashboard widgets", fullDescription: diff --git a/src/commands/event/index.ts b/src/commands/event/index.ts index eec48bab9..b4bc451d8 100644 --- a/src/commands/event/index.ts +++ b/src/commands/event/index.ts @@ -5,6 +5,8 @@ export const eventRoute = buildRouteMap({ routes: { view: viewCommand, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "View Sentry events", fullDescription: diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts index 9d59e4fcb..eccb68520 100644 --- a/src/commands/issue/index.ts +++ b/src/commands/issue/index.ts @@ -11,6 +11,8 @@ export const issueRoute = buildRouteMap({ plan: planCommand, view: viewCommand, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "Manage Sentry issues", fullDescription: diff --git a/src/commands/log/index.ts b/src/commands/log/index.ts index 502ea939d..f05c06829 100644 --- a/src/commands/log/index.ts +++ b/src/commands/log/index.ts @@ -13,6 +13,8 @@ export const logRoute = buildRouteMap({ list: listCommand, view: viewCommand, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "View Sentry logs", fullDescription: diff --git a/src/commands/org/index.ts b/src/commands/org/index.ts index 63586ff21..8010efbc0 100644 --- a/src/commands/org/index.ts +++ b/src/commands/org/index.ts @@ -7,6 +7,8 @@ export const orgRoute = buildRouteMap({ list: listCommand, view: viewCommand, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "Work with Sentry organizations", fullDescription: diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts index 5186b9fa3..7c57caff8 100644 --- a/src/commands/project/index.ts +++ b/src/commands/project/index.ts @@ -11,6 +11,8 @@ export const projectRoute = buildRouteMap({ list: listCommand, view: viewCommand, }, + defaultCommand: "view", + aliases: { show: "view", remove: "delete" }, docs: { brief: "Work with Sentry projects", fullDescription: diff --git a/src/commands/span/index.ts b/src/commands/span/index.ts index 2105faa7a..3b6dfffda 100644 --- a/src/commands/span/index.ts +++ b/src/commands/span/index.ts @@ -13,6 +13,8 @@ export const spanRoute = buildRouteMap({ list: listCommand, view: viewCommand, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "List and view spans in projects or traces", fullDescription: diff --git a/src/commands/trace/index.ts b/src/commands/trace/index.ts index ad21e53b6..99c9be710 100644 --- a/src/commands/trace/index.ts +++ b/src/commands/trace/index.ts @@ -15,6 +15,8 @@ export const traceRoute = buildRouteMap({ view: viewCommand, logs: logsCommand, }, + defaultCommand: "view", + aliases: { show: "view" }, docs: { brief: "View distributed traces", fullDescription: diff --git a/src/lib/command-suggestions.ts b/src/lib/command-suggestions.ts new file mode 100644 index 000000000..ac135790e --- /dev/null +++ b/src/lib/command-suggestions.ts @@ -0,0 +1,208 @@ +/** + * Command synonym suggestion registry. + * + * Maps unknown command tokens to context-aware suggestions, keyed by + * `"routeContext/unknownToken"`. Used by the `exceptionWhileParsingArguments`, + * `exceptionWhileRunningCommand`, and `noCommandRegisteredForInput` overrides + * in app.ts to help users who type commands that don't exist. + * + * Populated from CLI-Q3 telemetry analysis (~100 events, 85 users). + */ + +/** A suggestion for an unknown command token. */ +export type CommandSuggestion = { + /** The command to suggest (shown in "Tip:" hint) */ + command: string; + /** Optional explanation of why this is suggested */ + explanation?: string; +}; + +/** + * Route groups that have `defaultCommand: "view"` set. + * + * Used to detect the no-args case (`sentry issue` with no subcommand) + * so we can show a usage hint instead of a confusing parse error. + */ +export const ROUTES_WITH_DEFAULT_VIEW: ReadonlySet = new Set([ + "issue", + "event", + "org", + "project", + "dashboard", + "trace", + "span", + "log", +]); + +/** + * Synonym map: `routeContext/unknownToken` → suggestion. + * + * Route context is the last successfully matched route segment before + * the unknown token. For top-level routes, prefix with `/`. + */ +const SUGGESTIONS: ReadonlyMap = new Map([ + // --- issue events (most common, ~20 events) --- + [ + "issue/events", + { + command: "sentry issue view ", + explanation: "To see events for an issue, view the issue details", + }, + ], + + // --- view synonyms (~15 events) --- + ["issue/get", { command: "sentry issue view " }], + ["issue/details", { command: "sentry issue view " }], + ["issue/detail", { command: "sentry issue view " }], + ["issue/info", { command: "sentry issue view " }], + + // --- mutation commands (~8 events) --- + [ + "issue/resolve", + { + command: + 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"status":"resolved"}\'', + explanation: "Issue mutations are available via the API", + }, + ], + [ + "issue/update", + { + command: + "sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT", + explanation: "Issue mutations are available via the API", + }, + ], + [ + "issue/set-status", + { + command: + 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"status":"..."}\'', + explanation: "Issue status changes are available via the API", + }, + ], + [ + "issue/close", + { + command: + 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"status":"resolved"}\'', + explanation: "Issue status changes are available via the API", + }, + ], + [ + "issue/ignore", + { + command: + 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"status":"ignored"}\'', + explanation: "Issue status changes are available via the API", + }, + ], + [ + "issue/assign", + { + command: + 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"assignedTo":"..."}\'', + explanation: "Issue assignment is available via the API", + }, + ], + [ + "issue/comment", + { + command: + 'sentry api /api/0/organizations/{org}/issues/{issue_id}/comments/ --method POST --data \'{"text":"..."}\'', + explanation: "Issue commenting is available via the API", + }, + ], + + // --- event list (~6 events) --- + [ + "event/list", + { + command: "sentry issue view ", + explanation: + "Events are scoped to issues. View an issue to see its latest event", + }, + ], + + // --- old sentry-cli commands (~5 events) --- + ["cli/info", { command: "sentry auth status" }], + [ + "cli/send-event", + { + command: + "sentry api /api/0/projects/{org}/{project}/store/ --method POST", + explanation: "Use the API to send test events", + }, + ], + ["cli/issues", { command: "sentry issue list" }], + ["cli/logs", { command: "sentry log list" }], + + // --- dashboard synonyms --- + ["dashboard/default-overview", { command: "sentry dashboard list" }], + + // --- issue trends (from telemetry) --- + [ + "issue/trends", + { + command: "sentry issue list --sort freq", + explanation: "Use issue list with sort options for trend analysis", + }, + ], + + // --- issue latest-event (from telemetry) --- + ["issue/latest-event", { command: "sentry issue view " }], + + // --- issue search/find --- + ["issue/search", { command: "sentry issue list --query " }], + ["issue/find", { command: "sentry issue list --query " }], +]); + +/** + * Look up a suggestion for an unknown command token. + * + * @param routeContext - The parent route (e.g., "issue", "dashboard"), or empty string for top-level + * @param unknownToken - The unrecognized token (case-insensitive) + * @returns A suggestion, or undefined if no match + */ +export function getCommandSuggestion( + routeContext: string, + unknownToken: string +): CommandSuggestion | undefined { + const key = routeContext + ? `${routeContext}/${unknownToken.toLowerCase()}` + : `/${unknownToken.toLowerCase()}`; + return SUGGESTIONS.get(key); +} + +/** + * Check process.argv for a known synonym and return the suggestion string. + * + * Inspects argv for the pattern `[routeGroup, unknownToken, ...]` where + * `unknownToken` is in the synonym map. Returns the formatted suggestion + * string or undefined if no match. + * + * Used by `exceptionWhileParsingArguments` (Cases A/B) and + * `exceptionWhileRunningCommand` (Case C) in app.ts. + */ +export function getSynonymSuggestionFromArgv(): string | undefined { + const args = process.argv.slice(2); + const nonFlags = args.filter((t) => !t.startsWith("-")); + if (nonFlags.length < 2) { + return; + } + + const routeContext = nonFlags[0]; + const unknownToken = nonFlags[1]; + if (!(routeContext && unknownToken)) { + return; + } + + const suggestion = getCommandSuggestion(routeContext, unknownToken); + if (!suggestion) { + return; + } + + return suggestion.explanation + ? `${suggestion.explanation}: ${suggestion.command}` + : suggestion.command; +} diff --git a/test/lib/command-suggestions.test.ts b/test/lib/command-suggestions.test.ts new file mode 100644 index 000000000..8e4f346d3 --- /dev/null +++ b/test/lib/command-suggestions.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for the command synonym suggestion registry. + * + * Core invariants (case-insensitivity, lookup consistency) could be + * property-tested, but the map is small and static — unit tests are + * sufficient here. These verify each telemetry-driven pattern category. + */ + +import { describe, expect, test } from "bun:test"; +import { + getCommandSuggestion, + ROUTES_WITH_DEFAULT_VIEW, +} from "../../src/lib/command-suggestions.js"; + +describe("getCommandSuggestion", () => { + // --- Pattern 1: issue events (most common) --- + test("suggests issue view for 'issue/events'", () => { + const s = getCommandSuggestion("issue", "events"); + expect(s).toBeDefined(); + expect(s!.command).toContain("issue view"); + expect(s!.explanation).toBeDefined(); + }); + + // --- Pattern 2: view synonyms --- + test("suggests issue view for 'issue/get'", () => { + const s = getCommandSuggestion("issue", "get"); + expect(s).toBeDefined(); + expect(s!.command).toContain("issue view"); + }); + + test("suggests issue view for 'issue/details'", () => { + expect(getCommandSuggestion("issue", "details")?.command).toContain( + "issue view" + ); + }); + + test("suggests issue view for 'issue/info'", () => { + expect(getCommandSuggestion("issue", "info")?.command).toContain( + "issue view" + ); + }); + + // --- Pattern 3: mutation commands --- + test("suggests sentry api for 'issue/resolve'", () => { + const s = getCommandSuggestion("issue", "resolve"); + expect(s).toBeDefined(); + expect(s!.command).toContain("sentry api"); + expect(s!.command).toContain("resolved"); + }); + + test("suggests sentry api for 'issue/update'", () => { + const s = getCommandSuggestion("issue", "update"); + expect(s).toBeDefined(); + expect(s!.command).toContain("sentry api"); + }); + + test("suggests sentry api for 'issue/assign'", () => { + const s = getCommandSuggestion("issue", "assign"); + expect(s).toBeDefined(); + expect(s!.command).toContain("assignedTo"); + }); + + // --- Pattern 4: event list --- + test("suggests issue view for 'event/list'", () => { + const s = getCommandSuggestion("event", "list"); + expect(s).toBeDefined(); + expect(s!.command).toContain("issue view"); + expect(s!.explanation).toContain("scoped to issues"); + }); + + // --- Pattern 5: old sentry-cli commands --- + test("suggests auth status for 'cli/info'", () => { + expect(getCommandSuggestion("cli", "info")?.command).toContain( + "auth status" + ); + }); + + test("suggests issue list for 'cli/issues'", () => { + expect(getCommandSuggestion("cli", "issues")?.command).toContain( + "issue list" + ); + }); + + test("suggests log list for 'cli/logs'", () => { + expect(getCommandSuggestion("cli", "logs")?.command).toContain("log list"); + }); + + test("suggests api for 'cli/send-event'", () => { + expect(getCommandSuggestion("cli", "send-event")?.command).toContain( + "sentry api" + ); + }); + + // --- Pattern 6: dashboard synonyms --- + test("suggests dashboard list for 'dashboard/default-overview'", () => { + expect( + getCommandSuggestion("dashboard", "default-overview")?.command + ).toContain("dashboard list"); + }); + + // --- Case insensitivity --- + test("is case-insensitive on the unknown token", () => { + expect(getCommandSuggestion("issue", "Events")).toBeDefined(); + expect(getCommandSuggestion("issue", "RESOLVE")).toBeDefined(); + expect(getCommandSuggestion("cli", "INFO")).toBeDefined(); + }); + + // --- No match --- + test("returns undefined for unrecognized token", () => { + expect(getCommandSuggestion("issue", "foobar")).toBeUndefined(); + }); + + test("returns undefined for empty route context with unknown token", () => { + expect(getCommandSuggestion("", "foobar")).toBeUndefined(); + }); + + test("returns undefined for unknown route context", () => { + expect(getCommandSuggestion("nonexistent", "events")).toBeUndefined(); + }); +}); + +describe("ROUTES_WITH_DEFAULT_VIEW", () => { + test("contains all 8 route groups with defaultCommand: view", () => { + const expected = [ + "issue", + "event", + "org", + "project", + "dashboard", + "trace", + "span", + "log", + ]; + for (const route of expected) { + expect(ROUTES_WITH_DEFAULT_VIEW.has(route)).toBe(true); + } + }); + + test("does not contain route groups without defaultCommand", () => { + expect(ROUTES_WITH_DEFAULT_VIEW.has("auth")).toBe(false); + expect(ROUTES_WITH_DEFAULT_VIEW.has("cli")).toBe(false); + expect(ROUTES_WITH_DEFAULT_VIEW.has("sourcemap")).toBe(false); + expect(ROUTES_WITH_DEFAULT_VIEW.has("repo")).toBe(false); + expect(ROUTES_WITH_DEFAULT_VIEW.has("team")).toBe(false); + }); +}); diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index c269b8b86..da73c6ad1 100644 --- a/test/lib/completions.property.test.ts +++ b/test/lib/completions.property.test.ts @@ -177,8 +177,26 @@ describe("property: binary name parametrization", () => { describe("proposeCompletions: Stricli integration", () => { const tree = extractCommandTree(); - test("subcommands match extractCommandTree for each group", async () => { + // Route groups with defaultCommand set don't propose subcommand names + // through proposeCompletions — Stricli treats the empty string as a + // potential positional arg for the default command and proposes flags + // instead. These groups are tested separately below. + const groupsWithDefaultCommand = new Set([ + "issue", + "event", + "org", + "project", + "dashboard", + "trace", + "span", + "log", + ]); + + test("subcommands match extractCommandTree for each group without defaultCommand", async () => { for (const group of tree.groups) { + if (groupsWithDefaultCommand.has(group.name)) { + continue; + } const completions = await proposeCompletions( app, [group.name, ""], @@ -190,9 +208,31 @@ describe("proposeCompletions: Stricli integration", () => { } }); + test("groups with defaultCommand propose flags for the default view command", async () => { + for (const group of tree.groups) { + if (!groupsWithDefaultCommand.has(group.name)) { + continue; + } + const completions = await proposeCompletions( + app, + [group.name, ""], + completionContext + ); + const actual = completions.map((c) => c.completion); + // With defaultCommand: "view", Stricli proposes flags for the view + // command rather than subcommand names. Verify it returns flags. + for (const completion of actual) { + expect(completion.startsWith("--")).toBe(true); + } + } + }); + test("partial prefix filters subcommands correctly", async () => { for (const group of tree.groups) { - if (group.subcommands.length === 0) { + if ( + group.subcommands.length === 0 || + groupsWithDefaultCommand.has(group.name) + ) { continue; } // Pick the first subcommand's first character as prefix From ab79370dd99e45e695e0a46dc1ba8563684c75c9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 2 Apr 2026 00:30:04 +0000 Subject: [PATCH 2/3] refactor: derive routesWithDefaultCommand from route map introspection Replace the hardcoded ROUTES_WITH_DEFAULT_VIEW set with runtime derivation using getDefaultCommand() from the Stricli route map. No manual list to maintain when adding new route groups. --- src/app.ts | 22 +++++++++++++++-- src/lib/command-suggestions.ts | 17 ------------- src/lib/introspect.ts | 2 ++ test/lib/command-suggestions.test.ts | 37 ++++++++++++++++++---------- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/app.ts b/src/app.ts index ab996cc03..dbf2974f7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,7 +40,6 @@ import { listCommand as trialListCommand } from "./commands/trial/list.js"; import { getCommandSuggestion, getSynonymSuggestionFromArgv, - ROUTES_WITH_DEFAULT_VIEW, } from "./lib/command-suggestions.js"; import { CLI_VERSION } from "./lib/constants.js"; import { @@ -51,6 +50,7 @@ import { stringifyUnknown, } from "./lib/errors.js"; import { error as errorColor, warning } from "./lib/formatters/colors.js"; +import { isRouteMap, type RouteMap } from "./lib/introspect.js"; /** * Plural alias → singular route name mapping. @@ -127,6 +127,24 @@ export const routes = buildRouteMap({ }, }); +/** + * Route group names that have `defaultCommand` set. + * + * Derived from the route map at module load time — no manual list to maintain. + * Used to detect the no-args case (`sentry issue` with no subcommand) + * so we can show a usage hint instead of a confusing parse error. + */ +const routesWithDefaultCommand: ReadonlySet = new Set( + routes + .getAllEntries() + .filter( + (e) => + isRouteMap(e.target as unknown) && + (e.target as unknown as RouteMap).getDefaultCommand?.() + ) + .map((e) => e.name.original) +); + /** * Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`). * @@ -141,7 +159,7 @@ function detectBareRouteGroup(ansiColor: boolean): string | undefined { if ( nonFlags.length <= 1 && nonFlags[0] && - ROUTES_WITH_DEFAULT_VIEW.has(nonFlags[0]) + routesWithDefaultCommand.has(nonFlags[0]) ) { const route = nonFlags[0]; const msg = `Usage: sentry ${route} [args]\nRun "sentry ${route} --help" to see available commands`; diff --git a/src/lib/command-suggestions.ts b/src/lib/command-suggestions.ts index ac135790e..90e19f1c0 100644 --- a/src/lib/command-suggestions.ts +++ b/src/lib/command-suggestions.ts @@ -17,23 +17,6 @@ export type CommandSuggestion = { explanation?: string; }; -/** - * Route groups that have `defaultCommand: "view"` set. - * - * Used to detect the no-args case (`sentry issue` with no subcommand) - * so we can show a usage hint instead of a confusing parse error. - */ -export const ROUTES_WITH_DEFAULT_VIEW: ReadonlySet = new Set([ - "issue", - "event", - "org", - "project", - "dashboard", - "trace", - "span", - "log", -]); - /** * Synonym map: `routeContext/unknownToken` → suggestion. * diff --git a/src/lib/introspect.ts b/src/lib/introspect.ts index dfd21abd6..3e0d18248 100644 --- a/src/lib/introspect.ts +++ b/src/lib/introspect.ts @@ -35,6 +35,8 @@ export type RouteMap = { brief: string; fullDescription?: string; getAllEntries: () => RouteMapEntry[]; + /** Returns the default command if one is configured, undefined otherwise */ + getDefaultCommand?: () => unknown; }; /** A leaf command with parameters */ diff --git a/test/lib/command-suggestions.test.ts b/test/lib/command-suggestions.test.ts index 8e4f346d3..396c96ade 100644 --- a/test/lib/command-suggestions.test.ts +++ b/test/lib/command-suggestions.test.ts @@ -7,10 +7,9 @@ */ import { describe, expect, test } from "bun:test"; -import { - getCommandSuggestion, - ROUTES_WITH_DEFAULT_VIEW, -} from "../../src/lib/command-suggestions.js"; +import { routes } from "../../src/app.js"; +import { getCommandSuggestion } from "../../src/lib/command-suggestions.js"; +import { isRouteMap, type RouteMap } from "../../src/lib/introspect.js"; describe("getCommandSuggestion", () => { // --- Pattern 1: issue events (most common) --- @@ -119,8 +118,20 @@ describe("getCommandSuggestion", () => { }); }); -describe("ROUTES_WITH_DEFAULT_VIEW", () => { - test("contains all 8 route groups with defaultCommand: view", () => { +describe("routes with defaultCommand", () => { + /** Derive the set the same way app.ts does — via introspection */ + const routesWithDefault = new Set( + routes + .getAllEntries() + .filter( + (e) => + isRouteMap(e.target as unknown) && + (e.target as unknown as RouteMap).getDefaultCommand?.() + ) + .map((e) => e.name.original) + ); + + test("all route groups with a view subcommand have defaultCommand set", () => { const expected = [ "issue", "event", @@ -132,15 +143,15 @@ describe("ROUTES_WITH_DEFAULT_VIEW", () => { "log", ]; for (const route of expected) { - expect(ROUTES_WITH_DEFAULT_VIEW.has(route)).toBe(true); + expect(routesWithDefault.has(route)).toBe(true); } }); - test("does not contain route groups without defaultCommand", () => { - expect(ROUTES_WITH_DEFAULT_VIEW.has("auth")).toBe(false); - expect(ROUTES_WITH_DEFAULT_VIEW.has("cli")).toBe(false); - expect(ROUTES_WITH_DEFAULT_VIEW.has("sourcemap")).toBe(false); - expect(ROUTES_WITH_DEFAULT_VIEW.has("repo")).toBe(false); - expect(ROUTES_WITH_DEFAULT_VIEW.has("team")).toBe(false); + test("route groups without view do not have defaultCommand", () => { + expect(routesWithDefault.has("auth")).toBe(false); + expect(routesWithDefault.has("cli")).toBe(false); + expect(routesWithDefault.has("sourcemap")).toBe(false); + expect(routesWithDefault.has("repo")).toBe(false); + expect(routesWithDefault.has("team")).toBe(false); }); }); From 40efb72b69a53ad20679378f73f0748042445b07 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 2 Apr 2026 01:11:18 +0000 Subject: [PATCH 3/3] fix: skip Sentry capture for known synonym user mistakes Move the synonym check before Sentry.captureException() so that known user mistakes like `sentry issue resolve` don't create noise in our Sentry project. Extract formatSynonymError() helper to keep complexity under Biome's limit. --- src/app.ts | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/app.ts b/src/app.ts index dbf2974f7..ec752c64a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -185,6 +185,31 @@ function detectPluralAliasMisuse(ansiColor: boolean): string | undefined { return; } +/** + * Format a CliError with a synonym suggestion when the user typed a known + * synonym that was consumed as a positional arg by `defaultCommand: "view"`. + * + * Returns the formatted error string if a synonym match is found, + * undefined otherwise. Skips Sentry capture for these known user mistakes. + */ +function formatSynonymError( + exc: unknown, + ansiColor: boolean +): string | undefined { + if (!(exc instanceof CliError)) { + return; + } + const synonymHint = getSynonymSuggestionFromArgv(); + if (!synonymHint) { + return; + } + const prefix = ansiColor ? errorColor("Error:") : "Error:"; + const tip = ansiColor + ? warning(`Tip: ${synonymHint}`) + : `Tip: ${synonymHint}`; + return `${prefix} ${exc.format()}\n${tip}`; +} + /** * Custom error formatting for CLI errors. * @@ -274,23 +299,22 @@ const customText: ApplicationText = { throw exc; } + // Case C: With defaultCommand: "view", unknown tokens like "events" are + // silently consumed as the positional arg. The view command fails at the + // domain level (e.g., ResolutionError). Check argv for a known synonym + // and show the suggestion — skip Sentry capture since these are known + // user mistakes, not real errors. + const synonymResult = formatSynonymError(exc, ansiColor); + if (synonymResult) { + return synonymResult; + } + // Report command errors to Sentry. Stricli catches exceptions and doesn't // re-throw, so we must capture here to get visibility into command failures. Sentry.captureException(exc); if (exc instanceof CliError) { const prefix = ansiColor ? errorColor("Error:") : "Error:"; - // Case C: With defaultCommand: "view", unknown tokens like "events" are - // silently consumed as the positional arg. The view command fails at the - // domain level (e.g., ResolutionError). Check argv for a known synonym - // and append the suggestion to the error. - const synonymHint = getSynonymSuggestionFromArgv(); - if (synonymHint) { - const tip = ansiColor - ? warning(`Tip: ${synonymHint}`) - : `Tip: ${synonymHint}`; - return `${prefix} ${exc.format()}\n${tip}`; - } return `${prefix} ${exc.format()}`; } if (exc instanceof Error) {