Skip to content

Commit 34f1323

Browse files
committed
feat: dynamic cache-backed shell completions with fuzzy matching
Add hybrid static + dynamic shell completion system that suggests cached org slugs, project slugs, and project aliases alongside existing command and subcommand names. Architecture: - Static: command/subcommand names, flag names, and enum values are embedded in the shell script for instant tab completion (0ms) - Dynamic: positional arg values (org slugs, project names, aliases) are completed by calling `sentry __complete` at runtime, which reads the SQLite cache with fuzzy matching (<50ms) New files: - src/lib/fuzzy.ts: Shared Levenshtein distance + fuzzyMatch() utility with tiered scoring (exact > prefix > contains > Levenshtein distance) - src/lib/complete.ts: Completion engine handling the __complete fast-path with context parsing, cache querying, and lazy project fetching Key changes: - src/bin.ts: Add __complete fast-path before any middleware/telemetry - src/lib/completions.ts: Extend extractCommandTree() with flag metadata; update bash/zsh/fish generators with flag completion and dynamic callback - src/lib/db/project-cache.ts: Add getAllCachedProjects() and getCachedProjectsForOrg() for completion queries - src/lib/platforms.ts: Use shared levenshtein() from fuzzy.ts Features: - Fuzzy matching for org/project names (typo-tolerant) - Flag name completion for all commands (static, instant) - Lazy project cache population on first tab-complete per org - Graceful degradation when DB/API unavailable - Tab-separated value\tdescription output for zsh/fish
1 parent f2fead1 commit 34f1323

File tree

11 files changed

+1356
-73
lines changed

11 files changed

+1356
-73
lines changed

