diff --git a/components/terminal/autocomplete/commandHistoryStore.ts b/components/terminal/autocomplete/commandHistoryStore.ts index a1fe78bb..190b3dca 100644 --- a/components/terminal/autocomplete/commandHistoryStore.ts +++ b/components/terminal/autocomplete/commandHistoryStore.ts @@ -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(); @@ -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(); 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(); const results: HistoryEntry[] = []; diff --git a/components/terminal/autocomplete/completionEngine.ts b/components/terminal/autocomplete/completionEngine.ts index 77073283..358dc457 100644 --- a/components/terminal/autocomplete/completionEngine.ts +++ b/components/terminal/autocomplete/completionEngine.ts @@ -157,7 +157,7 @@ export async function getCompletions( cwd?: string; } = {}, ): Promise { - 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++) { diff --git a/electron/main.cjs b/electron/main.cjs index ee13d9a2..1b25bd2b 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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 diff --git a/electron/specs/awk.js b/electron/specs/awk.js new file mode 100644 index 00000000..d0f380b6 --- /dev/null +++ b/electron/specs/awk.js @@ -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; diff --git a/electron/specs/journalctl.js b/electron/specs/journalctl.js new file mode 100644 index 00000000..c8f6cad3 --- /dev/null +++ b/electron/specs/journalctl.js @@ -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; diff --git a/electron/specs/yum.js b/electron/specs/yum.js new file mode 100644 index 00000000..3aa17e14 --- /dev/null +++ b/electron/specs/yum.js @@ -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;