Skip to content

Commit 9ecc62f

Browse files
authored
feat: dynamic cache-backed shell completions with fuzzy matching (#465)
## Summary Add a hybrid static + dynamic shell completion system that suggests cached org slugs, project slugs, and project aliases alongside existing command/subcommand names — with fuzzy matching for typo tolerance. ## Approach: Hybrid Static + Dynamic - **Static** (0ms): command/subcommand names, flag names, and enum flag values are embedded in the shell script - **Dynamic** (<50ms): positional arg values are completed by calling `sentry __complete` at runtime, which reads the SQLite cache with fuzzy matching - **Lazy fetch** (one-time ~200-500ms): if no projects are cached for an org, fetches and caches them on first tab-complete ## New Files - `src/lib/fuzzy.ts` — Shared Levenshtein distance + `fuzzyMatch()` with tiered scoring (exact > prefix > contains > Levenshtein distance) - `src/lib/complete.ts` — Completion engine: handles the `__complete` fast-path with context parsing, cache querying, and lazy project fetching - `test/lib/fuzzy.test.ts` — Property-based + unit tests for fuzzy matching - `test/lib/complete.test.ts` — Tests for the completion engine ## Key Changes - **`src/bin.ts`**: `__complete` fast-path at top of `main()` — skips all middleware, telemetry, and auth - **`src/lib/completions.ts`**: Extended `extractCommandTree()` with flag metadata; updated bash/zsh/fish generators with flag completion + dynamic `__complete` callback - **`src/lib/db/project-cache.ts`**: Added `getAllCachedProjects()` and `getCachedProjectsForOrg()` for completion queries - **`src/lib/platforms.ts`**: Uses shared `levenshtein()` from `fuzzy.ts` instead of local copy ## How Tab Completion Works ``` sentry <TAB> → commands (static, instant) sentry issue <TAB> → subcommands (static, instant) sentry issue list --<TAB> → flags (static, instant) sentry issue list <TAB> → org slugs with "/" (dynamic, from cache) sentry issue list myorg/<TAB> → projects for that org (dynamic, lazy-fetches if cold) sentry issue list senry/<TAB> → "sentry/" via fuzzy match (Levenshtein distance 1) ```
1 parent fcaeb0f commit 9ecc62f

18 files changed

+1948
-242
lines changed

AGENTS.md

Lines changed: 38 additions & 42 deletions
Large diffs are not rendered by default.

src/bin.ts

Lines changed: 159 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,10 @@
1-
import { isatty } from "node:tty";
2-
import { run } from "@stricli/core";
3-
import { app } from "./app.js";
4-
import { buildContext } from "./context.js";
5-
import { AuthError, formatError, getExitCode } from "./lib/errors.js";
6-
import { error } from "./lib/formatters/colors.js";
7-
import { runInteractiveLogin } from "./lib/interactive-login.js";
8-
import { getEnvLogLevel, setLogLevel } from "./lib/logger.js";
9-
import { isTrialEligible, promptAndStartTrial } from "./lib/seer-trial.js";
10-
import { withTelemetry } from "./lib/telemetry.js";
11-
import { startCleanupOldBinary } from "./lib/upgrade.js";
12-
import {
13-
abortPendingVersionCheck,
14-
getUpdateNotification,
15-
maybeCheckForUpdateInBackground,
16-
shouldSuppressNotification,
17-
} from "./lib/version-check.js";
1+
/**
2+
* CLI entry point with fast-path dispatch.
3+
*
4+
* Shell completion (`__complete`) is dispatched before any heavy imports
5+
* to avoid loading `@sentry/node-core` (~280ms). All other commands go through
6+
* the full CLI with telemetry, middleware, and error recovery.
7+
*/
188

199
// Exit cleanly when downstream pipe consumer closes (e.g., `sentry issue list | head`).
2010
// EPIPE (errno -32) is normal Unix behavior — not an error. Node.js/Bun ignore SIGPIPE
@@ -30,6 +20,20 @@ function handleStreamError(err: NodeJS.ErrnoException): void {
3020
process.stdout.on("error", handleStreamError);
3121
process.stderr.on("error", handleStreamError);
3222

23+
/**
24+
* Fast-path: shell completion.
25+
*
26+
* Dispatched before importing the full CLI to avoid loading @sentry/node-core,
27+
* @stricli/core, and other heavy dependencies. Only loads the lightweight
28+
* completion engine and SQLite cache modules.
29+
*/
30+
async function runCompletion(completionArgs: string[]): Promise<void> {
31+
// Disable telemetry so db/index.ts skips the @sentry/node-core lazy-require (~280ms)
32+
process.env.SENTRY_CLI_NO_TELEMETRY = "1";
33+
const { handleComplete } = await import("./lib/complete.js");
34+
await handleComplete(completionArgs);
35+
}
36+
3337
/**
3438
* Error-recovery middleware for the CLI.
3539
*
@@ -46,125 +50,146 @@ process.stderr.on("error", handleStreamError);
4650
* @returns A function with the same signature, with error recovery added
4751
*/
4852
type ErrorMiddleware = (
49-
next: (argv: string[]) => Promise<void>,
50-
args: string[]
53+
proceed: (cmdInput: string[]) => Promise<void>,
54+
retryArgs: string[]
5155
) => Promise<void>;
5256

5357
/**
54-
* Seer trial prompt middleware.
58+
* Full CLI execution with telemetry, middleware, and error recovery.
5559
*
56-
* Catches trial-eligible SeerErrors and offers to start a free trial.
57-
* On success, retries the original command. On failure/decline, re-throws
58-
* so the outer error handler displays the full error with upgrade URL.
60+
* All heavy imports are loaded here (not at module top level) so the
61+
* `__complete` fast-path can skip them entirely.
5962
*/
60-
const seerTrialMiddleware: ErrorMiddleware = async (next, args) => {
61-
try {
62-
await next(args);
63-
} catch (err) {
64-
if (isTrialEligible(err)) {
65-
const started = await promptAndStartTrial(
66-
// biome-ignore lint/style/noNonNullAssertion: isTrialEligible guarantees orgSlug is defined
67-
err.orgSlug!,
68-
err.reason
69-
);
70-
71-
if (started) {
72-
process.stderr.write("\nRetrying command...\n\n");
73-
await next(args);
74-
return;
63+
async function runCli(cliArgs: string[]): Promise<void> {
64+
const { isatty } = await import("node:tty");
65+
const { run } = await import("@stricli/core");
66+
const { app } = await import("./app.js");
67+
const { buildContext } = await import("./context.js");
68+
const { AuthError, formatError, getExitCode } = await import(
69+
"./lib/errors.js"
70+
);
71+
const { error } = await import("./lib/formatters/colors.js");
72+
const { runInteractiveLogin } = await import("./lib/interactive-login.js");
73+
const { getEnvLogLevel, setLogLevel } = await import("./lib/logger.js");
74+
const { isTrialEligible, promptAndStartTrial } = await import(
75+
"./lib/seer-trial.js"
76+
);
77+
const { withTelemetry } = await import("./lib/telemetry.js");
78+
const { startCleanupOldBinary } = await import("./lib/upgrade.js");
79+
const {
80+
abortPendingVersionCheck,
81+
getUpdateNotification,
82+
maybeCheckForUpdateInBackground,
83+
shouldSuppressNotification,
84+
} = await import("./lib/version-check.js");
85+
86+
// ---------------------------------------------------------------------------
87+
// Error-recovery middleware
88+
// ---------------------------------------------------------------------------
89+
90+
/**
91+
* Seer trial prompt middleware.
92+
*
93+
* Catches trial-eligible SeerErrors and offers to start a free trial.
94+
* On success, retries the original command. On failure/decline, re-throws
95+
* so the outer error handler displays the full error with upgrade URL.
96+
*/
97+
const seerTrialMiddleware: ErrorMiddleware = async (next, argv) => {
98+
try {
99+
await next(argv);
100+
} catch (err) {
101+
if (isTrialEligible(err)) {
102+
const started = await promptAndStartTrial(
103+
// biome-ignore lint/style/noNonNullAssertion: isTrialEligible guarantees orgSlug is defined
104+
err.orgSlug!,
105+
err.reason
106+
);
107+
108+
if (started) {
109+
process.stderr.write("\nRetrying command...\n\n");
110+
await next(argv);
111+
return;
112+
}
75113
}
114+
throw err;
76115
}
77-
throw err;
78-
}
79-
};
80-
81-
/**
82-
* Auto-authentication middleware.
83-
*
84-
* Catches auth errors (not_authenticated, expired) in interactive TTYs
85-
* and runs the login flow. On success, retries through the full middleware
86-
* chain so inner middlewares (e.g., trial prompt) also apply to the retry.
87-
*/
88-
const autoAuthMiddleware: ErrorMiddleware = async (next, args) => {
89-
try {
90-
await next(args);
91-
} catch (err) {
92-
// Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
93-
// Errors can opt-out via skipAutoAuth (e.g., auth status command)
94-
if (
95-
err instanceof AuthError &&
96-
(err.reason === "not_authenticated" || err.reason === "expired") &&
97-
!err.skipAutoAuth &&
98-
isatty(0)
99-
) {
100-
process.stderr.write(
101-
err.reason === "expired"
102-
? "Authentication expired. Starting login flow...\n\n"
103-
: "Authentication required. Starting login flow...\n\n"
104-
);
105-
106-
const loginSuccess = await runInteractiveLogin();
107-
108-
if (loginSuccess) {
109-
process.stderr.write("\nRetrying command...\n\n");
110-
await next(args);
116+
};
117+
118+
/**
119+
* Auto-authentication middleware.
120+
*
121+
* Catches auth errors (not_authenticated, expired) in interactive TTYs
122+
* and runs the login flow. On success, retries through the full middleware
123+
* chain so inner middlewares (e.g., trial prompt) also apply to the retry.
124+
*/
125+
const autoAuthMiddleware: ErrorMiddleware = async (next, argv) => {
126+
try {
127+
await next(argv);
128+
} catch (err) {
129+
// Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
130+
// Errors can opt-out via skipAutoAuth (e.g., auth status command)
131+
if (
132+
err instanceof AuthError &&
133+
(err.reason === "not_authenticated" || err.reason === "expired") &&
134+
!err.skipAutoAuth &&
135+
isatty(0)
136+
) {
137+
process.stderr.write(
138+
err.reason === "expired"
139+
? "Authentication expired. Starting login flow...\n\n"
140+
: "Authentication required. Starting login flow...\n\n"
141+
);
142+
143+
const loginSuccess = await runInteractiveLogin();
144+
145+
if (loginSuccess) {
146+
process.stderr.write("\nRetrying command...\n\n");
147+
await next(argv);
148+
return;
149+
}
150+
151+
// Login failed or was cancelled
152+
process.exitCode = 1;
111153
return;
112154
}
113155

114-
// Login failed or was cancelled
115-
process.exitCode = 1;
116-
return;
156+
throw err;
117157
}
118-
119-
throw err;
158+
};
159+
160+
/**
161+
* Error-recovery middlewares applied around command execution.
162+
*
163+
* Order matters: applied innermost-first, so the last entry wraps the
164+
* outermost layer. Auth middleware is outermost so it catches errors
165+
* from both the command and any inner middleware retries.
166+
*/
167+
const errorMiddlewares: ErrorMiddleware[] = [
168+
seerTrialMiddleware,
169+
autoAuthMiddleware,
170+
];
171+
172+
/** Run CLI command with telemetry wrapper */
173+
async function runCommand(argv: string[]): Promise<void> {
174+
await withTelemetry(async (span) =>
175+
run(app, argv, buildContext(process, span))
176+
);
120177
}
121-
};
122178

123-
/**
124-
* Error-recovery middlewares applied around command execution.
125-
*
126-
* Order matters: applied innermost-first, so the last entry wraps the
127-
* outermost layer. Auth middleware is outermost so it catches errors
128-
* from both the command and any inner middleware retries.
129-
*
130-
* To add a new middleware, append it to this array.
131-
*/
132-
const errorMiddlewares: ErrorMiddleware[] = [
133-
seerTrialMiddleware,
134-
autoAuthMiddleware,
135-
];
136-
137-
/** Run CLI command with telemetry wrapper */
138-
async function runCommand(args: string[]): Promise<void> {
139-
await withTelemetry(async (span) =>
140-
run(app, args, buildContext(process, span))
141-
);
142-
}
143-
144-
/**
145-
* Build the command executor by composing error-recovery middlewares.
146-
*
147-
* Wraps `runCommand` with each middleware in order (innermost-first),
148-
* producing a single function that handles all error recovery.
149-
*/
150-
function buildExecutor(): (args: string[]) => Promise<void> {
179+
/** Build the command executor by composing error-recovery middlewares. */
151180
let executor = runCommand;
152181
for (const mw of errorMiddlewares) {
153-
const next = executor;
154-
executor = (args) => mw(next, args);
182+
const inner = executor;
183+
executor = (argv) => mw(inner, argv);
155184
}
156-
return executor;
157-
}
158185

159-
/** Command executor with all error-recovery middlewares applied */
160-
const executeCommand = buildExecutor();
186+
// ---------------------------------------------------------------------------
187+
// Main execution
188+
// ---------------------------------------------------------------------------
161189

162-
async function main(): Promise<void> {
163190
// Clean up old binary from previous Windows upgrade (no-op if file doesn't exist)
164191
startCleanupOldBinary();
165192

166-
const args = process.argv.slice(2);
167-
168193
// Apply SENTRY_LOG_LEVEL env var early (lazy read, not at module load time).
169194
// CLI flags (--log-level, --verbose) are handled by Stricli via
170195
// buildCommand and take priority when present.
@@ -173,15 +198,15 @@ async function main(): Promise<void> {
173198
setLogLevel(envLogLevel);
174199
}
175200

176-
const suppressNotification = shouldSuppressNotification(args);
201+
const suppressNotification = shouldSuppressNotification(cliArgs);
177202

178203
// Start background update check (non-blocking)
179204
if (!suppressNotification) {
180205
maybeCheckForUpdateInBackground();
181206
}
182207

183208
try {
184-
await executeCommand(args);
209+
await executor(cliArgs);
185210
} catch (err) {
186211
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
187212
process.exitCode = getExitCode(err);
@@ -200,4 +225,20 @@ async function main(): Promise<void> {
200225
}
201226
}
202227

203-
main();
228+
// ---------------------------------------------------------------------------
229+
// Dispatch: check argv before any heavy imports
230+
// ---------------------------------------------------------------------------
231+
232+
const args = process.argv.slice(2);
233+
234+
if (args[0] === "__complete") {
235+
runCompletion(args.slice(1)).catch(() => {
236+
// Completions should never crash — silently return no results
237+
process.exitCode = 0;
238+
});
239+
} else {
240+
runCli(args).catch((err) => {
241+
process.stderr.write(`Fatal: ${err}\n`);
242+
process.exitCode = 1;
243+
});
244+
}

0 commit comments

Comments
 (0)