src/bin.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,18 @@ function buildExecutor(): (args: string[]) => Promise<void> {
160160
const executeCommand = buildExecutor();
161161

162162
async function main(): Promise<void> {
163+
const args = process.argv.slice(2);
164+
165+
// Fast-path: shell completion (no telemetry, no middleware, no upgrade check)
166+
if (args[0] === "__complete") {
167+
const { handleComplete } = await import("./lib/complete.js");
168+
await handleComplete(args.slice(1));
169+
return;
170+
}
171+
163172
// Clean up old binary from previous Windows upgrade (no-op if file doesn't exist)
164173
startCleanupOldBinary();
165174

166-
const args = process.argv.slice(2);
167-
168175
// Apply SENTRY_LOG_LEVEL env var early (lazy read, not at module load time).
169176
// CLI flags (--log-level, --verbose) are handled by Stricli via
170177
// buildCommand and take priority when present.

src/lib/complete.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/**
2+
* Shell completion engine.
3+
*
4+
* Handles the `__complete` fast-path: parses completion context from
5+
* shell words, queries the SQLite cache, and outputs suggestions.
6+
*
7+
* Designed for minimal startup time — no Stricli boot, no telemetry,
8+
* no auth check, no API calls. Opens the SQLite cache directly for ~1ms
9+
* reads. All data comes from caches already populated by normal CLI
10+
* usage (org_regions, project_cache, project_aliases).
11+
*
12+
* Protocol:
13+
* Input: `sentry __complete <word1> <word2> ... <partial>`
14+
* Output: One completion per line to stdout (`value\tdescription`)
15+
* Exit: 0 on success (even if no completions)
16+
*/
17+
18+
import { getProjectAliases } from "./db/project-aliases.js";
19+
import { getCachedProjectsForOrg } from "./db/project-cache.js";
20+
import { getCachedOrganizations } from "./db/regions.js";
21+
import { fuzzyMatch } from "./fuzzy.js";
22+
23+
/**
24+
* Completion result with optional description for rich shell display.
25+
* Shells that support descriptions (zsh, fish) use both fields.
26+
*/
27+
type Completion = {
28+
value: string;
29+
description?: string;
30+
};
31+
32+
/**
33+
* Main entry point for `sentry __complete`.
34+
*
35+
* Called from the bin.ts fast-path. Parses the shell words to determine
36+
* what kind of completion is needed, queries the cache, and writes
37+
* results to stdout.
38+
*
39+
* @param args - The words after `__complete` (COMP_WORDS[1:] from the shell)
40+
*/
41+
export async function handleComplete(args: string[]): Promise<void> {
42+
// The last word is the partial being completed (may be empty)
43+
const partial = args.at(-1) ?? "";
44+
// All preceding words form the command path context
45+
const precedingWords = args.slice(0, -1);
46+
47+
let completions: Completion[];
48+
49+
try {
50+
completions = await getCompletions(precedingWords, partial);
51+
} catch {
52+
// Graceful degradation — if DB fails, return no completions
53+
completions = [];
54+
}
55+
56+
// Write completions to stdout, one per line
57+
const output = completions
58+
.map((c) => (c.description ? `${c.value}\t${c.description}` : c.value))
59+
.join("\n");
60+
61+
if (output) {
62+
process.stdout.write(`${output}\n`);
63+
}
64+
}
65+
66+
/** Commands that accept org/project positional args. */
67+
const ORG_PROJECT_COMMANDS = new Set([
68+
"issue list",
69+
"issue view",
70+
"issue explain",
71+
"issue plan",
72+
"project list",
73+
"project view",
74+
"project delete",
75+
"project create",
76+
"trace list",
77+
"trace view",
78+
"trace logs",
79+
"span list",
80+
"span view",
81+
"event view",
82+
"log list",
83+
"log view",
84+
"dashboard list",
85+
]);
86+
87+
/** Commands that accept only an org slug (no project). */
88+
const ORG_ONLY_COMMANDS = new Set([
89+
"org view",
90+
"team list",
91+
"repo list",
92+
"trial list",
93+
"trial start",
94+
]);
95+
96+
/**
97+
* Determine what completions to provide based on the command context.
98+
*
99+
* Walks the preceding words to identify the command path, then decides
100+
* whether to complete org slugs, project slugs, or aliases.
101+
*
102+
* @param precedingWords - Words before the partial (determines context)
103+
* @param partial - The current partial word being completed
104+
*/
105+
export async function getCompletions(
106+
precedingWords: string[],
107+
partial: string
108+
): Promise<Completion[]> {
109+
// Build the command path from preceding words (e.g., "issue list")
110+
const cmdPath =
111+
precedingWords.length >= 2
112+
? `${precedingWords[0]} ${precedingWords[1]}`
113+
: "";
114+
115+
// Check if this is a flag value position (previous word is a flag)
116+
const lastWord = precedingWords.at(-1);
117+
if (lastWord?.startsWith("--")) {
118+
// We're completing a flag value — don't provide org/project completions
119+
return [];
120+
}
121+
122+
if (ORG_PROJECT_COMMANDS.has(cmdPath)) {
123+
return await completeOrgSlashProject(partial);
124+
}
125+
126+
if (ORG_ONLY_COMMANDS.has(cmdPath)) {
127+
return await completeOrgSlugs(partial);
128+
}
129+
130+
// Not a known command path — no dynamic completions
131+
return [];
132+
}
133+
134+
/**
135+
* Complete organization slugs with fuzzy matching.
136+
*
137+
* Queries the org_regions cache for all known org slugs and matches
138+
* them against the partial input.
139+
*
140+
* @param partial - Partial org slug to match
141+
* @returns Completions with org names as descriptions
142+
*/
143+
export async function completeOrgSlugs(partial: string): Promise<Completion[]> {
144+
const orgs = await getCachedOrganizations();
145+
if (orgs.length === 0) {
146+
return [];
147+
}
148+
149+
const slugs = orgs.map((o) => o.slug);
150+
const matched = fuzzyMatch(partial, slugs);
151+
152+
// Build a slug→name lookup for descriptions
153+
const nameMap = new Map(orgs.map((o) => [o.slug, o.name]));
154+
155+
return matched.map((slug) => ({
156+
value: slug,
157+
description: nameMap.get(slug),
158+
}));
159+
}
160+
161+
/**
162+
* Complete the `org/project` positional pattern with fuzzy matching.
163+
*
164+
* Two modes based on whether the partial contains a slash:
165+
* - No slash: suggest org slugs with a trailing `/` appended
166+
* - Has slash: split on first `/`, fuzzy-match project slugs for that org
167+
*
168+
* Also includes project aliases (e.g., `A`, `B`) as suggestions.
169+
*
170+
* @param partial - The partial input (e.g., "", "sen", "sentry/", "sentry/cl")
171+
* @returns Completions for org or org/project values
172+
*/
173+
export async function completeOrgSlashProject(
174+
partial: string
175+
): Promise<Completion[]> {
176+
const slashIdx = partial.indexOf("/");
177+
178+
if (slashIdx === -1) {
179+
// No slash — suggest org slugs (with trailing slash) + aliases
180+
const [orgCompletions, aliasCompletions] = await Promise.all([
181+
completeOrgSlugsWithSlash(partial),
182+
completeAliases(partial),
183+
]);
184+
return [...orgCompletions, ...aliasCompletions];
185+
}
186+
187+
// Has slash — complete project within the org
188+
const orgPart = partial.slice(0, slashIdx);
189+
const projectPart = partial.slice(slashIdx + 1);
190+
191+
return completeProjectSlugs(projectPart, orgPart);
192+
}
193+
194+
/**
195+
* Complete org slugs and append a trailing `/` to each.
196+
*
197+
* When the user types `sentry issue list sen<TAB>`, we want to suggest
198+
* `sentry/` so they can continue typing the project name.
199+
*/
200+
async function completeOrgSlugsWithSlash(
201+
partial: string
202+
): Promise<Completion[]> {
203+
const orgs = await getCachedOrganizations();
204+
if (orgs.length === 0) {
205+
return [];
206+
}
207+
208+
const slugs = orgs.map((o) => o.slug);
209+
const matched = fuzzyMatch(partial, slugs);
210+
211+
const nameMap = new Map(orgs.map((o) => [o.slug, o.name]));
212+
213+
return matched.map((slug) => ({
214+
value: `${slug}/`,
215+
description: nameMap.get(slug),
216+
}));
217+
}
218+
219+
/**
220+
* Complete project slugs for a specific org with fuzzy matching.
221+
*
222+
* Reads from the project_cache SQLite table, which is populated by
223+
* DSN resolution and normal CLI command usage (e.g., `project list`,
224+
* `issue list`). The HTTP response cache handles API-level caching —
225+
* we don't make API calls during completion.
226+
*
227+
* @param projectPartial - Partial project slug to match
228+
* @param orgSlug - The org to find projects for
229+
*/
230+
export async function completeProjectSlugs(
231+
projectPartial: string,
232+
orgSlug: string
233+
): Promise<Completion[]> {
234+
const projects = await getCachedProjectsForOrg(orgSlug);
235+
236+
if (projects.length === 0) {
237+
return [];
238+
}
239+
240+
const slugs = projects.map((p) => p.projectSlug);
241+
const matched = fuzzyMatch(projectPartial, slugs);
242+
243+
const nameMap = new Map(projects.map((p) => [p.projectSlug, p.projectName]));
244+
245+
return matched.map((slug) => ({
246+
value: `${orgSlug}/${slug}`,
247+
description: nameMap.get(slug),
248+
}));
249+
}
250+
251+
/**
252+
* Complete project aliases (e.g., `A`, `B` from monorepo detection).
253+
*
254+
* Aliases are short identifiers that resolve to org/project pairs.
255+
* They are shown alongside org slug completions.
256+
*/
257+
export async function completeAliases(partial: string): Promise<Completion[]> {
258+
const aliases = await getProjectAliases();
259+
if (!aliases) {
260+
return [];
261+
}
262+
263+
const keys = Object.keys(aliases);
264+
const matched = fuzzyMatch(partial, keys);
265+
266+
return matched.map((alias) => {
267+
const entry = aliases[alias];
268+
return {
269+
value: alias,
270+
description: entry ? `${entry.orgSlug}/${entry.projectSlug}` : undefined,
271+
};
272+
});
273+
}

0 commit comments

Comments
 (0)