fix: add local autocomplete specs and isolate command history per host (#536)

Add local spec files for commands missing from @withfig/autocomplete
(journalctl, yum, awk) and load them with priority over the upstream
package. Also enforce strict per-host isolation for command history —
previously cross-host matching by OS leaked host-specific commands
(e.g. cd /cq/) into unrelated sessions.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈大猫
2026-03-27 00:04:42 +08:00
committed by GitHub
parent f896f2a071
commit c924259fc0
6 changed files with 177 additions and 53 deletions

View File

@@ -132,12 +132,8 @@ function scoreEntryAt(entry: HistoryEntry, now: number): number {
}
export interface HistoryQueryOptions {
/** Filter by host ID */
/** Filter by host ID (strict isolation — only this host's history) */
hostId?: string;
/** Also include entries from hosts with the same OS */
includeOsMatches?: boolean;
/** OS to match for cross-host suggestions */
os?: "linux" | "windows" | "macos";
/** Maximum number of results */
limit?: number;
}
@@ -159,7 +155,7 @@ export function queryHistory(
prefix: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, includeOsMatches = true, os, limit = 20 } = options;
const { hostId, limit = 20 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerPrefix = prefix.toLowerCase();
@@ -171,24 +167,15 @@ export function queryHistory(
// Must not be identical to prefix
if (entry.command === prefix) return false;
// Host filtering
// Host filtering: strict per-host isolation
if (hostId) {
if (entry.hostId === hostId) return true;
if (includeOsMatches && os && entry.os === os) return true;
return false;
return entry.hostId === hostId;
}
return true;
});
// Sort by: same host first, then by score
filtered.sort((a, b) => {
if (hostId) {
const aIsHost = a.hostId === hostId ? 1 : 0;
const bIsHost = b.hostId === hostId ? 1 : 0;
if (aIsHost !== bIsHost) return bIsHost - aIsHost;
}
return scoreEntryAt(b, now) - scoreEntryAt(a, now);
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
@@ -212,7 +199,7 @@ export function fuzzyQueryHistory(
query: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, includeOsMatches = true, os, limit = 10 } = options;
const { hostId, limit = 10 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerQuery = query.toLowerCase();
@@ -223,9 +210,7 @@ export function fuzzyQueryHistory(
for (const entry of store.entries) {
// Host filtering
if (hostId) {
const isHost = entry.hostId === hostId;
const isOsMatch = includeOsMatches && os && entry.os === os;
if (!isHost && !isOsMatch) continue;
if (entry.hostId !== hostId) continue;
}
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
@@ -234,16 +219,9 @@ export function fuzzyQueryHistory(
}
}
scored.sort((a, b) => {
// Prefer same host
if (hostId) {
const aIsHost = a.entry.hostId === hostId ? 1 : 0;
const bIsHost = b.entry.hostId === hostId ? 1 : 0;
if (aIsHost !== bIsHost) return bIsHost - aIsHost;
}
// Then by fuzzy match quality * history score
return b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now);
});
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
@@ -270,8 +248,6 @@ export function queryRecentHistoryByCommand(
excludeCommand,
argumentPrefix,
hostId,
includeOsMatches = true,
os,
limit = 3,
} = options;
if (!commandName || limit <= 0) return [];
@@ -296,21 +272,12 @@ export function queryRecentHistoryByCommand(
}
if (hostId) {
if (entry.hostId === hostId) return true;
if (includeOsMatches && os && entry.os === os) return true;
return false;
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => {
if (hostId) {
const aIsHost = a.hostId === hostId ? 1 : 0;
const bIsHost = b.hostId === hostId ? 1 : 0;
if (aIsHost !== bIsHost) return bIsHost - aIsHost;
}
return b.lastUsedAt - a.lastUsedAt;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];

View File

@@ -157,7 +157,7 @@ export async function getCompletions(
cwd?: string;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, os, maxResults = 15 } = options;
const { hostId, maxResults = 15 } = options;
if (!input || input.trim().length === 0) return [];
@@ -174,8 +174,6 @@ export async function getCompletions(
// Cap history to leave room for spec suggestions in the popup
const historyOpts: HistoryQueryOptions = {
hostId,
os,
includeOsMatches: true,
limit: preferPathSuggestions ? 0 : 5,
};
@@ -198,8 +196,6 @@ export async function getCompletions(
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId,
os,
includeOsMatches: true,
limit: 3,
});
for (let index = 0; index < recentHistory.length; index++) {

View File

@@ -508,8 +508,19 @@ const registerBridges = (win) => {
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
ipcMain.handle("netcatty:figspec:list", async () => {
try {
const fs = require("fs");
const mod = await import("@withfig/autocomplete");
return mod.default || [];
const figSpecs = mod.default || [];
// Merge local specs (covers commands missing from @withfig/autocomplete)
const localSpecDir = path.join(electronDir, "specs");
let localNames = [];
try {
localNames = fs.readdirSync(localSpecDir)
.filter(f => f.endsWith(".js"))
.map(f => f.slice(0, -3));
} catch { /* no local specs dir */ }
const merged = [...new Set([...figSpecs, ...localNames])];
return merged;
} catch (err) {
console.warn("[Main] Failed to load fig spec list:", err?.message || err);
return [];
@@ -520,10 +531,21 @@ const registerBridges = (win) => {
// Sanitize: reject absolute paths, path traversal, and non-spec characters
if (!commandName || commandName.startsWith("/") || commandName.startsWith("\\") ||
commandName.includes("..") || !/^[@a-zA-Z0-9._/+-]+$/.test(commandName)) return null;
const { pathToFileURL } = require("url");
const fs = require("fs");
// Try local specs first (covers commands missing from @withfig/autocomplete)
const localSpec = path.join(electronDir, "specs", `${commandName}.js`);
if (fs.existsSync(localSpec)) {
const mod = await import(pathToFileURL(localSpec).href);
const spec = mod.default?.default ?? mod.default ?? null;
return spec ? JSON.parse(JSON.stringify(spec)) : null;
}
// Fall back to @withfig/autocomplete
// Can't use `import("@withfig/autocomplete/build/...")` because the package's
// "exports" field restricts allowed import paths. Use file URL to bypass.
const specFile = path.join(electronDir, "..", "node_modules", "@withfig", "autocomplete", "build", `${commandName}.js`);
const { pathToFileURL } = require("url");
const mod = await import(pathToFileURL(specFile).href);
const spec = mod.default?.default ?? mod.default ?? null;
// IPC requires serializable data — JSON round-trip strips functions/symbols

33
electron/specs/awk.js Normal file
View File

@@ -0,0 +1,33 @@
// awk spec — pattern scanning and text processing language
const completionSpec = {
name: "awk",
description: "Pattern scanning and text processing language",
args: [
{ name: "program", description: "AWK program text (e.g. '{print $1}')" },
{ name: "file", description: "Input file(s)", isOptional: true, isVariadic: true, template: "filepaths" },
],
options: [
{ name: "-F", description: "Set field separator", args: { name: "separator", description: "e.g. ',' or '\\t'" } },
{ name: "-v", description: "Assign a variable (var=value)", args: { name: "var=value" } },
{ name: "-f", description: "Read AWK program from file", args: { name: "progfile", template: "filepaths" } },
{ name: "-o", description: "Enable pretty-printed output", args: { name: "file", isOptional: true, template: "filepaths" } },
{ name: "-b", description: "Treat all input data as single-byte characters (gawk)" },
{ name: "-c", description: "Run in POSIX compatibility mode (gawk)" },
{ name: "-C", description: "Print copyright information" },
{ name: "-d", description: "Dump variables to file (gawk)", args: { name: "file", isOptional: true, template: "filepaths" } },
{ name: "-e", description: "Specify AWK program text", args: { name: "program" } },
{ name: "-E", description: "Read AWK program from file (like -f but disables command-line variable assignments)", args: { name: "file", template: "filepaths" } },
{ name: "-i", description: "Include AWK source library", args: { name: "source-file", template: "filepaths" } },
{ name: "-l", description: "Load dynamic extension (gawk)", args: { name: "ext" } },
{ name: "-n", description: "Disable automatic input record splitting (gawk)" },
{ name: "-N", description: "Use locale decimal point for parsing input data (gawk)" },
{ name: "-p", description: "Profile execution and write to file (gawk)", args: { name: "file", isOptional: true, template: "filepaths" } },
{ name: "-P", description: "POSIX compatibility mode (gawk)" },
{ name: "-S", description: "Sandbox mode — disable system(), I/O redirection (gawk)" },
{ name: "-t", description: "Enable type checking (gawk)" },
{ name: "--help", description: "Show help" },
{ name: "--version", description: "Print version information" },
],
};
export default completionSpec;

View File

@@ -0,0 +1,38 @@
// journalctl spec — systemd journal query tool
const completionSpec = {
name: "journalctl",
description: "Query the systemd journal",
options: [
{ name: ["-f", "--follow"], description: "Follow new journal entries (like tail -f)" },
{ name: ["-r", "--reverse"], description: "Show newest entries first" },
{ name: ["-e", "--pager-end"], description: "Jump to the end of the journal" },
{ name: "--no-pager", description: "Do not pipe output into a pager" },
{ name: ["-a", "--all"], description: "Show all fields in full" },
{ name: ["-n", "--lines"], description: "Number of journal entries to show", args: { name: "N", description: "Number of lines" } },
{ name: ["-o", "--output"], description: "Change journal output mode", args: { name: "format", suggestions: ["short", "short-precise", "short-iso", "short-iso-precise", "short-full", "short-monotonic", "verbose", "export", "json", "json-pretty", "json-sse", "cat"] } },
{ name: ["-x", "--catalog"], description: "Augment log lines with explanation texts" },
{ name: ["-b", "--boot"], description: "Show messages from a specific boot", args: { name: "ID", isOptional: true } },
{ name: ["-k", "--dmesg"], description: "Show kernel messages only" },
{ name: ["-u", "--unit"], description: "Show messages for the specified unit", args: { name: "UNIT" } },
{ name: "--user-unit", description: "Show messages for the specified user unit", args: { name: "UNIT" } },
{ name: ["-t", "--identifier"], description: "Show messages with the specified syslog identifier", args: { name: "SYSLOG_IDENTIFIER" } },
{ name: ["-p", "--priority"], description: "Filter by message priority", args: { name: "PRIORITY", suggestions: ["emerg", "alert", "crit", "err", "warning", "notice", "info", "debug", "0", "1", "2", "3", "4", "5", "6", "7"] } },
{ name: ["-g", "--grep"], description: "Filter by message content (PCRE2 regex)", args: { name: "PATTERN" } },
{ name: ["-S", "--since"], description: "Show entries on or newer than date", args: { name: "DATE", description: "e.g. '2023-01-01', 'yesterday', '1 hour ago'" } },
{ name: ["-U", "--until"], description: "Show entries on or older than date", args: { name: "DATE" } },
{ name: "--disk-usage", description: "Show total disk usage of all journal files" },
{ name: "--vacuum-size", description: "Reduce disk usage below specified size", args: { name: "BYTES" } },
{ name: "--vacuum-time", description: "Remove journal files older than specified time", args: { name: "TIME" } },
{ name: "--list-boots", description: "Show a list of boots" },
{ name: ["-D", "--directory"], description: "Show journal files from directory", args: { name: "DIR", template: "folders" } },
{ name: "--file", description: "Operate on a specific journal file", args: { name: "FILE", template: "filepaths" } },
{ name: "--no-hostname", description: "Suppress hostname field" },
{ name: ["-q", "--quiet"], description: "Suppress info messages and privilege warning" },
{ name: "--utc", description: "Express time in UTC" },
{ name: "--system", description: "Show the system journal only" },
{ name: "--user", description: "Show the user journal for the current user" },
{ name: ["-h", "--help"], description: "Show help" },
],
};
export default completionSpec;

68
electron/specs/yum.js Normal file
View File

@@ -0,0 +1,68 @@
// yum spec — Yellowdog Updater Modified (RPM package manager)
const completionSpec = {
name: "yum",
description: "RPM package manager (RHEL/CentOS)",
subcommands: [
{ name: "install", description: "Install a package", args: { name: "package", isVariadic: true } },
{ name: "remove", description: "Remove a package", args: { name: "package", isVariadic: true } },
{ name: "update", description: "Update packages", args: { name: "package", isOptional: true, isVariadic: true } },
{ name: "upgrade", description: "Upgrade packages (same as update --obsoletes)", args: { name: "package", isOptional: true, isVariadic: true } },
{ name: "downgrade", description: "Downgrade a package", args: { name: "package", isVariadic: true } },
{ name: "list", description: "List packages", subcommands: [
{ name: "installed", description: "List installed packages" },
{ name: "available", description: "List available packages" },
{ name: "updates", description: "List packages with updates available" },
{ name: "extras", description: "List installed packages not in any repo" },
{ name: "obsoletes", description: "List obsoleting packages" },
{ name: "all", description: "List all packages" },
]},
{ name: "search", description: "Search package details for the given string", args: { name: "keyword", isVariadic: true } },
{ name: "info", description: "Display details about a package", args: { name: "package", isVariadic: true } },
{ name: "provides", description: "Find which package provides a file/feature", args: { name: "feature" } },
{ name: "clean", description: "Clean cached data", subcommands: [
{ name: "all", description: "Clean all cached data" },
{ name: "packages", description: "Clean cached packages" },
{ name: "metadata", description: "Clean cached metadata" },
{ name: "dbcache", description: "Clean cached db data" },
{ name: "expire-cache", description: "Expire the cache" },
]},
{ name: "makecache", description: "Generate the metadata cache" },
{ name: "groupinstall", description: "Install a package group", args: { name: "group" } },
{ name: "groupremove", description: "Remove a package group", args: { name: "group" } },
{ name: "grouplist", description: "List available package groups" },
{ name: "groupinfo", description: "Display details about a package group", args: { name: "group" } },
{ name: "check-update", description: "Check for available package updates" },
{ name: "reinstall", description: "Reinstall a package", args: { name: "package", isVariadic: true } },
{ name: "localinstall", description: "Install a local RPM package", args: { name: "rpm-file", template: "filepaths" } },
{ name: "deplist", description: "List package dependencies", args: { name: "package" } },
{ name: "repolist", description: "Display configured software repositories", subcommands: [
{ name: "all", description: "List all repos" },
{ name: "enabled", description: "List enabled repos" },
{ name: "disabled", description: "List disabled repos" },
]},
{ name: "repoinfo", description: "Display repository information" },
{ name: "history", description: "View and manage yum transaction history", subcommands: [
{ name: "list", description: "List transactions" },
{ name: "info", description: "Show transaction details", args: { name: "id" } },
{ name: "undo", description: "Undo a transaction", args: { name: "id" } },
{ name: "redo", description: "Redo a transaction", args: { name: "id" } },
]},
{ name: "autoremove", description: "Remove unneeded packages installed as dependencies" },
],
options: [
{ name: "-y", description: "Answer yes to all questions" },
{ name: "-q", description: "Quiet operation" },
{ name: "-v", description: "Verbose operation" },
{ name: "--enablerepo", description: "Enable a repository", args: { name: "repo" } },
{ name: "--disablerepo", description: "Disable a repository", args: { name: "repo" } },
{ name: "--nogpgcheck", description: "Disable GPG signature checking" },
{ name: "--skip-broken", description: "Skip packages with dependency problems" },
{ name: "--showduplicates", description: "Show duplicate packages in repos" },
{ name: "--installroot", description: "Set install root", args: { name: "path", template: "folders" } },
{ name: "-C", description: "Run entirely from system cache" },
{ name: "--security", description: "Include only security-related packages" },
{ name: ["-h", "--help"], description: "Show help" },
],
};
export default completionSpec;