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:
@@ -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[] = [];
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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
33
electron/specs/awk.js
Normal 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;
|
||||
38
electron/specs/journalctl.js
Normal file
38
electron/specs/journalctl.js
Normal 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
68
electron/specs/yum.js
Normal 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;
|
||||
Reference in New Issue
Block a user