diff --git a/src/common/bash-tooling.ts b/src/common/bash-tooling.ts new file mode 100644 index 0000000..84a5789 --- /dev/null +++ b/src/common/bash-tooling.ts @@ -0,0 +1,445 @@ +import { execFileSync, spawn } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { resolveShellPath } from "./shell-utils"; + +export type BashToolName = "rg" | "jq"; + +export type BashToolState = { + name: BashToolName; + available: boolean; +}; + +export type BashToolingStatus = { + tools: BashToolState[]; + missing: BashToolName[]; +}; + +export type InstallCommand = { + command: string; + args: string[]; + display: string; +}; + +export type BashToolInstallPlan = { + manager: string; + commands: InstallCommand[]; +}; + +export type BashToolInstallResult = { + before: BashToolingStatus; + after: BashToolingStatus; + plan: BashToolInstallPlan | null; + exitCode: number | null; + signal: NodeJS.Signals | null; + error?: string; +}; + +export type InstallSpawn = { + command: string; + args: string[]; +}; + +const BASH_TOOLS: BashToolName[] = ["rg", "jq"]; +const WINDOWS_TOOL_PACKAGES: Record = { + rg: ["BurntSushi.ripgrep.MSVC", "BurntSushi.ripgrep.GNU"], + jq: ["jqlang.jq"], +}; + +export function getBashToolingStatus(): BashToolingStatus { + addDiscoveredWindowsToolDirsToPath(); + const tools = BASH_TOOLS.map((name) => ({ + name, + available: isCommandAvailableInBash(name), + })); + return { + tools, + missing: tools.filter((tool) => !tool.available).map((tool) => tool.name), + }; +} + +export function formatBashToolingStatus(status: BashToolingStatus): string { + if (status.missing.length === 0) { + return "rg+jq ready"; + } + return `missing ${status.missing.join(",")} (/install)`; +} + +export function formatBashToolInstallResult(result: BashToolInstallResult): string { + if (result.before.missing.length === 0) { + return "/install\n└ rg and jq are already available"; + } + if (!result.plan) { + return [ + `/install\n└ Missing ${result.before.missing.join(", ")}`, + "No supported package manager found. Install ripgrep and jq manually, then restart the CLI.", + ].join("\n"); + } + if (result.after.missing.length === 0) { + return `/install\n└ Installed rg and jq with ${result.plan.manager}`; + } + if (result.exitCode === 0 && result.signal === null && !result.error) { + return [ + `/install\n└ ${result.plan.manager} finished, but Bash still cannot find every tool`, + `Still missing: ${result.after.missing.join(", ")}`, + "Restart the terminal or add the installed binaries to PATH.", + ].join("\n"); + } + const failedSuffix = + result.exitCode === null && result.signal === null + ? "" + : ` (exit ${result.exitCode ?? result.signal ?? "unknown"})`; + return [ + `/install\n└ ${result.plan.manager} install did not finish cleanly${failedSuffix}`, + `Still missing: ${result.after.missing.join(", ")}`, + `Command: ${result.plan.commands.map((command) => command.display).join(" && ")}`, + ].join("\n"); +} + +export function buildBashToolInstallPlan( + missing: BashToolName[], + options: { + platform?: NodeJS.Platform; + hasCommand?: (command: string) => boolean; + isRoot?: boolean; + } = {} +): BashToolInstallPlan | null { + const platform = options.platform ?? process.platform; + const hasCommand = options.hasCommand ?? isNativeCommandAvailable; + const isRoot = options.isRoot ?? (typeof process.getuid === "function" ? process.getuid() === 0 : false); + const packages = missing.map((tool) => (tool === "rg" ? "ripgrep" : "jq")); + + if (missing.length === 0) { + return { manager: "none", commands: [] }; + } + + if (platform === "win32") { + if (hasCommand("winget")) { + return { + manager: "winget", + commands: missing.map((tool) => { + const packageId = tool === "rg" ? "BurntSushi.ripgrep.MSVC" : "jqlang.jq"; + const args = [ + "install", + "-e", + "--id", + packageId, + "--accept-package-agreements", + "--accept-source-agreements", + ]; + return { command: "winget", args, display: formatCommand("winget", args) }; + }), + }; + } + if (hasCommand("scoop")) { + const args = ["install", ...packages]; + return { manager: "scoop", commands: [{ command: "scoop", args, display: formatCommand("scoop", args) }] }; + } + if (hasCommand("choco")) { + const args = ["install", "-y", ...packages]; + return { manager: "choco", commands: [{ command: "choco", args, display: formatCommand("choco", args) }] }; + } + return null; + } + + if (hasCommand("brew")) { + const args = ["install", ...packages]; + return { manager: "brew", commands: [{ command: "brew", args, display: formatCommand("brew", args) }] }; + } + + if (hasCommand("apt-get")) { + const update = privilegedCommand("apt-get", ["update"], platform, hasCommand, isRoot); + const install = privilegedCommand("apt-get", ["install", "-y", ...packages], platform, hasCommand, isRoot); + return { manager: "apt-get", commands: [update, install] }; + } + if (hasCommand("dnf")) { + return singlePrivilegedPlan("dnf", ["install", "-y", ...packages], platform, hasCommand, isRoot); + } + if (hasCommand("yum")) { + return singlePrivilegedPlan("yum", ["install", "-y", ...packages], platform, hasCommand, isRoot); + } + if (hasCommand("pacman")) { + return singlePrivilegedPlan("pacman", ["-S", "--needed", ...packages], platform, hasCommand, isRoot); + } + if (hasCommand("zypper")) { + return singlePrivilegedPlan("zypper", ["install", "-y", ...packages], platform, hasCommand, isRoot); + } + if (hasCommand("apk")) { + return singlePrivilegedPlan("apk", ["add", ...packages], platform, hasCommand, isRoot); + } + + return null; +} + +export async function installMissingBashTools(): Promise { + const before = getBashToolingStatus(); + if (before.missing.length === 0) { + return { before, after: before, plan: { manager: "none", commands: [] }, exitCode: 0, signal: null }; + } + + const plan = buildBashToolInstallPlan(before.missing); + if (!plan) { + return { before, after: before, plan: null, exitCode: null, signal: null }; + } + + let exitCode: number | null = 0; + let signal: NodeJS.Signals | null = null; + let error: string | undefined; + + for (const command of plan.commands) { + const result = await runInstallCommand(command); + exitCode = result.exitCode; + signal = result.signal; + error = result.error; + if (exitCode !== 0 || signal !== null || error) { + break; + } + } + + return { + before, + after: getBashToolingStatus(), + plan, + exitCode, + signal, + error, + }; +} + +export function addDiscoveredWindowsToolDirsToPath(): void { + if (process.platform !== "win32") { + return; + } + + const dirs = BASH_TOOLS.flatMap((tool) => findWindowsToolDirs(tool)); + for (const dir of dirs) { + prependProcessPath(dir); + } +} + +function isCommandAvailableInBash(command: BashToolName): boolean { + try { + execFileSync(resolveShellPath(), ["-lc", `command -v ${command} >/dev/null 2>&1`], { + stdio: "ignore", + windowsHide: true, + }); + return true; + } catch { + return false; + } +} + +function isNativeCommandAvailable(command: string): boolean { + try { + if (process.platform === "win32") { + execFileSync("where.exe", [command], { stdio: "ignore", windowsHide: true }); + } else { + execFileSync("sh", ["-lc", `command -v ${shellSingleQuote(command)} >/dev/null 2>&1`], { stdio: "ignore" }); + } + return true; + } catch { + return false; + } +} + +function singlePrivilegedPlan( + manager: string, + args: string[], + platform: NodeJS.Platform, + hasCommand: (command: string) => boolean, + isRoot: boolean +): BashToolInstallPlan { + return { manager, commands: [privilegedCommand(manager, args, platform, hasCommand, isRoot)] }; +} + +function privilegedCommand( + command: string, + args: string[], + platform: NodeJS.Platform, + hasCommand: (command: string) => boolean, + isRoot: boolean +): InstallCommand { + if (platform !== "win32" && !isRoot && hasCommand("sudo")) { + const sudoArgs = [command, ...args]; + return { command: "sudo", args: sudoArgs, display: formatCommand("sudo", sudoArgs) }; + } + return { command, args, display: formatCommand(command, args) }; +} + +function runInstallCommand(command: InstallCommand): Promise<{ + exitCode: number | null; + signal: NodeJS.Signals | null; + error?: string; +}> { + return new Promise((resolve) => { + let settled = false; + const installSpawn = buildInstallSpawn(command); + const child = spawn(installSpawn.command, installSpawn.args, { + stdio: "inherit", + windowsHide: false, + }); + + child.on("error", (error) => { + if (settled) { + return; + } + settled = true; + resolve({ exitCode: null, signal: null, error: error.message }); + }); + child.on("close", (exitCode, signal) => { + if (settled) { + return; + } + settled = true; + resolve({ exitCode, signal }); + }); + }); +} + +export function buildInstallSpawn(command: InstallCommand, platform: NodeJS.Platform = process.platform): InstallSpawn { + if (platform !== "win32") { + return { command: command.command, args: command.args }; + } + + const resolvedCommand = findNativeCommandPath(command.command) ?? command.command; + if (!/\.(cmd|bat)$/i.test(resolvedCommand)) { + return { command: resolvedCommand, args: command.args }; + } + + return { + command: "cmd.exe", + args: ["/d", "/s", "/c", formatCommandForCmd(resolvedCommand, command.args)], + }; +} + +function findNativeCommandPath(command: string): string | null { + try { + const output = execFileSync("where.exe", [command], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + return ( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null + ); + } catch { + return null; + } +} + +function findWindowsToolDirs(tool: BashToolName): string[] { + const executable = `${tool}.exe`; + const dirs = findExecutableDirs(executable); + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return dirs; + } + + const wingetPackages = path.join(localAppData, "Microsoft", "WinGet", "Packages"); + for (const packageId of WINDOWS_TOOL_PACKAGES[tool]) { + dirs.push(...findExecutableDirsUnderPackage(wingetPackages, packageId, executable)); + } + return uniquePaths(dirs); +} + +function findExecutableDirs(executable: string): string[] { + try { + const output = execFileSync("where.exe", [executable], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((file) => path.dirname(file)); + } catch { + return []; + } +} + +function findExecutableDirsUnderPackage(root: string, packageId: string, executable: string): string[] { + if (!fs.existsSync(root)) { + return []; + } + + const dirs: string[] = []; + const stack = safeReadDir(root) + .filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith(packageId.toLowerCase())) + .map((entry) => path.join(root, entry.name)); + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of safeReadDir(current)) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name.toLowerCase() === executable.toLowerCase()) { + dirs.push(current); + } + } + } + return dirs; +} + +function safeReadDir(dir: string): fs.Dirent[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function prependProcessPath(dir: string): void { + const key = Object.keys(process.env).find((envKey) => envKey.toLowerCase() === "path") ?? "Path"; + const current = process.env[key] ?? ""; + const parts = current.split(path.delimiter).filter(Boolean); + if (parts.some((part) => path.resolve(part).toLowerCase() === path.resolve(dir).toLowerCase())) { + return; + } + process.env[key] = [dir, ...parts].join(path.delimiter); +} + +function uniquePaths(paths: string[]): string[] { + const seen = new Set(); + const results: string[] = []; + for (const item of paths) { + const key = path.resolve(item).toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(item); + } + return results; +} + +function formatCommand(command: string, args: string[]): string { + return [command, ...args].map(quoteCommandArg).join(" "); +} + +function quoteCommandArg(arg: string): string { + return /^[A-Za-z0-9._/:-]+$/.test(arg) ? arg : JSON.stringify(arg); +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function formatCommandForCmd(command: string, args: string[]): string { + return [command, ...args].map(quoteCmdArg).join(" "); +} + +function quoteCmdArg(arg: string): string { + if (/^[A-Za-z0-9._/:=-]+$/.test(arg)) { + return arg; + } + return `"${arg.replace(/"/g, '""')}"`; +} diff --git a/src/tests/bashTooling.test.ts b/src/tests/bashTooling.test.ts new file mode 100644 index 0000000..97be8ef --- /dev/null +++ b/src/tests/bashTooling.test.ts @@ -0,0 +1,114 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildInstallSpawn, + buildBashToolInstallPlan, + formatBashToolInstallResult, + formatBashToolingStatus, + type BashToolingStatus, +} from "../common/bash-tooling"; + +test("formatBashToolingStatus reports ready and missing tools", () => { + assert.equal(formatBashToolingStatus(status([])), "rg+jq ready"); + assert.equal(formatBashToolingStatus(status(["rg"])), "missing rg (/install)"); + assert.equal(formatBashToolingStatus(status(["rg", "jq"])), "missing rg,jq (/install)"); +}); + +test("buildBashToolInstallPlan uses winget on Windows", () => { + const plan = buildBashToolInstallPlan(["rg", "jq"], { + platform: "win32", + hasCommand: (command) => command === "winget", + }); + + assert.equal(plan?.manager, "winget"); + assert.deepEqual( + plan?.commands.map((command) => command.display), + [ + "winget install -e --id BurntSushi.ripgrep.MSVC --accept-package-agreements --accept-source-agreements", + "winget install -e --id jqlang.jq --accept-package-agreements --accept-source-agreements", + ] + ); +}); + +test("buildBashToolInstallPlan uses one brew install for both tools", () => { + const plan = buildBashToolInstallPlan(["rg", "jq"], { + platform: "darwin", + hasCommand: (command) => command === "brew", + }); + + assert.equal(plan?.manager, "brew"); + assert.deepEqual( + plan?.commands.map((command) => command.display), + ["brew install ripgrep jq"] + ); +}); + +test("buildBashToolInstallPlan uses sudo for apt-get when not root", () => { + const plan = buildBashToolInstallPlan(["jq"], { + platform: "linux", + hasCommand: (command) => command === "apt-get" || command === "sudo", + isRoot: false, + }); + + assert.equal(plan?.manager, "apt-get"); + assert.deepEqual( + plan?.commands.map((command) => command.display), + ["sudo apt-get update", "sudo apt-get install -y jq"] + ); +}); + +test("formatBashToolInstallResult includes fallback instructions without a package manager", () => { + const result = formatBashToolInstallResult({ + before: status(["rg", "jq"]), + after: status(["rg", "jq"]), + plan: null, + exitCode: null, + signal: null, + }); + + assert.equal(result.includes("No supported package manager found"), true); +}); + +test("formatBashToolInstallResult separates PATH refresh from installer failure", () => { + const result = formatBashToolInstallResult({ + before: status(["jq"]), + after: status(["jq"]), + plan: { + manager: "winget", + commands: [{ command: "winget", args: ["install", "jq"], display: "winget install jq" }], + }, + exitCode: 0, + signal: null, + }); + + assert.equal(result.includes("Bash still cannot find"), true); + assert.equal(result.includes("Restart the terminal"), true); +}); + +test("buildInstallSpawn does not use a shell for native commands", () => { + const result = buildInstallSpawn({ command: "winget", args: ["install", "jq"], display: "" }, "win32"); + + assert.notEqual(result.command, "cmd.exe"); + assert.equal(result.command.endsWith("winget.exe") || result.command === "winget", true); + assert.deepEqual(result.args, ["install", "jq"]); +}); + +test("buildInstallSpawn runs Windows cmd shims through cmd.exe without node shell option", () => { + assert.deepEqual( + buildInstallSpawn({ command: "C:\\Tools\\scoop.cmd", args: ["install", "ripgrep", "jq"], display: "" }, "win32"), + { + command: "cmd.exe", + args: ["/d", "/s", "/c", '"C:\\Tools\\scoop.cmd" install ripgrep jq'], + } + ); +}); + +function status(missing: Array<"rg" | "jq">): BashToolingStatus { + return { + tools: [ + { name: "rg", available: !missing.includes("rg") }, + { name: "jq", available: !missing.includes("jq") }, + ], + missing, + }; +} diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 30d77ee..30ba67d 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -26,6 +26,8 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "init", "resume", "continue", + "mcp", + "install", "undo", "mcp", "raw", @@ -91,6 +93,13 @@ test("findExactSlashCommand returns built-in /skills", () => { assert.equal(item?.kind, "skills"); }); +test("findExactSlashCommand returns built-in /install", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/install"); + assert.ok(item); + assert.equal(item?.kind, "install"); +}); + test("findExactSlashCommand returns built-in /model", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/model"); diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index df7e109..a2d54ea 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -32,5 +32,6 @@ test("buildWelcomeTips includes built-in slash commands and loaded skills", () = const labels = tips.map((tip) => tip.label); assert.ok(labels.includes("/new")); assert.ok(labels.includes("/loaded")); + assert.ok(labels.includes("rg + jq")); assert.equal(labels.includes("/fresh"), false); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 75d6689..b23100f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -41,6 +41,12 @@ import { import { buildExitSummaryText } from "./exitSummary"; import { RawMode, useRawModeContext } from "./contexts"; import { renderMessageToStdout } from "./components/MessageView/utils"; +import { + formatBashToolInstallResult, + getBashToolingStatus, + installMissingBashTools, + type BashToolingStatus, +} from "../common/bash-tooling"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; @@ -81,6 +87,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); + const [bashToolingStatus, setBashToolingStatus] = useState(() => getBashToolingStatus()); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); const [showProcessStdout, setShowProcessStdout] = useState(false); @@ -244,6 +251,28 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setView("mcp-status"); return; } + if (submission.command === "install") { + setBusy(true); + setErrorLine(null); + setStatusLine("Installing missing Bash tools..."); + try { + const result = await installMissingBashTools(); + setBashToolingStatus(result.after); + setWelcomeNonce((n) => n + 1); + setMessages((prev) => [...prev, buildSyntheticSystemMessage(formatBashToolInstallResult(result))]); + if (result.after.missing.length === 0) { + setStatusLine("Bash tools ready: rg+jq"); + } else { + setStatusLine(`Bash tools still missing: ${result.after.missing.join(",")}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setErrorLine(message); + } finally { + setBusy(false); + } + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -613,6 +642,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. projectRoot={projectRoot} settings={resolvedSettings} skills={skills} + bashToolingStatus={bashToolingStatus} width={screenWidth} /> ); @@ -731,6 +761,22 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session }; } +function buildSyntheticSystemMessage(content: string): SessionMessage { + const now = new Date().toISOString(); + return { + id: `local-system-${Math.random().toString(36).slice(2)}`, + sessionId: "local", + role: "system", + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }; +} + export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft { return { nonce, diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index d2af534..fb68929 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -667,6 +667,15 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "install") { + onSubmit({ text: "/install", imageUrls: [], command: "install" }); + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 7e740d1..4d83669 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -8,18 +8,22 @@ import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescripti import { ThemedGradient } from "./ThemedGradient"; import { AsciiLogo } from "../AsciiArt"; import { useAppContext } from "./contexts"; +import type { BashToolingStatus } from "../common/bash-tooling"; +import { formatBashToolingStatus } from "../common/bash-tooling"; type WelcomeScreenProps = { projectRoot: string; settings: ResolvedDeepcodingSettings; skills: SkillInfo[]; + bashToolingStatus: BashToolingStatus; width: number; }; const TITLE_PANEL_WIDTH = 70; -const PANEL_CONTENT_HEIGHT = 8; +const PANEL_CONTENT_HEIGHT = 9; const SHORTCUT_TIPS = [ + { label: "rg + jq", description: "Install ripgrep and jq to make Bash project exploration faster and denser" }, { label: "Enter", description: "Send the prompt" }, { label: "Shift+Enter", description: "Insert a newline" }, { label: "Ctrl+V", description: "Paste an image from the clipboard" }, @@ -28,7 +32,13 @@ const SHORTCUT_TIPS = [ { label: "Ctrl+D twice", description: "Quit Deep Code CLI" }, ]; -export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeScreenProps): React.ReactElement { +export function WelcomeScreen({ + projectRoot, + settings, + skills, + bashToolingStatus, + width, +}: WelcomeScreenProps): React.ReactElement { const { version } = useAppContext(); const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); @@ -64,6 +74,7 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS + diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 6d9b7cc..0a55ae4 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -10,6 +10,7 @@ export type SlashCommandKind = | "continue" | "undo" | "mcp" + | "install" | "raw" | "exit"; @@ -71,6 +72,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/mcp", description: "Show MCP server status and available tools", }, + { + kind: "install", + name: "install", + label: "/install", + description: "Install missing Bash exploration tools: rg and jq", + }, { kind: "raw", name: "raw",